Skip to content

Protect stored router credentials and preserve password colons - #613

Closed
maghuro wants to merge 2 commits into
ExtremeFiretop:devfrom
maghuro:security/private-login-credentials
Closed

maghuro wants to merge 2 commits into
ExtremeFiretop:devfrom
maghuro:security/private-login-credentials

Conversation

@maghuro

@maghuro maghuro commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Summary

This supersedes #612 and keeps its first-colon password parsing fix while also moving stored router credentials out of the WebUI-readable MerlinAU configuration.

The current credential format is Base64 of username:password, which is required by the ASUS login flow. Base64 is therefore retained as the protocol representation, but the persistent value is now stored server-side in a protected file instead of being exposed through config.htm.

Changes

  • Preserve all password characters after the first :, including passwords containing one or multiple colons.
  • Store the encoded router credential in /jffs/addons/MerlinAU.d/login_credentials.b64 with mode 600.
  • Keep credentials_base64 TBD in the WebUI-readable configuration for compatibility.
  • Add a non-sensitive credentials_stored ENABLED/DISABLED state for the WebUI.
  • Do not repopulate the saved password into the browser DOM.
  • Only submit a replacement credential when the user actually enters a new password.
  • Allow a blank password field to retain and test an existing stored credential.
  • Redact transient credential values from WebUI debug output.
  • Migrate legacy stored credentials to protected storage on initialization.
  • Use atomic temporary-file writes for protected credential storage and legacy-config cleanup.
  • Fail closed by disabling the MerlinAU WebUI for that run if a legacy credential cannot be migrated safely.
  • Preserve protected credentials when uninstalling with configuration retention.
  • Stop printing the encoded credential in the CLI.

Validation

Tested on an ASUS GT-AX6000 running Asuswrt-Merlin.

Validated:

  • real migration from the existing credentials_base64 value;
  • exact byte-for-byte credential preservation;
  • private file permissions remain 600;
  • public/WebUI configuration contains only credentials_base64 TBD;
  • no persistent MerlinAU_credentials_base64 remains in shared settings;
  • saving from WebUI with the password field left blank preserves the stored credential;
  • login test succeeds using the private stored credential;
  • entering the current password again writes the protected credential correctly and the login test succeeds;
  • passwords containing :, multiple colons, leading colons and trailing colons preserve the complete password after the first delimiter;
  • repeated startup is idempotent;
  • shell syntax validated with sh -n.

The branch contains two commits on top of current upstream dev: the original #612 fix plus the protected-storage change.

@maghuro
maghuro force-pushed the security/private-login-credentials branch from 899ba0d to 1f9a299 Compare September 24, 2026 00:41
@ExtremeFiretop

Copy link
Copy Markdown
Owner

This seems real to me. Will reproduce after dinner.

@maghuro
maghuro force-pushed the security/private-login-credentials branch from 1f9a299 to d33a36c Compare September 24, 2026 01:26
@ExtremeFiretop

ExtremeFiretop commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

@Martinski4GitHub

This was confirmed on my side using a test password such as: Merlin:Test123!
The shell accepted it, but the WebUI only displays: Merlin

So I was able to independently replicate this.

Same in reverse, I type in: Merlin:Test123! in the WebUI, and it only displays : Merlin
Even though it actually saves Merlin:Test123! to the settings file.

Considering this has been your journey since the start:
#159
#192
#561
#569
#587
#590
#602

I'll let you handle this one and assess if it needs any changes before merging ;)

@ExtremeFiretop ExtremeFiretop added the bug Something isn't working label Sep 24, 2026
@maghuro
maghuro force-pushed the security/private-login-credentials branch from d33a36c to b07a036 Compare September 24, 2026 06:52
@maghuro

maghuro commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for reproducing the colon issue.

I went through the credential/password history you linked, especially #159, #192, #561, #569, #587, #590 and #602.

That history is actually why I followed the credential path end-to-end instead of stopping at the split(':') bug. There have been several legitimate edge cases over time around password input, copy/paste, special characters, Base64 handling and settings persistence.

While tracing that path I noticed the second issue addressed here: the reusable Base64 credential was being kept in custom_settings.txt, while that same file is exposed to the WebUI as config.htm.

I have now rebased the PR onto the current dev, including the recent settings work after #590/#610, and tested the resulting commit on my GT-AX6000.

Commit tested:

f6f1cb3e54e88973f0436cb3e0e5e1cdde23da3a

