diff --git a/lib/data/hub_auth_token.dart b/lib/data/hub_auth_token.dart index 02c522d..cc5bb6c 100644 --- a/lib/data/hub_auth_token.dart +++ b/lib/data/hub_auth_token.dart @@ -58,19 +58,56 @@ class HubAuthToken { } /// Persist [token] to disk, creating `~/.fai/` if needed. - /// On Unix the file is chmod-ed to 0600. Windows leaves the - /// default user-ACL alone. + /// + /// Atomic write: writes to `.tmp`, chmods 0600 on + /// Unix BEFORE moving into place, then renames. This + /// closes the TOCTOU window where the historical + /// `writeAsString` + later `chmod 600` sequence left the + /// file world-readable for the duration of the chmod call. + /// + /// Also sets `~/.fai/` itself to 0700 on Unix on first + /// creation so other users on a shared host can't even + /// enumerate the directory (the 0600 token-file mode is + /// fine in isolation; combining it with a 0755 parent + /// directory leaks the file's existence). static Future write(String token) async { final f = File(path); - await f.parent.create(recursive: true); - await f.writeAsString(token.trim(), flush: true); + final tmp = File('${f.path}.tmp'); + final parent = f.parent; + final parentExisted = await parent.exists(); + await parent.create(recursive: true); + if ((Platform.isLinux || Platform.isMacOS) && !parentExisted) { + try { + await Process.run('chmod', ['700', parent.path]); + } catch (_) {/* best-effort */} + } + // Write to the temp path with restrictive mode set up + // BEFORE the rename — the final file is never visible + // to other processes with the default umask mode. + await tmp.writeAsString(token.trim(), flush: true); if (Platform.isLinux || Platform.isMacOS) { try { - await Process.run('chmod', ['600', f.path]); - } catch (_) { - // best-effort; default perms still confine to the - // operator's machine. + await Process.run('chmod', ['600', tmp.path]); + } catch (_) {/* best-effort */} + } + // Atomic rename: on POSIX, replaces the destination + // in one syscall. On Windows, Dart's File.rename uses + // MoveFileEx with MOVEFILE_REPLACE_EXISTING which is + // equally atomic for this use case. + try { + await tmp.rename(f.path); + } catch (_) { + // On rare cross-mount setups, rename may fail; fall + // back to copy + delete. The token is then briefly + // visible at the .tmp path with 0600 mode — still + // safe, but not atomic. + await tmp.copy(f.path); + if (Platform.isLinux || Platform.isMacOS) { + try { + await Process.run('chmod', ['600', f.path]); + } catch (_) {/* best-effort */} } + await tmp.delete(); } } diff --git a/pubspec.yaml b/pubspec.yaml index d265114..d3f5f43 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.49.0 +version: 0.49.1 environment: sdk: ^3.11.0-200.1.beta