Skip to content

refact(password): Store permanent password as hashed verifier#507

Open
fufesou wants to merge 4 commits intorustdesk:mainfrom
fufesou:fix/permanent-password-hash
Open

refact(password): Store permanent password as hashed verifier#507
fufesou wants to merge 4 commits intorustdesk:mainfrom
fufesou:fix/permanent-password-hash

Conversation

@fufesou
Copy link
Contributor

@fufesou fufesou commented Mar 19, 2026

This PR changes hbb_common to store the permanent password as a non-reversible verifier rather than a reversible value on disk. It also prevents salt from being changed once the hashed storage format is active, so password verification remains stable across config sync paths.

Issue

  • Storing a permanent password in a reversible format on disk enables local recovery of the secret.
  • salt is part of the verifier derivation. If salt can change after switching to hashed storage, the stored verifier becomes unverifiable and effectively breaks the permanent password.

Solution

  • Introduce a hashed storage format for the permanent password:
    • Format: 01<base64(H1)>
    • H1 = SHA256(password || salt) (32 bytes)
  • Migrate existing stored values on load:
    • If a legacy value is present (plaintext-like or legacy encrypted form), derive H1 and rewrite storage as 01....
  • Ensure we do not apply reversible encryption to the new hashed storage:
    • Config::store() only encrypts legacy formats; 01... is stored as-is.
  • Freeze salt once hashed storage is active:
    • Config::set_salt() refuses updates when password is stored as 01....
    • Config::set(cfg) (used by config sync) is guarded so salt (and the associated hashed password) cannot be silently overwritten.

Notes / Risk

  • Post-migration, salt is intentionally long-lived to maintain verifier consistency.
  • Once upgraded, downgrading is not possible, even with the original password.

Summary by CodeRabbit

  • Bug Fixes / Security
    • Permanent passwords are now stored as hashed verifiers; existing plaintext storage is auto-migrated to the new format to improve security.
  • Behavior Changes
    • Password verification, salt handling, and config sync rules tightened to prevent insecure updates, accidental clearing, or downgrading of hashed passwords.
  • Tests
    • Added tests for migration, hashing/verifier roundtrips, and config-update validation rules.

Signed-off-by: fufesou <linlong1266@gmail.com>
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai
Copy link

coderabbitai bot commented Mar 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0f521353-0766-4d13-ad32-1861ea5aec27

📥 Commits

Reviewing files that changed from the base of the PR and between 3780220 and 5d5f12a.

📒 Files selected for processing (1)
  • src/config.rs

📝 Walkthrough

Walkthrough

Permanent password storage is migrated from plaintext to a salted SHA256 verifier with on-load/on-store migration. Config gains hashing/decoding/verification APIs, stricter salt/update normalization, and refuses unsafe sync/overwrite actions. Tests added for migration and normalization rules.

Changes

Cohort / File(s) Summary
Config & password hashing
src/config.rs
Migrate permanent password persistence to a salted SHA256 verifier format; add compute_permanent_password_h1, decode_permanent_password_h1_from_storage, get_local_permanent_password_storage_and_salt, set_permanent_password_storage_for_sync, matches_permanent_password_plain, has_permanent_password; remove get_permanent_password; enforce salt/update normalization and refuse unsafe clears/overwrites; add migration in load/store; add tests for roundtrip and normalization.
Password security integration
src/password_security.rs
has_valid_password() now uses Config::has_permanent_password() for permanent-password presence checks; overall flow unchanged.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through bytes and hashed the night,
Salt on my whiskers, verifier bright,
Plaintext tucked under clover and bark,
Migration hummed softly until dark,
Secure and warm — a rabbit's delight. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: migrating permanent password storage from plaintext/reversible to a hashed verifier format, which is the primary focus of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/config.rs (2)

1560-1568: ⚠️ Potential issue | 🟠 Major

Hash plaintext Config::set() updates before storing them in memory.

When the current password is empty or legacy, normalize_incoming_permanent_password_update() returns without canonicalizing incoming.password. Config::set() then keeps plaintext in CONFIG and only hashes a clone inside store(). Until restart, password checks fall back to plaintext comparison and set_salt() still allows salt changes.