The live test covered migration of the existing credential, mode 600 private storage, repeated startup, real router login, backend save without resending the password, and a real WebUI Apply with the password field left blank.

After the test, custom_settings.txt, its backup, shared custom settings and config.htm contained no reusable credential value, while login through the stored private credential remained verified.

The implementation is therefore no longer only statically reviewed on my side - the migration and normal WebUI/backend flow have now been exercised on actual hardware.

@Martinski4GitHub, given the history of this credential path, I'm very interested in anything you would change in the approach before this is merged.

@Martinski4GitHub

Copy link
Copy Markdown
Collaborator

@maghuro,

I've read the PR description twice, but it's not clear to me what exactly the issue is that this PR is trying to address by creating a separate "protected" file for the router WebUI login credentials.

Can you elaborate on the exact scenario or use case that this PR is trying to provide a solution for?

If you can, show an example with context and details, and maybe some screenshots illustrating the problem might help.

BTW, the bug in the WebUI regarding the colon separator between username & password was a great catch!!

Thanks.

@maghuro

maghuro commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@Martinski4GitHub thanks! I think I made the PR description too focused on the implementation and not enough on the concrete scenario.

The separate file is not intended to protect the credentials from someone who already has root/SSH access to the router. That is not the threat model here.

The issue I was trying to address is that, on current dev, the persistent router credential is stored in the same configuration file that MerlinAU deliberately exposes to its WebUI.

More specifically:

  • CONFIG_FILE is /jffs/addons/MerlinAU.d/custom_settings.txt.
  • That file contains credentials_base64, which is Base64 of username:password.
  • _CreateSymLinks_() exposes that same file as config.htm:
ln -sf "$CONFIG_FILE" "${SCRIPT_WEB_DIR}/config.htm"
  • The WebUI then fetches it directly:
url: '/ext/MerlinAU/config.htm'
  • On current dev, MerlinAU.asp reads credentials_base64, decodes it in the browser, extracts the password, and stores it in ajax_custom_settings.routerPassword:
case keyUpper === 'CREDENTIALS_BASE64':
    var decoded = atob(keyValue);
    var password = decoded.split(':')[1] || '';
    ajax_custom_settings.routerPassword = password;
  • InitializeFields() then places that saved password back into the routerPassword input.

As a concrete example, if the router credentials were:

admin:Merlin:Test123!

the existing configuration would persist a reusable value equivalent to:

credentials_base64 YWRtaW46TWVybGluOlRlc3QxMjMh

Base64 itself is not the problem here. MerlinAU needs that representation for the ASUS login flow, and this PR keeps using it.

The concern is that the saved reusable credential does not need to be sent back to the browser every time the MerlinAU WebUI is opened.

The normal use case is:

  1. The user saves the router password once because MerlinAU needs it later for unattended router login/update operations.
  2. MerlinAU needs to retain that credential server-side.
  3. At some later point the user opens the MerlinAU WebUI just to view or change settings.
  4. Today, config.htm also sends the existing reusable router credential to the browser.
  5. The browser decodes it and repopulates the password field.

For normal configuration display, the browser only really needs to know whether a credential is already stored, not what that credential is.

That is what the separate file is intended to change.

With this PR:

  • login_credentials.b64 contains the credential server-side and is mode 600.
  • custom_settings.txt / config.htm contains only:
credentials_base64 TBD
credentials_stored ENABLED
  • The WebUI can therefore show that a password already exists without receiving it.
  • The password field stays empty and indicates that leaving it blank keeps the stored password.
  • If the user enters a new password, that new credential is of course sent to the backend so it can replace the stored one.
  • On subsequent WebUI page loads, however, the existing password is not sent back to the browser.

So I would describe the goal less as protecting the credential from the router administrator and more as keeping a reusable secret server-side instead of unnecessarily returning it through the WebUI configuration path.

I used a separate file because custom_settings.txt still needs to remain WebUI-readable for the rest of MerlinAU's settings, so separating the credential seemed like the least invasive way to preserve the current architecture.

If you think the credential does not need to be separated this way, or there is a cleaner approach that fits MerlinAU's existing settings architecture better, I'm absolutely open to changing it. The main thing I wanted to avoid was sending the already-stored router password back to the browser every time the configuration page is loaded.

I’ll admit I’m a bit security-centric by nature, so my brain tends to wander through all sorts of possible edge cases and "what if" scenarios... even the slightly paranoid ones. 😅

