import { execFile, execFileSync } from 'node:child_process' import { randomBytes } from 'node:crypto' import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' import { dirname, win32 as pathWin32 } from 'node:path' let cachedWindowsUserSid: string | null | undefined type HardenedPathCacheEntry = { isDirectory: boolean dev: number ino: number size: number mode: number ctimeMs: number mtimeMs: number birthtimeMs: number } // Why: hardening shells out to PowerShell on Windows (~1-1.5s each). Re-hardening a path // whose ACLs we already applied in this process is wasted work that stalls the main thread, // so cache idempotent calls. The post-rename target write is NOT routed through this — it // always re-hardens (new inode) and then refreshes the cache entry. const hardenedPathsThisProcess = new Map() // Why: a directory's required ACL does not change because its mtime changed (child writes // update the directory's mtime constantly). Keying the directory cache on mtime/ctime causes // a cache miss — and a blocking PowerShell spawn — on every read-path call. We instead cache // directory hardening by PATH for the entire process lifetime: once a directory's ACL has // been applied in this process we trust it stays correct. Files keep the metadata-keyed cache // so that post-rename inode changes are detected correctly. See #4901 regression report. // // Known limitation: because this is a process-lifetime path cache, a directory that is deleted // and recreated during the same process will NOT be re-hardened. The secure dirs we own // (.orca runtime/auth/device stores) are not deleted at runtime, so this is acceptable; the // next process restart re-hardens. Do not rely on this cache if a path's lifecycle changes. const hardenedDirectoryPathsThisProcess = new Set() function hardenSecureDirectoryOnce(dirPath: string): void { // Why: directory hardening is async + path-cached. The directory ACL is broad-but-bounded // (current user + SYSTEM + Administrators) and re-applying it is what stormed the main // thread (#4901), so we never block on it. A pending dir ACL on first run is acceptable // because the credential FILES inside it are hardened synchronously on the write path. if (hardenedDirectoryPathsThisProcess.has(dirPath)) { return } applySecurePathRestriction(dirPath, true, process.platform, false) // Optimistic: cache even though the async ACL may still be in flight. The dir restriction // is best-effort and re-running it does not improve security, so we accept no-retry here. hardenedDirectoryPathsThisProcess.add(dirPath) } function hardenSecurePathOnce(targetPath: string, isDirectory: boolean): boolean { if (isDirectory && process.platform === 'win32') { hardenSecureDirectoryOnce(targetPath) return true } const currentEntry = getHardenedPathCacheEntry(targetPath, isDirectory) if (!currentEntry) { hardenedPathsThisProcess.delete(targetPath) } const cachedEntry = hardenedPathsThisProcess.get(targetPath) if (currentEntry && cachedEntry && hardenedPathCacheEntriesMatch(currentEntry, cachedEntry)) { return true } // Why: the read path re-hardens an existing file at most once per process (metadata-cached // above), so async file hardening here does not storm. The async restriction only re-asserts // an ACL on a file that already exists; new credential files are hardened synchronously on // the write path (see writeSecureFile), so there is no async window on creation. if (applySecurePathRestriction(targetPath, isDirectory, process.platform, false)) { rememberHardenedPath(targetPath, isDirectory) return true } return false } export function writeSecureJsonFile(targetPath: string, value: unknown): void { writeSecureFile(targetPath, JSON.stringify(value, null, 2)) } export function writeSecureFile(targetPath: string, contents: string): void { const dir = dirname(targetPath) if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o700 }) } // Windows directory hardening stays async + path-cached: it is what stormed the main thread // (#4901). POSIX keeps the metadata cache so chmod/ctime changes are corrected. hardenSecurePathOnce(dir, true) const tmpFile = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes(4).toString('hex')}.tmp` try { writeFileSync(tmpFile, contents, { encoding: 'utf-8', mode: 0o600 }) // Why: on Windows writeFileSync({mode:0o600}) is a no-op, so the file is created carrying // the parent directory's inherited (broader) ACL. We must restrict the credential file's // ACL SYNCHRONOUSLY before the atomic rename publishes it — otherwise writeSecureFile // would return with the credential readable under inherited ACLs for the ~1-1.5s PowerShell // cold-start window. The write path is infrequent, so the synchronous cost is acceptable; // it is the READ path that stormed (#4901), and that stays async + cached. applySecurePathRestriction(tmpFile, false, process.platform, true) renameSync(tmpFile, targetPath) // Why: these files carry runtime auth/device credentials; the published // path must remain current-user only after the atomic rename. Apply synchronously and only // cache on confirmed success so a failed ACL apply is retried on the next read/write. if (applySecurePathRestriction(targetPath, false, process.platform, true)) { rememberHardenedPath(targetPath, false) } } catch (error) { rmSync(tmpFile, { force: true }) throw error } } export function hardenExistingSecureFile(targetPath: string): void { const dir = dirname(targetPath) if (existsSync(dir)) { hardenSecurePathOnce(dir, true) } if (existsSync(targetPath)) { hardenSecurePathOnce(targetPath, false) } } /** Applies the platform-appropriate permission restriction to a path once, bypassing the cache. */ export function hardenSecurePath( targetPath: string, options: { isDirectory: boolean platform: NodeJS.Platform sync?: boolean } ): void { applySecurePathRestriction( targetPath, options.isDirectory, options.platform, options.sync ?? false ) } /** Applies hardening; async Windows calls only report that best-effort ACL work was accepted. */ function applySecurePathRestriction( targetPath: string, isDirectory: boolean, platform: NodeJS.Platform, sync: boolean ): boolean { if (platform === 'win32') { if (sync) { // Why: the write path must apply the credential FILE's ACL before returning, otherwise // the file is briefly readable under inherited (broader) ACLs (writeFileSync mode is a // no-op on Windows). Run PowerShell synchronously and report real success so callers only // cache the path as hardened when the ACL actually applied. The write path is infrequent. return restrictWindowsPathSync(targetPath, isDirectory) } // Why: the directory and read-path re-harden are async (fire-and-forget) to avoid blocking // the main thread (#4901). We optimistically return true because the restriction is // best-effort; the cache entry is written immediately so we do not re-spawn on the next call. bestEffortRestrictWindowsPath(targetPath, isDirectory) return true } chmodSync(targetPath, isDirectory ? 0o700 : 0o600) return true } /** Caches the current metadata snapshot for a just-hardened path, or clears it if the path is gone. */ function rememberHardenedPath(targetPath: string, isDirectory: boolean): void { const entry = getHardenedPathCacheEntry(targetPath, isDirectory) if (entry) { hardenedPathsThisProcess.set(targetPath, entry) } else { hardenedPathsThisProcess.delete(targetPath) } } /** * Snapshots a path's identity, mode, and timestamps so later drift is detectable. * Mode is tracked directly so a chmod is caught even where coarse ctime granularity hides it. */ function getHardenedPathCacheEntry( targetPath: string, isDirectory: boolean ): HardenedPathCacheEntry | null { try { const stats = statSync(targetPath) if (stats.isDirectory() !== isDirectory) { return null } return { isDirectory, dev: stats.dev, ino: stats.ino, size: stats.size, mode: stats.mode & 0o777, ctimeMs: stats.ctimeMs, mtimeMs: stats.mtimeMs, birthtimeMs: stats.birthtimeMs } } catch { return null } } /** True when two snapshots describe the same unchanged path (identity, mode, timestamps). */ function hardenedPathCacheEntriesMatch( a: HardenedPathCacheEntry, b: HardenedPathCacheEntry ): boolean { return ( a.isDirectory === b.isDirectory && a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mode === b.mode && a.ctimeMs === b.ctimeMs && a.mtimeMs === b.mtimeMs && a.birthtimeMs === b.birthtimeMs ) } function buildWindowsRestrictAclArgs( targetPath: string, currentUserSid: string, isDirectory: boolean ): string[] { return [ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_RESTRICT_ACL_SCRIPT, targetPath, currentUserSid, isDirectory ? '1' : '0' ] } function bestEffortRestrictWindowsPath(targetPath: string, isDirectory: boolean): void { const currentUserSid = getCurrentWindowsUserSid() if (!currentUserSid) { return } // Why: execFile (async) is used instead of execFileSync to avoid blocking the Electron main // thread. PowerShell cold-start is ~1–1.5 s; spawning it synchronously on every read-path // call saturated the main thread in v1.4.52+ where the env-store is read ~2×/s by the // remote-runtime tab-sync loop (#4901 regression). The restriction is best-effort // (see function name), so it is safe to apply it in the background. execFile( getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'), buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory), { windowsHide: true, timeout: 5000 }, () => { // Why: errors are intentionally ignored — credential-file hardening should not // prevent Orca from starting on Windows machines where PowerShell ACL APIs are // unavailable or locked down. } ) } function restrictWindowsPathSync(targetPath: string, isDirectory: boolean): boolean { const currentUserSid = getCurrentWindowsUserSid() if (!currentUserSid) { return false } // Why: synchronous variant for the credential-FILE write path only. The file must not be // published (renamed into place / returned to the caller) until its ACL has actually been // restricted, so we block here and report real success. This is the rare path; the frequent // read path stays async (bestEffortRestrictWindowsPath) to avoid the #4901 main-thread storm. try { execFileSync( getWindowsSystemToolPath('WindowsPowerShell\\v1.0\\powershell.exe'), buildWindowsRestrictAclArgs(targetPath, currentUserSid, isDirectory), { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true, timeout: 5000 } ) return true } catch { // Why: best-effort — a failed ACL apply (locked-down PowerShell, etc.) must not crash the // write. Returning false leaves the path uncached so a later read/write retries it. return false } } const WINDOWS_RESTRICT_ACL_SCRIPT = ` $ErrorActionPreference = 'Stop' $path = $args[0] $currentUserSid = $args[1] $isDirectory = $args[2] -eq '1' $allowedSidTexts = @($currentUserSid, 'S-1-5-18', 'S-1-5-32-544') $allowedSids = @{} foreach ($sidText in $allowedSidTexts) { $allowedSids[$sidText] = $true } $acl = Get-Acl -LiteralPath $path $acl.SetAccessRuleProtection($true, $false) foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) } $inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::None if ($isDirectory) { $inheritanceFlags = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit } foreach ($sidText in $allowedSidTexts) { $sid = [System.Security.Principal.SecurityIdentifier]::new($sidText) $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( $sid, [System.Security.AccessControl.FileSystemRights]::FullControl, $inheritanceFlags, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow ) [void]$acl.AddAccessRule($rule) } Set-Acl -LiteralPath $path -AclObject $acl $verifiedAcl = Get-Acl -LiteralPath $path if (-not $verifiedAcl.AreAccessRulesProtected) { throw 'ACL inheritance is still enabled' } $fullControl = [System.Security.AccessControl.FileSystemRights]::FullControl foreach ($rule in @($verifiedAcl.Access)) { $sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value if (-not $allowedSids.ContainsKey($sid)) { throw "Unexpected ACL entry $sid" } if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { throw "Unexpected ACL deny entry $sid" } if (($rule.FileSystemRights -band $fullControl) -ne $fullControl) { throw "ACL entry $sid does not grant FullControl" } } `.trim() function getCurrentWindowsUserSid(): string | null { if (cachedWindowsUserSid !== undefined) { return cachedWindowsUserSid } try { const output = execFileSync( getWindowsSystemToolPath('whoami.exe'), ['/user', '/fo', 'csv', '/nh'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 } ).trim() const columns = parseCsvLine(output) cachedWindowsUserSid = columns[1] ?? null } catch { cachedWindowsUserSid = null } return cachedWindowsUserSid } function getWindowsSystemToolPath(relativeSystem32Path: string): string { const systemRoot = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows' return pathWin32.join(systemRoot, 'System32', relativeSystem32Path) } function parseCsvLine(line: string): string[] { return line.split(/","/).map((part) => part.replace(/^"/, '').replace(/"$/, '')) } export function __resetSecureFileWindowsUserSidForTests(): void { cachedWindowsUserSid = undefined } export function __resetSecureFileHardenedPathsForTests(): void { hardenedPathsThisProcess.clear() hardenedDirectoryPathsThisProcess.clear() }