fix(desktop): create connection.json owner-only - #77622
Closed
ZHJay wants to merge 2 commits into
Closed
Conversation
`connection.json` under the desktop app's Electron `userData` was written with no
file mode, so it landed at the `0644` umask default — while its two
credential-bearing neighbours in the same directory, `desktop-installation.json`
and `native-oauth-tokens.json`, were already `0600`. That file holds the
safeStorage-encrypted gateway token plus the fields that are NOT encrypted: the
gateway URL and the SSH host, user, and key path.
- Route the single write choke point through a helper that creates the file
owner-only and atomically.
- Tighten an already-existing `0644` file once per launch on the read path, so
installs that already have one do not stay world-readable until the next save.
- Refuse to act on a path that is a symlink or not owned by the current user,
matching the guards `desktop-installation.ts` already applies to its sibling.
The symlink guard alone turned out to be insufficient, and that is worth
recording: `writeSecretFileAtomic` tightens its *temp* path, so a symlink planted
at `connection.json.tmp` meant `writeFileSync` followed it, the guard correctly
bailed, and `renameSync` then moved the link onto `connection.json` permanently.
Measured, guard-only vs. as-landed:
guards only token leaked: true config is a symlink: true 755
guards + temp unlink token leaked: false config is a symlink: false 600
So the temp path is unlinked before the write.
Issue NousResearch#77486's headline claim — that a dashboard session token is persisted in
plaintext — does not hold against main. The token has been safeStorage-encrypted
since the desktop app reached mainline in 51c68d4, and `encryptDesktopSecret`
aborts with an actionable message rather than degrading to plaintext when
safeStorage is unavailable. The `{ encoding: 'plain', value }` literal does exist
at main.ts:7084, but only on the `persistToken: false` branch, whose sole caller
is the connection-test handler, which never writes. So no mainline path *writes*
a plaintext token. The commits that did contain a plaintext-writing fallback
(d3d1772, d208f2c) are not ancestors of main — they live only on
upstream/bb/gui-* and the desktop-pr20059-installers pre-release tag.
At-rest migration of legacy non-safeStorage payloads is deliberately NOT included.
An earlier revision of this branch implemented it and it was removed after review
reproduced two token-loss paths: it force-converts the opt-in plaintext choice
PR NousResearch#62319 adds (silently reverting the user's decision, then destroying the token
on the next launch without the `--password-store=basic` flag), and it converts a
portable credential into a keychain-bound one with no consent — destroying the
only recoverable copy while not remediating the real exposure, since every
existing backup still holds the plaintext and the true remedy is rotation. It also
persisted raw `parsed`, bypassing `sanitizeConnectionProfiles`. A comment at the
read path records the three preconditions any future attempt needs.
`decryptDesktopSecret`'s non-safeStorage read fallback is untouched — it is what
lets a pre-release or hand-edited config work at all.
Windows still inherits the userData directory ACL rather than an explicit
owner-only one; mode bits are advisory there, so that half is deferred to
PR NousResearch#77527 rather than growing a second ACL implementation here.
e2e: `at-rest-connection-token.spec.ts` asserts the at-rest contract
implementation-independently — the token's plaintext value (and its base64 form)
must not appear in a raw-bytes scan of any file under userData or HERMES_HOME,
AND the app must still put the exact original token on the wire after a restart,
so a fix that simply drops the token cannot pass. Proven non-vacuous by mutation:
writing `{ encoding: 'plain', value }` still fails the scan while the
file-exists and gateway-URL guards pass. The migration case is a documented
`test.fixme` naming its three blockers.
Electron project 928 -> 924 tests (-9 migration, +5 new guard and
mechanism-isolation). Two of those five exist because reverting either owner-only
mechanism alone initially scored zero failures — they were masking each other, so
either could have been deleted green.
13 tasks
The helpers were tested; nothing proved main.ts called them. Reverting both call sites and both imports in readDesktopConnectionConfig / writeDesktopConnectionConfig left the whole suite green (947 passed / 2 skipped, tsc 0, eslint clean, e2e 1 passed 1 skipped) while connection.json went back to 0644 — the user-visible fix this PR promises was untested. The e2e spec could not catch it by construction: it asserts the ENCRYPTION contract with a raw-bytes scan, and safeStorage keeps the token opaque regardless of the file's mode, so a 0644 file passes that scan every time. There was no mode assertion anywhere in e2e/. Adds the missing third contract — unreadable by other local accounts — on all three paths that can produce the file: - write: assert the mode of the artifact test 1 already proves the app wrote. - read, valid file: seed the app's own encrypted connection.json back to 0644 and assert launch tightens it. Scoped to the MODE only, so it is independent of the still-deferred plaintext migration — the fixture's token is already ciphertext, so nothing re-encrypts, no NousResearch#62319 opt-in marker is involved, and no rotation guidance is owed. - read, corrupt file: a truncated file still holds the token bytes and throws into the swallowing catch, so it would be the one file never tightened. This is the only test that distinguishes the chmod's placement relative to the parse. Also moves the tighten above JSON.parse for exactly that reason, and pins the cache invariant the placement depends on: the tighten must be a chmod, not a rewrite, because it sits inside the function whose cache keys on mtimeMs. Asserted as `mode & 0o077 === 0` rather than `=== 0o600` to avoid a change-detector, and skipped on win32, where chmod maps to the read-only bit and the fix deliberately no-ops (ACLs are PR NousResearch#77527). Every assertion was mutation-tested: reverting the full wiring fails all three; reverting only the write path fails only the write test; deleting only the tighten-on-read fails only the two read tests; moving the tighten below the parse fails only the corrupt test; making the tighten a rewrite instead of a chmod fails the mtime assertions. Bundle greps confirmed each mutation reached dist/electron-main.mjs before the run.
19 tasks
3 tasks
Contributor
|
Salvaged and merged via #84898 — your fix + E2E test landed on main with authorship preserved (rebase-merge, f84ecd3). The original branch couldn't rebase-merge due to a merge commit, so it was rebuilt as clean cherry-picks. Live-tested end-to-end before merge: fresh write 0600, rewrite tightens 0644→0600, stale 0666 temp not inherited, startup repair path. Thanks @ZHJay! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
connection.jsonunder the desktop app's ElectronuserDatawas written with no file mode, so it landed at the0644umask default — while its two credential-bearing neighbours in the same directory were already0600:desktop-installation.json(desktop-installation.ts:106) andnative-oauth-tokens.json(main.ts:6304). That file holds the safeStorage-encrypted gateway token plus fields that are not encrypted: the gateway URL and the SSH host, user, and key path. This makes the three consistent.Three mechanisms, all on the single write choke point (
writeDesktopConnectionConfig, which every IPC save/apply andpersistSshConnectionTokenfunnels through):Owner-only atomic create —
writeSecretFileAtomiccreates the file0600from the moment it exists.Tighten-on-read — an install that already has a
0644file gets chmod'ed once per launch on the read path, so it does not stay group/other-readable until the user's next Settings save. Runs on a cache miss only; chmod moves ctime, not mtime, so it cannot invalidate the cache it sits inside.Sequenced before
JSON.parse, not after. A truncated or hand-mangledconnection.jsonstill contains the token bytes, and the parse throws into thecatchthat falls back to local mode — a fallback that is never written back, so nothing would ever come back for that file. With the chmod after the parse, exactly the file that is both corrupt and world-readable would be the one file never tightened, permanently. The chmod needs only the path, so it has no reason to wait for valid JSON.Refuse to act on a symlink or a path not owned by the current user — matching the guards
desktop-installation.tsalready applies to its sibling (desktop-installation.ts:19-30).The symlink finding (why the guard alone was not enough)
writeSecretFileAtomictightens its temp path, so a symlink planted atconnection.json.tmpmeantwriteFileSyncfollowed it, the lstat guard correctly bailed, andrenameSyncthen moved the link ontoconnection.jsonpermanently. Measured:Hence the temp path is unlinked before the write.
At-rest migration is deliberately NOT included
An earlier revision of this branch implemented migration of legacy non-safeStorage payloads to ciphertext. It was removed after review reproduced two token-loss paths:
--password-store=basicflag that PR adds. It also makes that PR's "Token stored in plain text" banner lie.parsed, bypassingsanitizeConnectionProfiles(reproduced: profile names violatingPROFILE_NAME_RE, bogusauthMode, and arbitrary junk re-persisted by the app's own hand).A comment at the read path records the three preconditions any future attempt needs.
decryptDesktopSecret's non-safeStorage read fallback is untouched — it is what lets a pre-release or hand-edited config work at all.Windows
Mode bits are advisory there, so
connection.jsonstill inherits the userData directory ACL.tightenSecretFileModeno-ops onwin32rather than flipping Node's read-only bit and breaking the next write. Deferred to open PR #77527 ("fix(security): enforce owner-only ACLs on Windows in_secure_file") rather than growing a second ACL implementation here.Related Issue
Context: #77486 — not
Fixes, because that issue bundles three claims and this addresses one, with a corrected premise.Correcting the premise. The issue's headline claim is that a dashboard session token is persisted in plaintext. That does not hold against
main. The token has been safeStorage-encrypted since the desktop app reached mainline in51c68d4ab, andencryptDesktopSecretaborts with an actionable message rather than degrading to plaintext when safeStorage is unavailable. Verified:git merge-base --is-ancestor d3d177283 upstream/maingit merge-base --is-ancestor d208f2c2c upstream/maingit merge-base --is-ancestor 51c68d4ab upstream/maingit log -S 'Fall through to plaintext' upstream/main -- apps/desktopTo be precise, because the loose version of this claim is false: the
{ encoding: 'plain', value }literal does exist onmainatmain.ts:7084, but only on thepersistToken: falsebranch. Its sole caller istestDesktopConnectionConfig(main.ts:7750, reached only viaipcMain.handle('hermes:connection-config:test')atmain.ts:9839), and that function body contains no write call of any kind. So the accurate statement is no mainline path writes a plaintext token — not that the literal is absent.The other two parts of #77486 are handled by sibling PRs, both open: #77579 ("fix(security): create browser-profile and media-cache artifacts owner-only") and #77584 ("fix(desktop): bound in-flight turn journal retention and purge it on delete"). That second one inverts the issue's proposed remedy: the issue asks for journal redaction, and #77584 instead bounds retention and purges on delete — redacting the turn tail would defeat the journal's purpose, since it exists to restore an interrupted turn and a redacted entry cannot do that.
Type of Change
Changes Made
apps/desktop/electron/hardening.ts— newtightenSecretFileModeandwriteSecretFileAtomichelpers,SECRET_FILE_MODE = 0o600, andSAFE_STORAGE_ENCODINGextracted so the writer here and the reader inmain.tscannot drift across the file boundary.apps/desktop/electron/main.ts—writeDesktopConnectionConfigroutes throughwriteSecretFileAtomic;readDesktopConnectionConfigcallstightenSecretFileModeon a cache miss;decryptDesktopSecretuses the shared encoding constant and gains a comment stating the non-safeStorage fallback is a read path, not a write path.apps/desktop/electron/hardening.test.ts— 15 → 27 tests.apps/desktop/e2e/at-rest-connection-token.spec.ts— new, implementation-independent at-rest contract: encryption, usability after restart, and owner-only mode on all three paths that can produce the file (fresh write, pre-existing0644, corrupt0644).native-oauth-tokens.jsonwas deliberately not routed through the new helper: its introducing commit already wrote{ mode: 0o600 }, so unlikeconnection.jsonthere is no loose-mode population in the field, and changing it would mix concerns.How to Test
apps/desktop:npx vitest run --project electron electron/hardening.test.ts→ 27 passed.npx vitest run --project electron→ 947 passed | 2 skipped. Measured baseline on the parent commit (b45d88690, currentupstream/main) in a separate worktree: 935 passed | 2 skipped. So this is +12 tests, no coverage loss;hardening.test.tsalone goes 15 → 27.npm run build && npx playwright test e2e/at-rest-connection-token.spec.ts --reporter=list→ 3 passed, 1 skipped (the skip is a deliberatetest.fixmefor the migration case, naming its three blockers).0644connection.json, launch the app andstatthe file — it becomes600on first read, and the configured gateway still connects.The vitest count is unchanged by the mode coverage below — the three new tests are Playwright, not vitest.
main.tscannot be unit-tested (it importselectron;grep -rln "from './main'"returns zero hits repo-wide), which is why the wiring is covered end to end instead.The e2e spec asserts the contract implementation-independently: the token's plaintext value and its base64 form must not appear in a raw-bytes scan of any file under
userDataorHERMES_HOME, and the app must still put the exact original token on the wire after a restart — so a "fix" that simply drops the token cannot pass. Proven non-vacuous by mutation: writing{ encoding: 'plain', value }still fails the scan while the file-exists and gateway-URL guards pass.Mutation testing (each mechanism reverted individually)
Two of the five new tests exist because reverting either owner-only mechanism alone initially scored zero failures — they were masking each other, so either could have been deleted green. That was a real anti-pattern, caught and fixed:
modetightenSecretFileModegroupstatSyncAll five were restored byte-identically afterwards (
hardening.tssha256603a7f4d…,main.ts16f075ed…) and the suite is green again.The wiring is now covered end to end
The mutation table above covers the helpers. It did not cover the wiring, and that turned out to matter: with both call sites and both imports in
main.tsreverted —writeDesktopConnectionConfigback to plainwriteFileAtomic, no tighten-on-read — the entire suite stayed green (947 passed / 2 skipped,tscexit 0, eslint clean, e2e 1 passed 1 skipped) whileconnection.jsonwent back to0644. Bundle proof:writeSecretFileAtomic(andtightenSecretFileMode(both dropped to 0 occurrences indist/electron-main.mjs. So the user-visible fix in this PR's title was, until now, untested.The e2e spec could not have caught it by construction. It asserted the encryption contract with a raw-bytes scan, and safeStorage keeps the token opaque regardless of the file's mode, so a
0644file passes that scan every time. There was no mode assertion anywhere ine2e/.The spec now asserts a third contract — unreadable by other local accounts — on all three paths that can produce the file:
connection.jsonpredates owner-only mode is tightened on read0644connection.jsonis tightened even though it never parses0644still holding token bytesEach is mutation-proven, individually:
main.tswiring (both call sites + both imports)mode 644writeDesktopConnectionConfig→writeFileAtomicJSON.parsemtimeassertionsmain.tswas restored byte-identically after each (sha2560431244d…), and the rebuilt bundle returned to sha25696ed89fc…withwriteSecretFileAtomic(back at 2 andtightenSecretFileMode(back at 3 occurrences. Every mutation was verified present indist/electron-main.mjsbefore its run, since a staledist/would silently produce a false green.Two design notes on the new assertions:
mode & 0o077 === 0, not=== 0o600. The requirement is that nobody else can reach the file; pinning exact bits would be a change-detector against a future0400or a setgid-directory umask.win32(mode bits are advisory there andtightenSecretFileModeno-ops by design — see the Windows section), so it cannot go red on a Windows runner for behaviour this PR never claimed. The encryption half stays unconditional.The two read tests also pin the invariant the tighten-on-read placement depends on: the tighten must be a chmod, not a rewrite, because it sits inside the function whose cache keys on
mtimeMs. Measured directly on macOS: mode644 → 600,mtimeMsunchanged,ctimeMsmoved.Follow-up, not done here:
readDesktopConnectionConfig/writeDesktopConnectionConfigboth hardcode the module-levelDESKTOP_CONNECTION_CONFIG_PATH, which is why no unit test can reach them. Making the path injectable — asdesktop-installation.tsalready does, precisely so desktop-installation.test.ts:41 can assert the mode of the file its orchestrating function wrote — is the durable fix and would make this unit-testable without Electron. It is a refactor of a ~12k-line file's call sites, so it does not belong in a bug fix; the e2e assertions above cover the same behaviour today, and with better fidelity (they witness the realapp.getPath('userData')file, not an injected temp path).Checklist
Code
fix(desktop):)pytest tests/ -qand all tests pass — not run, and not applicable: this change is TypeScript-only underapps/desktop/and touches no Python. I ran the JS/TS equivalents instead (vitest electron project,tscontsconfig.electron.jsonandtsconfig.e2e.json, eslint, Playwright) — all listed above. I did not run the fullscripts/run_tests.shPython suite.Lint/typecheck, run in this branch's worktree:
npx eslint electron/→ clean (exit 0).npx eslint e2e/at-rest-connection-token.spec.ts→ clean (exit 0).npx tsc -p tsconfig.electron.json --noEmitandnpx tsc -p tsconfig.e2e.json --noEmit→ both clean. To be clear,npx eslint e2e/as a whole is not clean — it exits 1 with 244 pre-existing problems (103 errors, 141 warnings) unrelated to this change; I linted only my own file.Documentation & Housekeeping
cli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.md— N/ARelationship to prior art
Searched
gh search prs/issuesforconnection.json(19 PRs / 12 issues),safeStorage(11 PRs / 5 issues), anddesktop userData mode(0 results). Two open PRs touchhardening.ts:encryptDesktopSecret, a newresolvePersistedRemoteTokenseam, an opt-in plaintext path), this changes file modes at the write/read choke point. It does not touchwriteDesktopConnectionConfig,writeFileAtomic, or either new helper (verified by grep over its diff). Both PRs do append to the sameexport {}block, so I tested that specific hazard withgit merge-fileagainst the common base: exit 0, zero conflict markers, and all six symbols coexist in the merged output. No textual conflict.bootstrap-platform.tsandmain.ts, nothardening.tsor this choke point.On #74897 (the apparent counter-signal)
#74897 moved
write_filenew files from 0600 to umask-derived 0644, which superficially points the other way. It does not generalize here, and its own body says why:_atomic_write's chmod branch only ran when the target already existed (if [ -e "$t" ]), so new files silently keptmktemp's 0600 — "invisible in single-user setups, but breaks any cross-process/cross-user reader (Obsidian LiveSync, Docker volumes, NAS mounts)." That 0600 was an accident of a skipped branch, not a policy, and #74897 restored umask-derived permissions for a path the user names, under a documented interop contract.connection.jsonis an app-private credential file inside Electron'suserDatathat the user never names and no second process is expected to read, and its two immediate neighbours are already 0600. Different contract, opposite default.