And thanks regarding the colon bug! That was actually what led me to trace this whole credential path in the first place. 🙂

@Martinski4GitHub

Copy link
Copy Markdown
Collaborator

The issue I was trying to address is that, on current dev, the persistent router credential is stored in the same configuration file that MerlinAU deliberately exposes to its WebUI.

.....

The concern is that the saved reusable credential does not need to be sent back to the browser every time the MerlinAU WebUI is opened.

Why not? What is the perceived "threat" you're trying to address?

For normal configuration display, the browser only really needs to know whether a credential is already stored, not what that credential is.

...

So I would describe the goal less as protecting the credential from the router administrator and more as keeping a reusable secret server-side instead of unnecessarily returning it through the WebUI configuration path.

There is intention and purpose in presenting the current password string to the user (obfuscated by default every time you open the WebUI page to prevent "over-the-shoulder" exposure to someone else, same thing done in the SSH CLI menu).

I can think of a couple of scenarios where making the actual password available to the user in the WebUI would be useful:

  • A user wants to double-check that they have indeed entered the correct password, perhaps because the MerlinAU password validation step shows a failure for some reason.

  • A user wants to change the password in MerlinAU immediately after changing it in the WebUI "Administration -> System" section, but the changes are only a few characters from the previous one. Showing the current password allows the user to simply edit/add/change the few characters.

There may be other scenarios, but the point is that users can find it useful to have the password available at their fingertips on the WebUI.

As you already know, if a hacker compromises the router via an SSH login, there's no point in protecting any files since the hacker has full access to the entire filesystem with root privileges. Or if the hacker has compromised the WebUI because of access over the WAN, they already have the login credentials anyway.

At this point, I don't see any value in "protecting" the password by not making it available in the WebUI.

Unless you have other reasons for your implementation, or explain exactly what the perceived threat is, I'm going to close this PR by Friday evening.

I appreciate the effort, but there must be a clear and verifiable reason, purpose, or value in order to make such wide-ranging changes in the code.

@maghuro

maghuro commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@Martinski4GitHub thanks for the detailed explanation.

I understand the intended behavior much better now, particularly that making the existing password available in the WebUI is a deliberate UX choice rather than an accidental consequence of the current implementation.

I still personally lean toward keeping reusable secrets server-side whenever the client does not strictly need them, so I think the approach in this PR can be defensible under a different threat model. But I also understand the usability cases you described and, ultimately, this is an architectural decision for MerlinAU and its maintainers to make.

There is no point in me pushing the protected-storage part further when the current behavior is intentional and you both prefer to retain it.

I also saw #614 and noticed that the colon parsing fix has already been incorporated there, thank you for that. That was the reproducible bug that originally started me down this rabbit hole, so I'm glad that part will make it into the project.

From my side, then, there is nothing further that needs to be addressed in #613. Feel free to close it.

Thanks to both of you for taking the time to review, test and discuss the proposal!

@ExtremeFiretop

ExtremeFiretop commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

@Martinski4GitHub thanks for the detailed explanation.

I understand the intended behavior much better now, particularly that making the existing password available in the WebUI is a deliberate UX choice rather than an accidental consequence of the current implementation.

I still personally lean toward keeping reusable secrets server-side whenever the client does not strictly need them, so I think the approach in this PR can be defensible under a different threat model. But I also understand the usability cases you described and, ultimately, this is an architectural decision for MerlinAU and its maintainers to make.

There is no point in me pushing the protected-storage part further when the current behavior is intentional and you both prefer to retain it.

I also saw #614 and noticed that the colon parsing fix has already been incorporated there, thank you for that. That was the reproducible bug that originally started me down this rabbit hole, so I'm glad that part will make it into the project.

From my side, then, there is nothing further that needs to be addressed in #613. Feel free to close it.

Thanks to both of you for taking the time to review, test and discuss the proposal!

Thanks @maghuro
I'll merge @Martinski4GitHub 's fix and close this one for now.

Thank you for identifying this issue for us though :)
I have to generally agree with Martinski on this one, the design intentionally functioned this way from the start.

The password is protected by the file system, they would already need to know it to gain access to it via SSH or the WebUI (if exposed)
By the point they can gain access the password in MerlinAU, they would already have it.

Only exclusion would be some exploit allowing login without a password on ASUS side, but in vulnerability case that extreme, the hacker wouldn't need or be looking for a password at that stage anyways.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants