Skip to content

fix(credentials): create the auth profile store owner-only - #5795

Open
ntdatt812 wants to merge 1 commit into
tinyhumansai:mainfrom
ntdatt812:fix/5724-credential-store-permissions
Open

fix(credentials): create the auth profile store owner-only#5795
ntdatt812 wants to merge 1 commit into
tinyhumansai:mainfrom
ntdatt812:fix/5724-credential-store-permissions

Conversation

@ntdatt812

Copy link
Copy Markdown

Closes #5724.

The defect

write_persisted_locked wrote the credential store with fs::write, which creates at 0o666 & ~umask0644 under the usual 022 — and then fs::renamed it into place. rename carries the source file's mode onto the destination, so auth-profiles.json was world-readable after every save.

The mode was never a decision. grep -rn "set_permissions\|PermissionsExt\|from_mode" src/openhuman/security/credentials/ returns nothing: there was no hardening anywhere in the module, and no runtime repair either.

What that exposes, and where it actually matters

Not on every install, and I would rather be precise than reach for a severity label:

Storage path When What sits in the world-readable file
OS keychain macOS/Windows, keychain available and consented no secrets — provider names, ids, timestamps
encrypted JSON keychain unavailable, declined, or headless OAuth access/refresh tokens as enc2: ciphertext
plaintext JSON oldest legacy path tokens in the clear

Row 2 is the normal state on Linux and on any server install without a working keyring. There, confidentiality rested entirely on .secret_key being 0600 — defence-in-depth collapsing to a single control on exactly the installs least likely to have a keyring.

The fix

write_owner_only() creates the file with 0o600 already set rather than widening then narrowing — the same shape #2360 landed for the secret key in keyring/encrypted_store.rs:330, whose comment names the goal: "to avoid a TOCTOU race where the file is briefly world-readable".

It also chmods explicitly after opening. .mode() applies only when the file is created, so an interrupted save that left a 0644 tmp behind would have it reused as-is by the next save — and rename would carry that mode straight through to the store. That is the failure this fix would otherwise still have.

Windows keeps fs::write: there is no mode to set, and the file inherits the ACL of the per-user profile directory.

Tests

Three cases in a #[cfg(all(test, unix))] module:

  • a_new_credential_file_is_owner_only — mode is 0600, not 0644;
  • a_leftover_world_readable_tmp_is_repaired — seeds a 0644 tmp, asserts the precondition, then asserts the write narrows it. This is the case .mode() alone does not cover;
  • the_contents_are_written_and_truncated — a shorter payload must not leave stale bytes behind, since OpenOptions replaces fs::write's implicit truncate.

Verification note

cargo check -p openhuman --libexit 0, no diagnostic in profiles.rs; cargo fmt applied.

I could not run the tests: they are unix-gated and this is a Windows box, where that module does not even compile in. So they are reviewed-by-eye here and will first execute on CI. If you would rather I verify them on Linux before you spend review time, say so and I will find a way to.

Not done here

Existing installs stay 0644 until their next save, since this fixes the write path rather than repairing on load. #5635 does repair config.toml's mode on load; adding the same for the credential store is a reasonable follow-up but a different change, and I did not want to widen this one without asking.

`fs::write` creates at 0o666 & ~umask -- 0644 under the usual 022 -- and
`fs::rename` carries the source mode onto the destination, so auth-profiles.json
was world-readable after every save. The mode was never a decision; it was the
default nobody overrode, and the credentials module had no hardening at all.

On the encrypted-JSON storage path -- the normal state on Linux and on any
headless install without a working keyring -- that file holds OAuth token
ciphertext, so confidentiality rested entirely on .secret_key being 0600. This
is defence-in-depth collapsing to a single control on exactly the installs least
likely to have a keyring.

write_owner_only() creates with 0o600 already set rather than widening then
narrowing, which is the shape tinyhumansai#2360 landed for the secret key in
keyring/encrypted_store.rs. It also chmods explicitly, because `.mode()` applies
only at creation and an interrupted save can leave a 0644 tmp behind for the
next one to reuse -- and rename would carry that through.

Windows keeps `fs::write`: there is no mode to set, and the file inherits the
ACL of the per-user profile directory.

Closes tinyhumansai#5724
@ntdatt812
ntdatt812 requested a review from a team August 26, 2026 16:37
@tinysweeper tinysweeper Bot added the priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. label Aug 26, 2026

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

tinysweeper found nothing blocking. Approving.

$0.0000 · 0 in / 0 out

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

Labels

priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth-profiles.json (the credential store) is written world-readable (mode 644) — no hardening in the credentials module

1 participant