Suggested fix
     fn normalize_incoming_permanent_password_update(current: &Config, incoming: &mut Config) {
         if !incoming.password.is_empty()
             && is_permanent_password_hashed_storage(&incoming.password)
             && incoming.salt.is_empty()
         {
@@
 
         let current_is_hashed =
             !current.password.is_empty() && is_permanent_password_hashed_storage(&current.password);
         if !current_is_hashed {
+            let _ = Self::migrate_permanent_password_to_hashed_storage(incoming);
             return;
         }

Also applies to: 1578-1593

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 1560 - 1568, Config::set currently can place
plaintext or legacy passwords into CONFIG because
normalize_incoming_permanent_password_update may return early; move or duplicate
the hashing step so the in-memory update never stores plaintext: after calling
normalize_incoming_permanent_password_update(&lock, &mut cfg) but before
assigning *lock = cfg, ensure the incoming cfg.password is canonicalized/hashed
(the same hash logic used by store()) so the value written into CONFIG is the
hashed password; also ensure set_salt() checks/operates on the hashed password
in CONFIG (not a plaintext field). Update Config::set to perform the hashing of
cfg (or call the existing helper used by store()) prior to *lock = cfg so
temporary plaintext is never kept in memory.

1560-1575: ⚠️ Potential issue | 🟠 Major

Clear trusted devices when Config::set() changes the permanent password.

This path can now accept hashed→hashed password replacements, but unlike set_permanent_password() and set_permanent_password_storage_for_sync() it never invalidates trusted devices. A sync-driven password change will therefore keep remembered devices trusted.

Suggested fix
     pub fn set(cfg: Config) -> bool {
         let mut cfg = cfg;
         let mut lock = CONFIG.write().unwrap();
         Self::normalize_incoming_permanent_password_update(&lock, &mut cfg);
+        let password_changed = lock.password != cfg.password || lock.salt != cfg.salt;
         if *lock == cfg {
             return false;
         }
         *lock = cfg;
         lock.store();
@@
         #[cfg(target_os = "macos")]
         let new_key_pair = lock.key_pair.clone();
         drop(lock);
+        if password_changed {
+            Self::clear_trusted_devices();
+        }
         #[cfg(target_os = "macos")]
         Self::invalidate_key_pair_cache_if_changed(&new_key_pair);
         true
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 1560 - 1575, Config::set currently misses
invalidating remembered devices when a permanent password changes; detect
whether the normalized permanent password actually changed by comparing
lock.permanent_password with cfg.permanent_password after calling
Self::normalize_incoming_permanent_password_update, and if different call the
same trusted-device clearing logic used by set_permanent_password /
set_permanent_password_storage_for_sync (e.g. invoke the helper that
clears/invalidate trusted devices) before storing the new config and before
dropping the CONFIG lock (i.e., do this while you still hold lock, then proceed
to *lock = cfg; lock.store(); ... drop(lock); and keep the existing key‑pair
cache invalidation flow using
new_key_pair/invalidate_key_pair_cache_if_changed).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/config.rs`:
- Around line 1288-1303: The sync setter
set_permanent_password_storage_for_sync() currently overwrites config.salt which
bypasses the immutability enforced by set_salt() and Config::set(); change it to
refuse to change an already-set salt: if config.salt is non-empty (or otherwise
marked immutable) and differs from the incoming salt, return an Err (or
propagate the same "Refusing to persist..." style error); only accept and assign
the incoming salt when config.salt is empty (or unset), and otherwise require
equality with the incoming salt before updating config.password or returning Ok.
Update the implementation in set_permanent_password_storage_for_sync() to
perform that check and avoid config.salt = salt.to_owned() when a differing salt
is already present.

---

Outside diff comments:
In `@src/config.rs`:
- Around line 1560-1568: Config::set currently can place plaintext or legacy
passwords into CONFIG because normalize_incoming_permanent_password_update may
return early; move or duplicate the hashing step so the in-memory update never
stores plaintext: after calling
normalize_incoming_permanent_password_update(&lock, &mut cfg) but before
assigning *lock = cfg, ensure the incoming cfg.password is canonicalized/hashed
(the same hash logic used by store()) so the value written into CONFIG is the
hashed password; also ensure set_salt() checks/operates on the hashed password
in CONFIG (not a plaintext field). Update Config::set to perform the hashing of
cfg (or call the existing helper used by store()) prior to *lock = cfg so
temporary plaintext is never kept in memory.
- Around line 1560-1575: Config::set currently misses invalidating remembered
devices when a permanent password changes; detect whether the normalized
permanent password actually changed by comparing lock.permanent_password with
cfg.permanent_password after calling
Self::normalize_incoming_permanent_password_update, and if different call the
same trusted-device clearing logic used by set_permanent_password /
set_permanent_password_storage_for_sync (e.g. invoke the helper that
clears/invalidate trusted devices) before storing the new config and before
dropping the CONFIG lock (i.e., do this while you still hold lock, then proceed
to *lock = cfg; lock.store(); ... drop(lock); and keep the existing key‑pair
cache invalidation flow using
new_key_pair/invalidate_key_pair_cache_if_changed).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c240f6ad-667f-413e-8c4c-2b73e6df8569

📥 Commits

Reviewing files that changed from the base of the PR and between 48c37de and d98d208.

📒 Files selected for processing (2)
  • src/config.rs
  • src/password_security.rs

fufesou added 2 commits March 19, 2026 11:06
Signed-off-by: fufesou <linlong1266@gmail.com>
Signed-off-by: fufesou <linlong1266@gmail.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/config.rs (3)

53-64: Consider using a key derivation function for stronger offline attack resistance.

The current design uses a single SHA256 iteration: H1 = SHA256(password || salt). While this prevents plaintext storage, it's vulnerable to offline brute-force attacks if the config file is compromised. Password hashing best practices recommend key derivation functions like Argon2, bcrypt, or PBKDF2 with high iteration counts.

If backward compatibility or performance constraints require SHA256, document this trade-off. The PR description mentions this is for verifier storage, so the threat model may be acceptable—but it's worth explicit consideration.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 53 - 64, The function
compute_permanent_password_h1 currently computes H1 with a single
SHA256(password || salt), which is weak against offline brute force; replace its
implementation to use a proper KDF (e.g., Argon2id, bcrypt, or PBKDF2 with a
high iteration count) inside compute_permanent_password_h1 so the derived output
matches PERMANENT_PASSWORD_H1_LEN (truncate or encode as needed), or if you must
keep SHA256 for compatibility, add explicit documentation and a config flag
noting the trade-off and threat model; update any callers that assume SHA256
output format and ensure salt handling and parameter storage (e.g., Argon2
params) are persisted for verification.

3250-3356: Good test coverage; consider adding tests for sync and verification APIs.

The tests thoroughly cover:

  • Hash encoding/decoding roundtrip
  • Migration from plaintext (including "00"-prefixed edge case)
  • Config::set() normalization rules

Consider adding tests for:

  • set_permanent_password_storage_for_sync() behavior (valid sync, rejection cases)
  • matches_permanent_password_plain() verification (correct password, wrong password, empty input)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 3250 - 3356, Add unit tests to cover the sync and
verification APIs: write tests for set_permanent_password_storage_for_sync()
that assert successful acceptance of a correctly-formed hashed storage value,
and rejection/unchanged state for invalid inputs (e.g., plaintext or wrong
prefix) and when salt mismatches; also add tests for
matches_permanent_password_plain() verifying it returns true for the correct
plaintext with the configured salt, false for wrong plaintext and empty input,
and that it behaves correctly when the stored password is hashed vs plaintext.
Locate these behaviors via the functions set_permanent_password_storage_for_sync
and matches_permanent_password_plain and mirror the style of existing tests
(construct Config instances, mutate password/salt, call the function, then
assert expected password/salt and boolean outcomes).

45-48: Consider increasing salt length for stronger security.

DEFAULT_SALT_LEN = 6 produces a salt from 32 characters (CHARS), yielding ~31 bits of entropy. While this provides basic uniqueness, OWASP recommends at least 128 bits of entropy for cryptographic salts. Consider increasing to 22+ characters or using rand::thread_rng().gen::<[u8; 16]>() for direct byte generation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.rs` around lines 45 - 48, DEFAULT_SALT_LEN is too small (6) for
strong salts; increase it to provide ~128 bits of entropy (e.g., set
DEFAULT_SALT_LEN to 22+ for char-based salts) or switch to byte-based salts
(generate 16 random bytes via rand::thread_rng().gen::<[u8; 16]>()). Update the
constant DEFAULT_SALT_LEN and, if the code that creates salts (where
DEFAULT_SALT_LEN is used) expects character salts, adjust that logic to either
use the longer length or replace it with secure byte generation and store/encode
the bytes appropriately; ensure PERMANENT_PASSWORD_H1_LEN and any code consuming
the salt are kept consistent with the new salt representation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/config.rs`:
- Around line 1578-1580: Config::set currently replaces the config without
invalidating trusted devices when the permanent password or salt changes; update
Config::set to detect changes to permanent_password and/or salt by comparing the
incoming cfg.permanent_password and cfg.salt against the existing stored values,
and if either changed invoke the same trusted-device-clearing logic used by
set_permanent_password() / set_permanent_password_storage_for_sync() (or call
the shared helper those methods use) so synced password updates invalidate
trusted devices consistently; preserve existing behavior for non-password
changes.

---

Nitpick comments:
In `@src/config.rs`:
- Around line 53-64: The function compute_permanent_password_h1 currently
computes H1 with a single SHA256(password || salt), which is weak against
offline brute force; replace its implementation to use a proper KDF (e.g.,
Argon2id, bcrypt, or PBKDF2 with a high iteration count) inside
compute_permanent_password_h1 so the derived output matches
PERMANENT_PASSWORD_H1_LEN (truncate or encode as needed), or if you must keep
SHA256 for compatibility, add explicit documentation and a config flag noting
the trade-off and threat model; update any callers that assume SHA256 output
format and ensure salt handling and parameter storage (e.g., Argon2 params) are
persisted for verification.
- Around line 3250-3356: Add unit tests to cover the sync and verification APIs:
write tests for set_permanent_password_storage_for_sync() that assert successful
acceptance of a correctly-formed hashed storage value, and rejection/unchanged
state for invalid inputs (e.g., plaintext or wrong prefix) and when salt
mismatches; also add tests for matches_permanent_password_plain() verifying it
returns true for the correct plaintext with the configured salt, false for wrong
plaintext and empty input, and that it behaves correctly when the stored
password is hashed vs plaintext. Locate these behaviors via the functions
set_permanent_password_storage_for_sync and matches_permanent_password_plain and
mirror the style of existing tests (construct Config instances, mutate
password/salt, call the function, then assert expected password/salt and boolean
outcomes).
- Around line 45-48: DEFAULT_SALT_LEN is too small (6) for strong salts;
increase it to provide ~128 bits of entropy (e.g., set DEFAULT_SALT_LEN to 22+
for char-based salts) or switch to byte-based salts (generate 16 random bytes
via rand::thread_rng().gen::<[u8; 16]>()). Update the constant DEFAULT_SALT_LEN
and, if the code that creates salts (where DEFAULT_SALT_LEN is used) expects
character salts, adjust that logic to either use the longer length or replace it
with secure byte generation and store/encode the bytes appropriately; ensure
PERMANENT_PASSWORD_H1_LEN and any code consuming the salt are kept consistent
with the new salt representation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 81135760-445a-43e6-89d7-414cf986e0d0

📥 Commits

Reviewing files that changed from the base of the PR and between d98d208 and 3780220.

📒 Files selected for processing (1)
  • src/config.rs

@rustdesk
Copy link
Owner

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37802208aa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Signed-off-by: fufesou <linlong1266@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants