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-07-14 13:54:28 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/otpclient (Old) and /work/SRC/openSUSE:Factory/.otpclient.new.1991 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "otpclient" Tue Jul 14 13:54:28 2026 rev:51 rq:1365607 version:5.1.4 Changes: -------- --- /work/SRC/openSUSE:Factory/otpclient/otpclient.changes 2026-06-30 15:14:57.783151476 +0200 +++ /work/SRC/openSUSE:Factory/.otpclient.new.1991/otpclient.changes 2026-07-14 13:57:01.701595758 +0200 @@ -1,0 +2,42 @@ +Fri Jul 10 10:02:17 UTC 2026 - Paolo Stivanin <[email protected]> + +- Update to 5.1.4: + * FIX: a database containing a token with an out-of-range digit + count or period refused to open, locking you out of all of your + tokens. The accepted ranges now match the OTP engine exactly + (digits 4 to 10, period 1 to 120 seconds), so such tokens load + again. Any token the engine still cannot use (out-of-range + value, invalid secret, unsupported algorithm) is set aside and + preserved in the database rather than blocking the whole load + (#464, follow-up to #458/#462) + * FIX: right-click context menus on tokens and databases were + clipped to the scrolled area and needed scrolling to reach + lower items such as "Set Group"; they now size to their + contents (#465) + +------------------------------------------------------------------- +Thu Jul 9 12:18:09 UTC 2026 - Paolo Stivanin <[email protected]> + +- Update to 5.1.3: + * FIX: a database (or import) containing a token with neither an + account name nor an issuer refused to open on 5.1.x with "Token + N has neither a label nor an issuer", locking you out of all of + your tokens. Such tokens now load with a synthesized Unknown N + placeholder label instead of being rejected, so the database + opens and the token stays usable and editable. Interactive + manual entry and editing still require a name (#462, follow-up + to #458) + +------------------------------------------------------------------- +Wed Jul 8 09:17:06 UTC 2026 - Paolo Stivanin <[email protected]> + +- Update to 5.1.2: + * FIX: screensaver, session lock, and suspend now respect the Auto- + Lock setting. With Auto-Lock off, the app no longer locks the + database on every screen lock and then leaves you behind a manual + password prompt. The mutual exclusivity between Auto-Lock and + Secret Service is also restored, enabling one greys out the + other, and a legacy profile with both enabled is reconciled on + startup (#460, re-report of #279) + +------------------------------------------------------------------- Old: ---- v5.1.1.tar.gz v5.1.1.tar.gz.asc New: ---- v5.1.4.tar.gz v5.1.4.tar.gz.asc ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ otpclient.spec ++++++ --- /var/tmp/diff_new_pack.E0btL7/_old 2026-07-14 13:57:02.569625476 +0200 +++ /var/tmp/diff_new_pack.E0btL7/_new 2026-07-14 13:57:02.573625613 +0200 @@ -18,7 +18,7 @@ %define uclname OTPClient Name: otpclient -Version: 5.1.1 +Version: 5.1.4 Release: 0 Summary: Simple GTK+ client for managing TOTP and HOTP License: GPL-3.0-or-later ++++++ _scmsync.obsinfo ++++++ --- /var/tmp/diff_new_pack.E0btL7/_old 2026-07-14 13:57:02.641627941 +0200 +++ /var/tmp/diff_new_pack.E0btL7/_new 2026-07-14 13:57:02.649628214 +0200 @@ -1,5 +1,5 @@ -mtime: 1782805223 -commit: 5e0abec62dd086beb5a6c336598ffd44e909c3fbf58727be47043db47b3f1e4a +mtime: 1783677767 +commit: 04838f01d636678220ee57cb23fc179016d264a8d2db38d44d75884e0a1996f0 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-07-10 12:02:47.000000000 +0200 @@ -0,0 +1,4 @@ +*.obscpio +*.osc +_build.* +.pbuild ++++++ v5.1.1.tar.gz -> v5.1.4.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/CMakeLists.txt new/OTPClient-5.1.4/CMakeLists.txt --- old/OTPClient-5.1.1/CMakeLists.txt 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/CMakeLists.txt 2026-07-10 11:41:32.000000000 +0200 @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.25) -project(OTPClient VERSION "5.1.1" LANGUAGES "C") +project(OTPClient VERSION "5.1.4" LANGUAGES "C") include(GNUInstallDirs) include(CTest) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/data/com.github.paolostivanin.OTPClient.appdata.xml new/OTPClient-5.1.4/data/com.github.paolostivanin.OTPClient.appdata.xml --- old/OTPClient-5.1.1/data/com.github.paolostivanin.OTPClient.appdata.xml 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/data/com.github.paolostivanin.OTPClient.appdata.xml 2026-07-10 11:41:32.000000000 +0200 @@ -26,7 +26,7 @@ <li>desktop search provider for GNOME Shell and KDE KRunner (opt-in trigger keyword)</li> <li>command-line companion (otpclient-cli) with scriptable table/JSON/CSV output</li> <li>support for TOTP, HOTP, and Steam codes</li> - <li>configurable digits (4 to 10), period (10 to 120 seconds), and algorithm (SHA1, SHA256, SHA512)</li> + <li>configurable digits (4 to 10), period (1 to 120 seconds), and algorithm (SHA1, SHA256, SHA512)</li> <li>import and export of encrypted/plain Aegis backups</li> <li>import and export of encrypted/plain Authenticator Pro and 2FAS backups</li> <li>import and export of plain FreeOTP+ backups (key URI format)</li> @@ -90,6 +90,31 @@ </content_rating> <releases> + <release version="5.1.4" date="2026-07-10"> + <description> + <p>Bug-fix release. Following the anonymous-token fix in 5.1.3, a database containing a token with an out-of-range digit count still refused to open, again locking users out of every other token. The load-time validator was stricter than the OTP engine and the app's own UI; the accepted ranges now match the engine (digits 4 to 10, period 1 to 120 seconds). Any token the engine still cannot use is set aside so the rest of the database opens, and is preserved for repair rather than dropped.</p> + <ul> + <li>FIX: a database containing a token with an out-of-range digit count (or period) refused to open, locking users out of all their tokens. Accepted ranges now match the OTP engine (digits 4 to 10, period 1 to 120), and any token that still cannot be loaded is kept aside and preserved instead of bricking the whole database (#464, follow-up to #458/#462)</li> + <li>FIX: right-click context menus on tokens and databases were clipped to the scrolled area and needed scrolling to reach lower items such as "Set Group"; they now size to their contents (#465)</li> + </ul> + </description> + </release> + <release version="5.1.3" date="2026-07-09"> + <description> + <p>Bug-fix release. Following 5.1.1, which restored issuer-only tokens, a database containing a token with neither an account name nor an issuer still refused to open, and a single such token blocked every other token from loading. These anonymous tokens, common in databases created before 5.1.0 added load-time validation, now load with a synthesized placeholder label instead of being rejected.</p> + <ul> + <li>FIX: a database (or import) containing a token with neither an account name nor an issuer refused to open on 5.1.x, locking users out of all their tokens. Such tokens now load with a synthesized "Unknown N" label; interactive manual entry and editing still require a name (#462, follow-up to #458)</li> + </ul> + </description> + </release> + <release version="5.1.2" date="2026-07-07"> + <description> + <p>Bug-fix release. Screen and session locking, and locking on system suspend, now honor the Auto-Lock setting the same way the inactivity timer already did, so with Auto-Lock off the database is no longer locked on every screen lock. The mutual exclusivity between Auto-Lock and the Secret Service integration is restored.</p> + <ul> + <li>FIX: screensaver, session lock, and suspend now respect the Auto-Lock setting, so with Auto-Lock off the app no longer locks and then prompts for a password on every screen lock; Auto-Lock and Secret Service are mutually exclusive again, including reconciliation of a legacy both-on profile (#460, re-report of #279)</li> + </ul> + </description> + </release> <release version="5.1.1" date="2026-06-30"> <description> <p>Bug-fix release. Databases containing a token with an issuer but no account name (for example some ProtonMail or Steam entries) failed to open on 5.1.0 with "Could not open database: Token has a missing label", locking users out of the entire database. Such tokens are valid again.</p> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/cli/exec-action.c new/OTPClient-5.1.4/src/cli/exec-action.c --- old/OTPClient-5.1.1/src/cli/exec-action.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/cli/exec-action.c 2026-07-10 11:41:32.000000000 +0200 @@ -211,6 +211,12 @@ g_clear_error (&err); return FALSE; } + /* Issue #464: broken tokens are set aside so the database still opens. */ + guint quarantined = db_get_quarantined_count (db_data); + if (quarantined > 0) + g_printerr (ngettext ("Warning: %u token could not be loaded and was skipped.\n", + "Warning: %u tokens could not be loaded and were skipped.\n", + quarantined), quarantined); } if (use_secret_service == TRUE && secret_service_runtime_ok && diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/common/authpro.c new/OTPClient-5.1.4/src/common/authpro.c --- old/OTPClient-5.1.1/src/common/authpro.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/common/authpro.c 2026-07-10 11:41:32.000000000 +0200 @@ -396,8 +396,9 @@ otp->account_name = g_strdup (json_string_value (json_object_get (obj, "Username"))); otp->secret = secure_strdup (secret_str); otp->digits = (guint32)json_integer_value (json_object_get(obj, "Digits")); - otp->counter = json_integer_value (json_object_get (obj, "Counter")); - otp->period = (guint32)json_integer_value (json_object_get (obj, "Period")); + /* period and counter share a union, so only the member matching the + * token type may be written; it is set per-type in the switch below. + * Writing both here clobbered the HOTP counter with the Period. */ gboolean skip = FALSE; guint32 algo = (guint32)json_integer_value (json_object_get(obj, "Algorithm")); @@ -421,12 +422,15 @@ switch (type) { case 1: otp->type = g_strdup ("HOTP"); + otp->counter = json_integer_value (json_object_get (obj, "Counter")); break; case 2: otp->type = g_strdup ("TOTP"); + otp->period = (guint32)json_integer_value (json_object_get (obj, "Period")); break; case 4: otp->type = g_strdup ("TOTP"); + otp->period = (guint32)json_integer_value (json_object_get (obj, "Period")); g_free (otp->issuer); otp->issuer = g_strdup ("Steam"); break; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/common/db-common.c new/OTPClient-5.1.4/src/common/db-common.c --- old/OTPClient-5.1.1/src/common/db-common.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/common/db-common.c 2026-07-10 11:41:32.000000000 +0200 @@ -112,6 +112,9 @@ static void restore_live_from_committed (DatabaseData *db_data); +static gboolean partition_valid_tokens (DatabaseData *db_data, + GError **err); + void db_invalidate_kdf_cache (DatabaseData *db_data) @@ -149,6 +152,10 @@ json_decref (db_data->committed_json_data); db_data->committed_json_data = NULL; } + if (db_data->quarantined_tokens != NULL) { + json_decref (db_data->quarantined_tokens); + db_data->quarantined_tokens = NULL; + } g_slist_free_full (db_data->data_to_add, (GDestroyNotify) json_decref); db_data->data_to_add = NULL; @@ -392,6 +399,43 @@ } +guint +db_get_quarantined_count (DatabaseData *db_data) +{ + if (db_data == NULL || db_data->quarantined_tokens == NULL) + return 0; + return (guint) json_array_size (db_data->quarantined_tokens); +} + + +/* Load-time policy: never let one bad token brick the whole database. Move every + * token that fails validation into db_data->quarantined_tokens (preserved and + * re-merged on save, surfaced in the UI for repair) so the remaining valid + * tokens open normally. Still fails hard when the root is not a JSON array (the + * file is structurally corrupt rather than merely holding a bad entry). Rebuilds + * quarantined_tokens from scratch each call so the migration re-decrypt pass + * does not double-count. */ +static gboolean +partition_valid_tokens (DatabaseData *db_data, + GError **err) +{ + if (!json_is_array (db_data->in_memory_json_data)) { + g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, + "Database JSON root must be an array."); + return FALSE; + } + if (db_data->quarantined_tokens != NULL) + json_decref (db_data->quarantined_tokens); + db_data->quarantined_tokens = json_array (); + guint set_aside = otp_extract_invalid_tokens (db_data->in_memory_json_data, + db_data->quarantined_tokens); + if (set_aside > 0) + g_info ("Set aside %u token(s) that failed validation; opened the " + "database with the remaining valid tokens.", set_aside); + return TRUE; +} + + void load_db (DatabaseData *db_data, GError **err) @@ -432,7 +476,13 @@ "Error while loading json data: %s", jerr.text); return; } - if (!otp_validate_database_root (db_data->in_memory_json_data, err)) + /* Give any anonymous token (neither label nor issuer) a placeholder so a + * single such entry does not make the whole database refuse to open + * (issue #462). The names live in memory and persist on the next save. */ + guint repaired = otp_repair_database_root (db_data->in_memory_json_data); + if (repaired > 0) + g_info ("Assigned placeholder labels to %u anonymous token(s) on load.", repaired); + if (!partition_valid_tokens (db_data, err)) return; if (db_data->current_db_version < DB_VERSION || db_data->needs_legacy_kdf_migration) { @@ -459,7 +509,8 @@ "Error while loading json data: %s", jerr.text); return; } - if (!otp_validate_database_root (db_data->in_memory_json_data, err)) + otp_repair_database_root (db_data->in_memory_json_data); + if (!partition_valid_tokens (db_data, err)) return; } @@ -726,9 +777,13 @@ OtpImportReport *report = ctx->report != NULL ? ctx->report : &local; *report = (OtpImportReport) {0, 0, 0}; - for (GSList *l = ctx->otps; l != NULL; l = l->next) { + gsize import_index = 0; + for (GSList *l = ctx->otps; l != NULL; l = l->next, import_index++) { otp_t *otp = l->data; GError *validation_err = NULL; + /* Keep an anonymous imported token instead of dropping it (issue #462); + * a no-op if a parse-time repair already named it. */ + otp_repair_anonymous_import_token (otp, import_index); if (!otp_validate_import_token (otp, &validation_err)) { if (validation_err != NULL) { g_printerr ("Skipping invalid imported token: %s\n", validation_err->message); @@ -851,9 +906,13 @@ guint added = 0, skipped = 0; GSList *new_hashes = NULL; GSList *new_data = NULL; - for (GSList *l = otps; l != NULL; l = l->next) { + gsize import_index = 0; + for (GSList *l = otps; l != NULL; l = l->next, import_index++) { otp_t *otp = l->data; GError *validation_err = NULL; + /* Keep an anonymous imported token instead of dropping it (issue #462); + * a no-op if a parse-time repair already named it. */ + otp_repair_anonymous_import_token (otp, import_index); if (!otp_validate_import_token (otp, &validation_err)) { if (validation_err != NULL) { g_printerr ("Skipping invalid imported token: %s\n", validation_err->message); @@ -1497,9 +1556,34 @@ return FALSE; } - gsize input_data_len = json_dumpb (json_data, NULL, 0, JSON_COMPACT); + // Preserve tokens set aside on load because they failed validation (issue + // #464): they are kept out of the live in-memory set, so merge them back in + // for serialization only, leaving the caller's json_data untouched. merged is + // a shallow copy referencing the same token objects, so freeing it never + // touches the underlying secrets. + json_t *to_dump = json_data; + json_t *merged = NULL; + if (db_data->quarantined_tokens != NULL && + json_array_size (db_data->quarantined_tokens) > 0) { + merged = json_copy (json_data); + if (merged == NULL) { + g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, + "Failed to merge preserved tokens for serialization."); + explicit_bzero (derived_key, ARGON2ID_KEYLEN); + gcry_free (derived_key); + return FALSE; + } + gsize q_idx; + json_t *q_obj; + json_array_foreach (db_data->quarantined_tokens, q_idx, q_obj) + json_array_append (merged, q_obj); + to_dump = merged; + } + + gsize input_data_len = json_dumpb (to_dump, NULL, 0, JSON_COMPACT); if (input_data_len == 0) { g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Failed to serialize the in-memory database."); + if (merged != NULL) json_decref (merged); explicit_bzero (derived_key, ARGON2ID_KEYLEN); gcry_free (derived_key); return FALSE; @@ -1508,17 +1592,20 @@ if (in_memory_dumped_data == NULL) { g_set_error (err, secmem_alloc_error_gquark (), SECMEM_ALLOC_ERRCODE, "Failed to allocate secure memory for serialized database."); + if (merged != NULL) json_decref (merged); explicit_bzero (derived_key, ARGON2ID_KEYLEN); gcry_free (derived_key); return FALSE; } - if (json_dumpb (json_data, in_memory_dumped_data, input_data_len, JSON_COMPACT) == (size_t) -1) { + if (json_dumpb (to_dump, in_memory_dumped_data, input_data_len, JSON_COMPACT) == (size_t) -1) { g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Failed to serialize the in-memory database."); + if (merged != NULL) json_decref (merged); gcry_free (in_memory_dumped_data); explicit_bzero (derived_key, ARGON2ID_KEYLEN); gcry_free (derived_key); return FALSE; } + if (merged != NULL) json_decref (merged); guchar *enc_buffer = g_malloc0 (input_data_len); // C3 fix: in_memory_dumped_data holds the plaintext database (every secret). diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/common/db-common.h new/OTPClient-5.1.4/src/common/db-common.h --- old/OTPClient-5.1.1/src/common/db-common.h 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/common/db-common.h 2026-07-10 11:41:32.000000000 +0200 @@ -65,6 +65,13 @@ json_t *committed_json_data; + /* Tokens that failed validation on load (out-of-range digits/period, invalid + * secret, unsupported algo, ...). Kept aside instead of bricking the whole + * database (issues #458/#462/#464): the valid tokens load, these are preserved + * verbatim and re-merged into the file on every save, and the UI surfaces them + * for the user to repair or delete. NULL until load runs. */ + json_t *quarantined_tokens; + GSList *objects_hash; GSList *data_to_add; @@ -125,6 +132,10 @@ void database_data_free (DatabaseData *db_data); +/* Number of tokens set aside on load because they failed validation (preserved + * in the file and surfaced to the user for repair). 0 in the normal case. */ +guint db_get_quarantined_count (DatabaseData *db_data); + void load_db (DatabaseData *db_data, GError **error); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/common/otp-validation.c new/OTPClient-5.1.4/src/common/otp-validation.c --- old/OTPClient-5.1.1/src/common/otp-validation.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/common/otp-validation.c 2026-07-10 11:41:32.000000000 +0200 @@ -36,6 +36,17 @@ return TRUE; } +/* Placeholder name for a token that arrives with neither a label nor an issuer. + * Not translated: it is written into stored token data and must stay stable + * regardless of the locale the database is later opened under. The index keeps + * multiple placeholders distinct and mirrors the "Token N" position that + * validation reports, so the label matches the diagnostic users used to see. */ +static gchar * +otp_anonymous_placeholder (gsize index) +{ + return g_strdup_printf ("Unknown %" G_GSIZE_FORMAT, index); +} + gboolean otp_validate_token_object (json_t *obj, gsize index, @@ -87,7 +98,7 @@ return FALSE; } json_int_t digits = json_integer_value (digits_obj); - if (digits < 6 || digits > 8) { + if (digits < OTP_DIGITS_MIN || digits > OTP_DIGITS_MAX) { g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Token %" G_GSIZE_FORMAT " has an out-of-range digits value.", index); return FALSE; @@ -108,7 +119,7 @@ return FALSE; } json_int_t period = json_integer_value (period_obj); - if (period <= 0 || period > 300) { + if (period < OTP_PERIOD_MIN || period > OTP_PERIOD_MAX) { g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Token %" G_GSIZE_FORMAT " has an out-of-range TOTP period.", index); return FALSE; @@ -183,13 +194,13 @@ "Import token has an unsupported or missing algorithm."); return FALSE; } - if (otp->digits < 6 || otp->digits > 8) { + if (otp->digits < OTP_DIGITS_MIN || otp->digits > OTP_DIGITS_MAX) { g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Import token has an out-of-range digits value."); return FALSE; } if (g_ascii_strcasecmp (otp->type, "TOTP") == 0) { - if (otp->period == 0 || otp->period > 300) { + if (otp->period < OTP_PERIOD_MIN || otp->period > OTP_PERIOD_MAX) { g_set_error (err, generic_error_gquark (), GENERIC_ERRCODE, "Import token has an out-of-range TOTP period."); return FALSE; @@ -201,3 +212,84 @@ } return TRUE; } + +gboolean +otp_repair_anonymous_token_object (json_t *obj, + gsize index) +{ + if (!json_is_object (obj)) + return FALSE; + + const gchar *label = json_string_value (json_object_get (obj, "label")); + const gchar *issuer = json_string_value (json_object_get (obj, "issuer")); + gboolean has_label = (label != NULL && label[0] != '\0'); + gboolean has_issuer = (issuer != NULL && issuer[0] != '\0'); + if (has_label || has_issuer) + return FALSE; + + gchar *placeholder = otp_anonymous_placeholder (index); + json_object_set_new (obj, "label", json_string (placeholder)); + g_free (placeholder); + return TRUE; +} + +guint +otp_repair_database_root (json_t *root) +{ + if (!json_is_array (root)) + return 0; + + guint repaired = 0; + gsize index; + json_t *obj; + json_array_foreach (root, index, obj) { + if (otp_repair_anonymous_token_object (obj, index)) + repaired++; + } + return repaired; +} + +gboolean +otp_repair_anonymous_import_token (otp_t *otp, + gsize index) +{ + if (otp == NULL) + return FALSE; + + gboolean has_label = (otp->account_name != NULL && otp->account_name[0] != '\0'); + gboolean has_issuer = (otp->issuer != NULL && otp->issuer[0] != '\0'); + if (has_label || has_issuer) + return FALSE; + + /* account_name is non-sensitive and freed with g_free across the importers, + * so g_strdup keeps the allocator contract (even while Jansson's global + * allocator is gcry secure memory). */ + g_free (otp->account_name); + otp->account_name = otp_anonymous_placeholder (index); + return TRUE; +} + +guint +otp_extract_invalid_tokens (json_t *root, + json_t *invalid_out) +{ + if (!json_is_array (root) || !json_is_array (invalid_out)) + return 0; + + guint moved = 0; + /* Walk backwards so removing an entry never shifts an index we still have to + * visit. Take a reference in invalid_out before dropping root's, so the + * token object survives the move. */ + for (gsize i = json_array_size (root); i > 0; i--) { + gsize idx = i - 1; + json_t *obj = json_array_get (root, idx); + GError *verr = NULL; + if (otp_validate_token_object (obj, idx, &verr)) + continue; + g_clear_error (&verr); + json_array_append (invalid_out, obj); + json_array_remove (root, idx); + moved++; + } + return moved; +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/common/otp-validation.h new/OTPClient-5.1.4/src/common/otp-validation.h --- old/OTPClient-5.1.1/src/common/otp-validation.h 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/common/otp-validation.h 2026-07-10 11:41:32.000000000 +0200 @@ -2,6 +2,7 @@ #include <glib.h> #include <jansson.h> +#include <cotp.h> #include "common.h" G_BEGIN_DECLS @@ -9,11 +10,37 @@ /* Exclusive upper bound: valid HOTP counters satisfy counter < 2^48. */ #define OTP_HOTP_COUNTER_MAX ((guint64) (1ULL << 48)) +/* Accepted OTP field ranges, kept in lockstep with what libcotp can actually + * generate (it hard-rejects out-of-range input, it never clamps). digits comes + * straight from libcotp's MIN_DIGITS/MAX_DIGITS. libcotp does not yet export + * period bounds, so we fall back to its check_period() range (1 to 120) until it + * does, at which point these pick the macros up automatically. Making the + * validator stricter than these is what locked users out in #464 (digits); + * making it looser than the engine (the old period ceiling of 300) admits + * tokens libcotp then refuses to generate. */ +#define OTP_DIGITS_MIN MIN_DIGITS +#define OTP_DIGITS_MAX MAX_DIGITS +#ifndef MIN_PERIOD +#define MIN_PERIOD 1 +#endif +#ifndef MAX_PERIOD +#define MAX_PERIOD 120 +#endif +#define OTP_PERIOD_MIN MIN_PERIOD +#define OTP_PERIOD_MAX MAX_PERIOD + /* Validation policy: * - Loaded databases are validated as a full root array before becoming live. * - Imported tokens are validated before entering a transaction. * - Direct generators (CLI/search provider) and GUI add/edit flows validate * again at their local boundary so future bypasses fail closed. + * + * Repair policy (issue #462): + * - A token with neither a label nor an issuer is anonymous. Rejecting one + * aborts the whole load, locking the user out of every other token. On the + * non-interactive load and file/URI import paths we instead synthesize a + * placeholder label so the token stays usable and editable. Interactive + * entry (manual-add, edit) keeps rejecting: the user is right there to fix it. */ gboolean otp_secret_is_valid_base32 (const gchar *secret); @@ -28,4 +55,27 @@ gboolean otp_validate_import_token (const otp_t *otp, GError **err); +/* Give an anonymous token (neither label nor issuer) a synthesized "Unknown N" + * label. Idempotent: a token that already has a label or issuer is left as-is. + * Returns TRUE when a placeholder was assigned. */ +gboolean otp_repair_anonymous_token_object (json_t *obj, + gsize index); + +/* Repair every anonymous token in a database root array in place. Returns the + * number of tokens that received a placeholder. */ +guint otp_repair_database_root (json_t *root); + +/* Same as otp_repair_anonymous_token_object for an in-flight import token, + * writing the placeholder into otp->account_name. */ +gboolean otp_repair_anonymous_import_token (otp_t *otp, + gsize index); + +/* Move every token that fails otp_validate_token_object out of `root` and into + * `invalid_out` (both must be JSON arrays), preserving it for later repair + * instead of failing the whole load. Run it after otp_repair_database_root so an + * anonymous-but-otherwise-valid token is kept, not quarantined. Returns the + * number of tokens moved; `root` is left holding only tokens that validate. */ +guint otp_extract_invalid_tokens (json_t *root, + json_t *invalid_out); + G_END_DECLS diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/common/parse-uri.c new/OTPClient-5.1.4/src/common/parse-uri.c --- old/OTPClient-5.1.1/src/common/parse-uri.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/common/parse-uri.c 2026-07-10 11:41:32.000000000 +0200 @@ -241,14 +241,14 @@ if (period != NULL) { gchar *endptr = NULL; gint64 v = g_ascii_strtoll (period, &endptr, 10); - if (endptr != period && *endptr == '\0' && v > 0 && v <= 300) + if (endptr != period && *endptr == '\0' && v >= OTP_PERIOD_MIN && v <= OTP_PERIOD_MAX) otp->period = (guint32) v; } const gchar *digits = g_hash_table_lookup (params, "digits"); if (digits != NULL) { gchar *endptr = NULL; gint64 v = g_ascii_strtoll (digits, &endptr, 10); - if (endptr != digits && *endptr == '\0' && v >= 6 && v <= 8) + if (endptr != digits && *endptr == '\0' && v >= OTP_DIGITS_MIN && v <= OTP_DIGITS_MAX) otp->digits = (guint32) v; } const gchar *counter = g_hash_table_lookup (params, "counter"); @@ -262,6 +262,9 @@ } GError *validation_err = NULL; + /* Name an anonymous token rather than rejecting it (issue #462); the + * commit path leaves it as-is once it carries a label. */ + otp_repair_anonymous_import_token (otp, g_slist_length (*otps)); if (!otp_validate_import_token (otp, &validation_err)) { if (validation_err != NULL) g_clear_error (&validation_err); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/common/twofas.c new/OTPClient-5.1.4/src/common/twofas.c --- old/OTPClient-5.1.1/src/common/twofas.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/common/twofas.c 2026-07-10 11:41:32.000000000 +0200 @@ -287,8 +287,9 @@ g_set_error (&err, generic_error_gquark (), GENERIC_ERRCODE, "Couldn't encrypt the reference data."); goto end; } + // json_string() copies the C string, it does not take ownership, so + // encoded_ref_data still has to be freed by us (done at `end:`). json_object_set_new (enc_root, "reference", json_string (encoded_ref_data)); - encoded_ref_data = NULL; // ownership transferred via json_string json_enc_data = json_dumps (enc_root, JSON_COMPACT); if (json_enc_data == NULL) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/gui/dialogs/manual-add-dialog.c new/OTPClient-5.1.4/src/gui/dialogs/manual-add-dialog.c --- old/OTPClient-5.1.1/src/gui/dialogs/manual-add-dialog.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/gui/dialogs/manual-add-dialog.c 2026-07-10 11:41:32.000000000 +0200 @@ -352,7 +352,7 @@ adw_preferences_group_add (ADW_PREFERENCES_GROUP (settings_group), self->algo_combo); /* Digits */ - GtkAdjustment *digits_adj = gtk_adjustment_new (6, 4, 10, 1, 1, 0); + GtkAdjustment *digits_adj = gtk_adjustment_new (6, OTP_DIGITS_MIN, OTP_DIGITS_MAX, 1, 1, 0); self->digits_spin = adw_spin_row_new (digits_adj, 1, 0); adw_preferences_row_set_title (ADW_PREFERENCES_ROW (self->digits_spin), _("Digits")); gtk_widget_set_tooltip_text (self->digits_spin, @@ -360,7 +360,7 @@ adw_preferences_group_add (ADW_PREFERENCES_GROUP (settings_group), self->digits_spin); /* Period (TOTP) */ - GtkAdjustment *period_adj = gtk_adjustment_new (30, 1, 300, 1, 10, 0); + GtkAdjustment *period_adj = gtk_adjustment_new (30, OTP_PERIOD_MIN, OTP_PERIOD_MAX, 1, 10, 0); self->period_spin = adw_spin_row_new (period_adj, 1, 0); adw_preferences_row_set_title (ADW_PREFERENCES_ROW (self->period_spin), _("Period (seconds)")); gtk_widget_set_tooltip_text (self->period_spin, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/gui/dialogs/settings-dialog.c new/OTPClient-5.1.4/src/gui/dialogs/settings-dialog.c --- old/OTPClient-5.1.1/src/gui/dialogs/settings-dialog.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/gui/dialogs/settings-dialog.c 2026-07-10 11:41:32.000000000 +0200 @@ -34,6 +34,25 @@ G_DEFINE_FINAL_TYPE (SettingsDialog, settings_dialog, ADW_TYPE_PREFERENCES_DIALOG) +/* Forward declarations: the Auto-Lock and Secret Service handlers each need to + * switch the other off, since the two are mutually exclusive (#279). */ +static void on_auto_lock_toggled (GObject *obj, GParamSpec *pspec, SettingsDialog *self); +static void on_secret_service_toggled (GObject *obj, GParamSpec *pspec, SettingsDialog *self); + +static void +sync_lock_exclusivity (SettingsDialog *self) +{ + gboolean auto_lock = adw_switch_row_get_active (ADW_SWITCH_ROW (self->auto_lock_switch)); + gboolean secret_service = adw_switch_row_get_active (ADW_SWITCH_ROW (self->secret_service_switch)); + + /* A lock is meaningless when the password lives in the keyring (it is + * bypassed by a restart), so whichever feature is on greys out and disables + * the other. */ + gtk_widget_set_sensitive (self->auto_lock_switch, !secret_service); + gtk_widget_set_sensitive (self->secret_service_switch, !auto_lock); + gtk_widget_set_sensitive (self->inactivity_combo, auto_lock && !secret_service); +} + static void on_show_next_otp_toggled (GObject *obj, GParamSpec *pspec, @@ -87,7 +106,20 @@ (void) pspec; gboolean active = adw_switch_row_get_active (ADW_SWITCH_ROW (obj)); otpclient_application_set_auto_lock (self->app, active); - gtk_widget_set_sensitive (self->inactivity_combo, active); + + /* Enabling Auto-Lock turns Secret Service off (#279). */ + if (active && + adw_switch_row_get_active (ADW_SWITCH_ROW (self->secret_service_switch))) + { + g_signal_handlers_block_by_func (self->secret_service_switch, + on_secret_service_toggled, self); + adw_switch_row_set_active (ADW_SWITCH_ROW (self->secret_service_switch), FALSE); + g_signal_handlers_unblock_by_func (self->secret_service_switch, + on_secret_service_toggled, self); + otpclient_application_set_use_secret_service (self->app, FALSE); + } + + sync_lock_exclusivity (self); } static void @@ -130,6 +162,7 @@ if (!active) { otpclient_application_set_use_secret_service (self->app, FALSE); + sync_lock_exclusivity (self); return; } @@ -140,6 +173,19 @@ GError *err = NULL; if (otpclient_secret_service_probe (&err)) { otpclient_application_set_use_secret_service (self->app, TRUE); + + /* Enabling Secret Service turns Auto-Lock off (#279). */ + if (adw_switch_row_get_active (ADW_SWITCH_ROW (self->auto_lock_switch))) + { + g_signal_handlers_block_by_func (self->auto_lock_switch, + on_auto_lock_toggled, self); + adw_switch_row_set_active (ADW_SWITCH_ROW (self->auto_lock_switch), FALSE); + g_signal_handlers_unblock_by_func (self->auto_lock_switch, + on_auto_lock_toggled, self); + otpclient_application_set_auto_lock (self->app, FALSE); + } + + sync_lock_exclusivity (self); return; } @@ -513,8 +559,6 @@ break; } } - gtk_widget_set_sensitive (self->inactivity_combo, - otpclient_application_get_auto_lock (app)); g_signal_connect (self->inactivity_combo, "notify::selected", G_CALLBACK (on_inactivity_changed), self); adw_preferences_group_add (security_group, self->inactivity_combo); @@ -558,6 +602,22 @@ G_CALLBACK (on_secret_service_toggled), self); adw_preferences_group_add (security_group, self->secret_service_switch); + /* #279: Auto-Lock and Secret Service are mutually exclusive. Reconcile a + * legacy profile that has both enabled (Secret Service wins, since the lock + * is bypassable via restart when the password is in the keyring), then set + * the initial greyed-out state. */ + if (otpclient_application_get_auto_lock (app) && + otpclient_application_get_use_secret_service (app)) + { + g_signal_handlers_block_by_func (self->auto_lock_switch, + on_auto_lock_toggled, self); + adw_switch_row_set_active (ADW_SWITCH_ROW (self->auto_lock_switch), FALSE); + g_signal_handlers_unblock_by_func (self->auto_lock_switch, + on_auto_lock_toggled, self); + otpclient_application_set_auto_lock (self->app, FALSE); + } + sync_lock_exclusivity (self); + /* Integration group */ AdwPreferencesGroup *integration_group = ADW_PREFERENCES_GROUP (adw_preferences_group_new ()); adw_preferences_group_set_title (integration_group, _("Integration")); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/gui/google-migration.c new/OTPClient-5.1.4/src/gui/google-migration.c --- old/OTPClient-5.1.1/src/gui/google-migration.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/gui/google-migration.c 2026-07-10 11:41:32.000000000 +0200 @@ -165,6 +165,8 @@ sensitive_free (base32); GError *validation_error = NULL; + /* Name an anonymous token rather than dropping it (issue #462). */ + otp_repair_anonymous_import_token (otp, g_slist_length (result)); if (otp->secret == NULL || !otp_validate_import_token (otp, &validation_error)) { g_clear_error (&validation_error); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/gui/lock-app.c new/OTPClient-5.1.4/src/gui/lock-app.c --- old/OTPClient-5.1.1/src/gui/lock-app.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/gui/lock-app.c 2026-07-10 11:41:32.000000000 +0200 @@ -177,7 +177,11 @@ gboolean active = FALSE; g_variant_get (parameters, "(b)", &active); - if (active) + + /* Only auto-lock on session lock when Auto-Lock is enabled, matching the + * inactivity timer. Without this guard the app locks on every screen lock + * even with Auto-Lock off (#460, a re-report of #279). */ + if (active && otpclient_application_get_auto_lock (app)) lock_app_lock (app); } @@ -196,10 +200,13 @@ (void) interface_name; (void) signal_name; + OTPClientApplication *app = OTPCLIENT_APPLICATION (user_data); gboolean preparing = FALSE; g_variant_get (parameters, "(b)", &preparing); - if (preparing) - lock_app_lock (OTPCLIENT_APPLICATION (user_data)); + + /* Respect Auto-Lock here too (see on_screensaver_signal / #460). */ + if (preparing && otpclient_application_get_auto_lock (app)) + lock_app_lock (app); } static gboolean diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/gui/otp-entry.c new/OTPClient-5.1.4/src/gui/otp-entry.c --- old/OTPClient-5.1.1/src/gui/otp-entry.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/gui/otp-entry.c 2026-07-10 11:41:32.000000000 +0200 @@ -4,6 +4,7 @@ #include <limits.h> #include "common.h" #include "otp-entry.h" +#include "otp-validation.h" struct _OTPEntry { @@ -256,7 +257,7 @@ g_param_spec_string ("otp-type", NULL, NULL, "TOTP", G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS); properties[PROP_PERIOD] = - g_param_spec_uint ("period", NULL, NULL, 1, 300, 30, + g_param_spec_uint ("period", NULL, NULL, OTP_PERIOD_MIN, OTP_PERIOD_MAX, 30, G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS); properties[PROP_COUNTER] = g_param_spec_uint64 ("counter", NULL, NULL, 0, G_MAXUINT64, 0, @@ -265,7 +266,7 @@ g_param_spec_string ("algorithm", NULL, NULL, "SHA1", G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS); properties[PROP_DIGITS] = - g_param_spec_uint ("digits", NULL, NULL, 4, 10, 6, + g_param_spec_uint ("digits", NULL, NULL, OTP_DIGITS_MIN, OTP_DIGITS_MAX, 6, G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS); properties[PROP_SECRET] = g_param_spec_string ("secret", NULL, NULL, NULL, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/gui/otpclient-application.c new/OTPClient-5.1.4/src/gui/otpclient-application.c --- old/OTPClient-5.1.1/src/gui/otpclient-application.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/gui/otpclient-application.c 2026-07-10 11:41:32.000000000 +0200 @@ -726,6 +726,20 @@ otpclient_window_hide_loading (self->window); otpclient_window_start_otp_timer (self->window); otpclient_window_sync_active_flag (self->window); + + /* Issue #464: tokens that failed validation were set aside so the rest + * of the database could open. Tell the user they are preserved; the + * dedicated repair dialog arrives in 5.2.0. */ + guint quarantined = db_get_quarantined_count (self->db_data); + if (quarantined > 0) + { + g_autofree gchar *msg = g_strdup_printf ( + ngettext ("%u token could not be loaded and was kept for repair", + "%u tokens could not be loaded and were kept for repair", + quarantined), + quarantined); + otpclient_window_show_error_toast (self->window, msg); + } } if (self->app_locked) lock_app_unlock (self); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/gui/otpclient-window.c new/OTPClient-5.1.4/src/gui/otpclient-window.c --- old/OTPClient-5.1.1/src/gui/otpclient-window.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/gui/otpclient-window.c 2026-07-10 11:41:32.000000000 +0200 @@ -3748,6 +3748,7 @@ } static void on_popover_closed (GtkPopover *popover, gpointer user_data); +static void popup_menu_at (GtkWidget *popover, GtkWidget *over, double x, double y); static void on_token_right_click (GtkGestureClick *gesture, @@ -3870,11 +3871,7 @@ g_object_unref (group_submenu); GtkWidget *popover = gtk_popover_menu_new_from_model (G_MENU_MODEL (menu)); - gtk_widget_set_parent (popover, self->otp_list); - GdkRectangle rect = { (int)x, (int)y, 1, 1 }; - gtk_popover_set_pointing_to (GTK_POPOVER (popover), &rect); - g_signal_connect (popover, "closed", G_CALLBACK (on_popover_closed), NULL); - gtk_popover_popup (GTK_POPOVER (popover)); + popup_menu_at (popover, self->otp_list, x, y); g_object_unref (menu); g_object_unref (builder); @@ -4338,6 +4335,37 @@ g_idle_add (popover_unparent_idle, popover); } +/* Present a context popover menu pointing at (x, y) in `over`'s coordinate + * space. The popover is parented to `over`'s nearest non-scrolled GtkBox + * ancestor, not to the list itself: a popover inside a GtkScrolledWindow is + * clipped to the scrolled viewport, which made GtkPopoverMenu scroll even for a + * handful of items (issue #465). Coordinates are translated into the anchor's + * space so the menu still points at the clicked row. */ +static void +popup_menu_at (GtkWidget *popover, + GtkWidget *over, + double x, + double y) +{ + GtkWidget *anchor = gtk_widget_get_ancestor (over, GTK_TYPE_BOX); + if (anchor == NULL) + anchor = over; + + graphene_point_t pt; + if (!gtk_widget_compute_point (over, anchor, + &GRAPHENE_POINT_INIT ((float) x, (float) y), &pt)) + { + pt.x = (float) x; + pt.y = (float) y; + } + + gtk_widget_set_parent (popover, anchor); + GdkRectangle rect = { (int) pt.x, (int) pt.y, 1, 1 }; + gtk_popover_set_pointing_to (GTK_POPOVER (popover), &rect); + g_signal_connect (popover, "closed", G_CALLBACK (on_popover_closed), NULL); + gtk_popover_popup (GTK_POPOVER (popover)); +} + static void on_db_right_click (GtkGestureClick *gesture, gint n_press, @@ -4363,11 +4391,7 @@ gtk_builder_get_object (builder, "db_context_menu")); GtkWidget *popover = gtk_popover_menu_new_from_model (menu_model); - gtk_widget_set_parent (popover, self->database_list); - GdkRectangle rect = { (int)x, (int)y, 1, 1 }; - gtk_popover_set_pointing_to (GTK_POPOVER (popover), &rect); - g_signal_connect (popover, "closed", G_CALLBACK (on_popover_closed), NULL); - gtk_popover_popup (GTK_POPOVER (popover)); + popup_menu_at (popover, self->database_list, x, y); g_object_unref (builder); } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/src/search-provider/search-provider.c new/OTPClient-5.1.4/src/search-provider/search-provider.c --- old/OTPClient-5.1.1/src/search-provider/search-provider.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/src/search-provider/search-provider.c 2026-07-10 11:41:32.000000000 +0200 @@ -531,6 +531,12 @@ return; } + /* Issue #464: broken tokens are set aside so search still works with the + * rest; note it in the journal without spamming (this reloads on a TTL). */ + guint quarantined = db_get_quarantined_count (db_data); + if (quarantined > 0) + g_info ("%u token(s) in '%s' could not be loaded and were skipped.", quarantined, db_path); + kdf_cache_capture_from_db_data (db_data, db_path); gsize index; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/tests/CMakeLists.txt new/OTPClient-5.1.4/tests/CMakeLists.txt --- old/OTPClient-5.1.1/tests/CMakeLists.txt 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/tests/CMakeLists.txt 2026-07-10 11:41:32.000000000 +0200 @@ -165,6 +165,21 @@ target_link_libraries(test_common_utils ${COMMON_LIBS}) add_test(NAME common_utils COMMAND test_common_utils) +add_executable(test_field_bounds_roundtrip + test_field_bounds_roundtrip.c + ${PROJECT_SOURCE_DIR}/src/common/common.c + ${PROJECT_SOURCE_DIR}/src/common/db-common.c + ${PROJECT_SOURCE_DIR}/src/common/file-size.c + ${PROJECT_SOURCE_DIR}/src/common/gquarks.c + ${PROJECT_SOURCE_DIR}/src/common/otp-validation.c +) +otpclient_apply_target_settings(test_field_bounds_roundtrip) +target_include_directories(test_field_bounds_roundtrip PRIVATE + ${PROJECT_SOURCE_DIR}/src/common +) +target_link_libraries(test_field_bounds_roundtrip ${COMMON_LIBS}) +add_test(NAME field_bounds_roundtrip COMMAND test_field_bounds_roundtrip) + if(BUILD_GUI) add_executable(test_otp_entry test_otp_entry.c diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/tests/README.md new/OTPClient-5.1.4/tests/README.md --- old/OTPClient-5.1.1/tests/README.md 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/tests/README.md 2026-07-10 11:41:32.000000000 +0200 @@ -74,3 +74,12 @@ token records, the hex string helpers, `secure_strdup`, and the algorithm name -> integer mapping. The validator tests pin every required-field and range check so a refactor of the validation policy can't silently relax it. + +**`test_field_bounds_roundtrip`** is the regression guard for the database-load +lockout class (#458/#462/#464). It pins the parity invariant that every digit +count and TOTP period libcotp can actually generate (4 to 10, 1 to 120) passes +both the load-time and import validators, so nothing a producer emits is later +rejected on open; that the validators still reject values the engine cannot use; +and that a database file which does contain such a token still opens - the valid +tokens load while the broken ones are set aside and preserved across a +save/reload instead of bricking the whole database. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/tests/test_db_roundtrip.c new/OTPClient-5.1.4/tests/test_db_roundtrip.c --- old/OTPClient-5.1.1/tests/test_db_roundtrip.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/tests/test_db_roundtrip.c 2026-07-10 11:41:32.000000000 +0200 @@ -209,6 +209,68 @@ cleanup_tmp_db (writer, dir, path); } +static otp_t * +anonymous_totp (void) +{ + otp_t *otp = g_new0 (otp_t, 1); + otp->type = g_strdup ("TOTP"); + otp->algo = g_strdup ("SHA1"); + otp->digits = 6; + otp->period = 30; + otp->account_name = NULL; /* neither a label ... */ + otp->issuer = NULL; /* ... nor an issuer */ + otp->secret = secure_strdup ("JBSWY3DPEHPK3PXP"); + return otp; +} + +static void +free_test_otp (otp_t *otp) +{ + g_free (otp->type); + g_free (otp->algo); + g_free (otp->account_name); + g_free (otp->issuer); + gcry_free (otp->secret); + g_free (otp); +} + +static void +test_import_anonymous_token_roundtrips (void) +{ + /* An imported token with neither a label nor an issuer used to be silently + * dropped. It now survives with a synthesized label and roundtrips through + * the real encrypt/decrypt path (issue #462). */ + gchar *path = NULL; + gchar *dir = make_tmp_db_dir (&path); + + DatabaseData *writer = make_db_data_with_path (path, "test-password"); + GError *err = NULL; + update_db (writer, &err); /* seed with alice + bob */ + g_assert_no_error (err); + + GSList *otps = g_slist_append (NULL, anonymous_totp ()); + guint added = 0, skipped = 0; + add_otps_to_db_ex (otps, writer, &added, &skipped); + g_assert_cmpuint (added, ==, 1); + g_assert_cmpuint (skipped, ==, 0); + + update_db (writer, &err); /* persist the staged import */ + g_assert_no_error (err); + + DatabaseData *reader = database_data_new (path, DEFAULT_MEMLOCK_VALUE); + reader->key = secure_strdup ("test-password"); + load_db (reader, &err); + g_assert_no_error (err); + g_assert_cmpuint (json_array_size (reader->in_memory_json_data), ==, 3); + + json_t *imported = json_array_get (reader->in_memory_json_data, 2); + g_assert_cmpstr (json_string_value (json_object_get (imported, "label")), ==, "Unknown 0"); + + g_slist_free_full (otps, (GDestroyNotify) free_test_otp); + database_data_free (reader); + cleanup_tmp_db (writer, dir, path); +} + static void test_purge_then_reload (void) { @@ -246,6 +308,7 @@ g_test_add_func ("/db-roundtrip/corrupted-ciphertext", test_corrupted_ciphertext_rejected); g_test_add_func ("/db-roundtrip/password-change", test_password_change_then_reload); g_test_add_func ("/db-roundtrip/kdf-param-update", test_kdf_param_update_then_reload); + g_test_add_func ("/db-roundtrip/import-anonymous", test_import_anonymous_token_roundtrips); g_test_add_func ("/db-roundtrip/purge-then-reload", test_purge_then_reload); return g_test_run (); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/tests/test_field_bounds_roundtrip.c new/OTPClient-5.1.4/tests/test_field_bounds_roundtrip.c --- old/OTPClient-5.1.1/tests/test_field_bounds_roundtrip.c 1970-01-01 01:00:00.000000000 +0100 +++ new/OTPClient-5.1.4/tests/test_field_bounds_roundtrip.c 2026-07-10 11:41:32.000000000 +0200 @@ -0,0 +1,200 @@ +#include <glib.h> +#include <glib/gstdio.h> +#include <jansson.h> +#include "common.h" +#include "db-common.h" +#include "otp-validation.h" +#include "gquarks.h" + +/* Regression guard for the database-load lockout class (#458/#462/#464): the + * loader validator drifted stricter than what libcotp and the app's own UI can + * produce, so a single legitimate token bricked the whole database. Two + * invariants pin it shut: + * 1. Every value in libcotp's real range passes both validators (parity), + * so nothing a producer can emit is later rejected on load. + * 2. A file that still contains tokens the engine cannot use opens anyway: + * the valid tokens load, the broken ones are quarantined and preserved + * across save/reload, and the open never fails. */ + +static json_t * +totp_obj (const gchar *label, guint digits, const gchar *algo, guint period) +{ + return build_json_obj ("TOTP", label, "Example", "JBSWY3DPEHPK3PXP", + digits, algo, period, 0, NULL); +} + +/* Wrap a single token in a root array and run the load-time validator. Consumes + * the token reference. */ +static gboolean +root_accepts (json_t *token) +{ + json_t *root = json_array (); + json_array_append_new (root, token); + GError *err = NULL; + gboolean ok = otp_validate_database_root (root, &err); + g_clear_error (&err); + json_decref (root); + return ok; +} + +/* Parity: every digit count and period libcotp accepts (4-10, 1-120) must pass + * the loader validator, across all algorithms. Under the old 6-8 digits bound + * this tripped on 4/5/9/10 - exactly the #464 lockout. */ +static void +test_engine_valid_values_accepted (void) +{ + const char *algos[] = { "SHA1", "SHA256", "SHA512" }; + for (guint d = OTP_DIGITS_MIN; d <= OTP_DIGITS_MAX; d++) + for (guint a = 0; a < G_N_ELEMENTS (algos); a++) + g_assert_true (root_accepts (totp_obj ("acc", d, algos[a], 30))); + + const guint periods[] = { OTP_PERIOD_MIN, 15, 30, 60, 90, OTP_PERIOD_MAX }; + for (guint p = 0; p < G_N_ELEMENTS (periods); p++) + g_assert_true (root_accepts (totp_obj ("acc", 6, "SHA1", periods[p]))); +} + +/* The import-path validator (save / manual-add / import) must agree with the + * loader on the same range, so a token accepted on one path is never rejected + * on the other. */ +static void +test_import_validator_matches_range (void) +{ + for (guint d = OTP_DIGITS_MIN; d <= OTP_DIGITS_MAX; d++) { + otp_t otp = { .type = "TOTP", .account_name = "acc", .issuer = "Example", + .secret = "JBSWY3DPEHPK3PXP", .digits = d, .algo = "SHA1" }; + otp.period = 30; + GError *err = NULL; + g_assert_true (otp_validate_import_token (&otp, &err)); + g_clear_error (&err); + } + const guint periods[] = { OTP_PERIOD_MIN, 30, OTP_PERIOD_MAX }; + for (guint p = 0; p < G_N_ELEMENTS (periods); p++) { + otp_t otp = { .type = "TOTP", .account_name = "acc", .issuer = "Example", + .secret = "JBSWY3DPEHPK3PXP", .digits = 6, .algo = "SHA1" }; + otp.period = periods[p]; + GError *err = NULL; + g_assert_true (otp_validate_import_token (&otp, &err)); + g_clear_error (&err); + } +} + +/* The validator must stay strict for values libcotp cannot generate, so the + * save/import paths still refuse to introduce them. */ +static void +test_out_of_range_rejected (void) +{ + g_assert_false (root_accepts (totp_obj ("acc", OTP_DIGITS_MIN - 1, "SHA1", 30))); + g_assert_false (root_accepts (totp_obj ("acc", OTP_DIGITS_MAX + 1, "SHA1", 30))); + g_assert_false (root_accepts (totp_obj ("acc", 6, "SHA1", OTP_PERIOD_MAX + 1))); + g_assert_false (root_accepts (totp_obj ("acc", 6, "SHA1", 0))); + g_assert_false (root_accepts (totp_obj ("acc", 6, "MD5", 30))); +} + +/* ---- never-brick round-trip (real encrypt/decrypt) ---- */ + +static gchar * +make_tmp_db_dir (gchar **path_out) +{ + GError *err = NULL; + gchar *dir = g_dir_make_tmp ("otpclient-fb-XXXXXX", &err); + g_assert_no_error (err); + g_assert_nonnull (dir); + *path_out = g_build_filename (dir, "test.enc", NULL); + return dir; +} + +static void +cleanup_tmp_db (DatabaseData *db_data, gchar *dir, gchar *path) +{ + if (db_data != NULL) + database_data_free (db_data); + g_unlink (path); + g_autofree gchar *lock_path = g_strconcat (path, ".lock", NULL); + g_unlink (lock_path); + g_autofree gchar *bak_path = g_strconcat (path, ".bak", NULL); + g_unlink (bak_path); + g_rmdir (dir); + g_free (path); + g_free (dir); +} + +static DatabaseData * +open_writer (const gchar *path, const gchar *pw) +{ + DatabaseData *db = database_data_new (path, DEFAULT_MEMLOCK_VALUE); + db->key = secure_strdup (pw); + db->argon2id_iter = ARGON2ID_MIN_ITER; + db->argon2id_memcost = ARGON2ID_MIN_MC; + db->argon2id_parallelism = ARGON2ID_MIN_PARAL; + db->current_db_version = DB_VERSION; + db->in_memory_json_data = json_array (); + json_array_append_new (db->in_memory_json_data, totp_obj ("alice", 6, "SHA1", 30)); + json_array_append_new (db->in_memory_json_data, totp_obj ("bob", 8, "SHA512", 60)); + return db; +} + +static DatabaseData * +open_reader (const gchar *path, const gchar *pw, GError **err) +{ + DatabaseData *db = database_data_new (path, DEFAULT_MEMLOCK_VALUE); + db->key = secure_strdup (pw); + load_db (db, err); + return db; +} + +static void +test_broken_tokens_quarantined_not_bricking (void) +{ + gchar *path = NULL; + gchar *dir = make_tmp_db_dir (&path); + + /* Seed 2 valid tokens and stage 3 the engine cannot use as "quarantined", + * so encrypt_db writes them into the file exactly as a legacy or corrupt + * database would carry them: an out-of-range digit count, an out-of-range + * period, and an invalid Base32 secret. */ + DatabaseData *writer = open_writer (path, "pw"); + writer->quarantined_tokens = json_array (); + json_array_append_new (writer->quarantined_tokens, totp_obj ("bad-digits", 12, "SHA1", 30)); + json_array_append_new (writer->quarantined_tokens, totp_obj ("bad-period", 6, "SHA1", 300)); + json_array_append_new (writer->quarantined_tokens, + build_json_obj ("TOTP", "bad-secret", "Example", "0000", 6, "SHA1", 30, 0, NULL)); + + GError *err = NULL; + update_db (writer, &err); + g_assert_no_error (err); + + /* Fresh open must succeed (not brick), load only the valid tokens, and set + * the broken ones aside. */ + DatabaseData *reader = open_reader (path, "pw", &err); + g_assert_no_error (err); + g_assert_nonnull (reader->in_memory_json_data); + g_assert_cmpint (json_array_size (reader->in_memory_json_data), ==, 2); + g_assert_cmpuint (db_get_quarantined_count (reader), ==, 3); + + /* Saving and reloading must preserve the broken tokens, never drop them. */ + update_db (reader, &err); + g_assert_no_error (err); + DatabaseData *reader2 = open_reader (path, "pw", &err); + g_assert_no_error (err); + g_assert_cmpint (json_array_size (reader2->in_memory_json_data), ==, 2); + g_assert_cmpuint (db_get_quarantined_count (reader2), ==, 3); + + database_data_free (reader2); + database_data_free (reader); + cleanup_tmp_db (writer, dir, path); +} + +int +main (int argc, char **argv) +{ + g_test_init (&argc, &argv, NULL); + gchar *init_err = init_libs (DEFAULT_MEMLOCK_VALUE); + g_assert_null (init_err); + + g_test_add_func ("/field-bounds/engine-valid-accepted", test_engine_valid_values_accepted); + g_test_add_func ("/field-bounds/import-matches-range", test_import_validator_matches_range); + g_test_add_func ("/field-bounds/out-of-range-rejected", test_out_of_range_rejected); + g_test_add_func ("/field-bounds/broken-quarantined", test_broken_tokens_quarantined_not_bricking); + + return g_test_run (); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/tests/test_otp_validation.c new/OTPClient-5.1.4/tests/test_otp_validation.c --- old/OTPClient-5.1.1/tests/test_otp_validation.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/tests/test_otp_validation.c 2026-07-10 11:41:32.000000000 +0200 @@ -80,6 +80,97 @@ json_decref (root); } +static void +test_anonymous_repaired_then_valid (void) +{ + /* A token with neither a label nor an issuer is rejected outright, but the + * load path repairs it first so the whole database still opens (issue #462). */ + json_t *root = json_array (); + json_t *obj = valid_totp (); + json_object_set_new (obj, "label", json_string ("")); + json_object_set_new (obj, "issuer", json_string ("")); + json_array_append_new (root, obj); + + GError *err = NULL; + g_assert_false (otp_validate_database_root (root, &err)); + g_clear_error (&err); + + g_assert_cmpuint (otp_repair_database_root (root), ==, 1); + g_assert_true (otp_validate_database_root (root, &err)); + g_assert_no_error (err); + + json_t *repaired = json_array_get (root, 0); + g_assert_cmpstr (json_string_value (json_object_get (repaired, "label")), ==, "Unknown 0"); + + /* Idempotent: a second pass finds nothing anonymous and changes nothing. */ + g_assert_cmpuint (otp_repair_database_root (root), ==, 0); + g_assert_cmpstr (json_string_value (json_object_get (repaired, "label")), ==, "Unknown 0"); + + json_decref (root); +} + +static void +test_repair_leaves_named_tokens (void) +{ + /* Labeled and issuer-only tokens must be left untouched. */ + json_t *root = json_array (); + json_array_append_new (root, valid_totp ()); + json_t *issuer_only = valid_totp (); + json_object_set_new (issuer_only, "label", json_string ("")); + json_array_append_new (root, issuer_only); + + g_assert_cmpuint (otp_repair_database_root (root), ==, 0); + g_assert_cmpstr (json_string_value (json_object_get (json_array_get (root, 0), "label")), ==, "alice"); + g_assert_cmpstr (json_string_value (json_object_get (json_array_get (root, 1), "label")), ==, ""); + + json_decref (root); +} + +static void +test_repair_indexes_by_position (void) +{ + /* The placeholder carries the token's array index, matching the "Token N" + * position that validation used to report. */ + json_t *root = json_array (); + json_array_append_new (root, valid_totp ()); /* index 0: named */ + json_t *anon = valid_totp (); + json_object_set_new (anon, "label", json_string ("")); + json_object_set_new (anon, "issuer", json_string ("")); + json_array_append_new (root, anon); /* index 1: anonymous */ + + g_assert_cmpuint (otp_repair_database_root (root), ==, 1); + g_assert_cmpstr (json_string_value (json_object_get (json_array_get (root, 1), "label")), ==, "Unknown 1"); + + json_decref (root); +} + +static void +test_repair_import_token (void) +{ + otp_t otp = {0}; + otp.type = g_strdup ("TOTP"); + otp.algo = g_strdup ("SHA1"); + otp.digits = 6; + otp.period = 30; + otp.secret = g_strdup ("JBSWY3DPEHPK3PXP"); + + g_assert_true (otp_repair_anonymous_import_token (&otp, 3)); + g_assert_cmpstr (otp.account_name, ==, "Unknown 3"); + + GError *err = NULL; + g_assert_true (otp_validate_import_token (&otp, &err)); + g_assert_no_error (err); + + /* Idempotent: an already-named token is not renamed. */ + g_assert_false (otp_repair_anonymous_import_token (&otp, 9)); + g_assert_cmpstr (otp.account_name, ==, "Unknown 3"); + + g_free (otp.type); + g_free (otp.algo); + g_free (otp.account_name); + g_free (otp.secret); +} + int main (int argc, char **argv) { @@ -92,6 +183,10 @@ g_test_add_func ("/validation/root-array", test_root_must_be_array); g_test_add_func ("/validation/zero-period", test_zero_period_rejected); g_test_add_func ("/validation/hotp-overflow", test_hotp_counter_overflow_rejected); + g_test_add_func ("/validation/anonymous-repaired", test_anonymous_repaired_then_valid); + g_test_add_func ("/validation/repair-leaves-named", test_repair_leaves_named_tokens); + g_test_add_func ("/validation/repair-index", test_repair_indexes_by_position); + g_test_add_func ("/validation/repair-import-token", test_repair_import_token); return g_test_run (); } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/OTPClient-5.1.1/tests/test_parse_uri_extra.c new/OTPClient-5.1.4/tests/test_parse_uri_extra.c --- old/OTPClient-5.1.1/tests/test_parse_uri_extra.c 2026-06-30 09:26:48.000000000 +0200 +++ new/OTPClient-5.1.4/tests/test_parse_uri_extra.c 2026-07-10 11:41:32.000000000 +0200 @@ -80,7 +80,7 @@ static void test_period_out_of_bounds (void) { - /* period=0 and period=999 fall outside (0,300]; parse_uri keeps the + /* period=0 and period=999 fall outside [1,120]; parse_uri keeps the * default of 30 in those cases. The resulting otp_t is still valid. */ otp_t *zero = parse_single ( "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example&period=0"); @@ -112,6 +112,41 @@ } static void +test_digits_widened_range (void) +{ + /* digits 4-10 is libcotp's real range (issue #464). Values the old 6-8 + * clamp used to reset to 6 - 4, 5, 9, 10 - must now be preserved. */ + const guint kept[] = { 4, 5, 9, 10 }; + for (guint i = 0; i < G_N_ELEMENTS (kept); i++) { + g_autofree gchar *uri = g_strdup_printf ( + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example&digits=%u", + kept[i]); + otp_t *otp = parse_single (uri); + g_assert_nonnull (otp); + g_assert_cmpuint (otp->digits, ==, kept[i]); + free_otp (otp); + } +} + +static void +test_period_upper_bound (void) +{ + /* 120 is the top of libcotp's range and must be kept; 150 is above it and + * falls back to the default 30 (issue #464). */ + otp_t *at_max = parse_single ( + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example&period=120"); + g_assert_nonnull (at_max); + g_assert_cmpuint (at_max->period, ==, 120); + free_otp (at_max); + + otp_t *over = parse_single ( + "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example&period=150"); + g_assert_nonnull (over); + g_assert_cmpuint (over->period, ==, 30); + free_otp (over); +} + +static void test_counter_at_max (void) { g_autofree gchar *at_max = g_strdup_printf ( @@ -214,6 +249,8 @@ g_test_add_func ("/parse-uri-extra/unknown-algorithm", test_unknown_algorithm_falls_back_to_sha1); g_test_add_func ("/parse-uri-extra/period-out-of-bounds", test_period_out_of_bounds); g_test_add_func ("/parse-uri-extra/digits-out-of-bounds", test_digits_out_of_bounds); + g_test_add_func ("/parse-uri-extra/digits-widened-range", test_digits_widened_range); + g_test_add_func ("/parse-uri-extra/period-upper-bound", test_period_upper_bound); g_test_add_func ("/parse-uri-extra/counter-at-max", test_counter_at_max); g_test_add_func ("/parse-uri-extra/malformed-scheme", test_malformed_scheme_ignored); g_test_add_func ("/parse-uri-extra/invalid-secret", test_invalid_secret_rejected);
