Some checks failed
Security / Security check (push) Failing after 2s
Track the platform rename: the hub spawn path is now ~/.chain/bin/chain (was ~/.fai/bin/fai.exe on Windows — both dir and binary were stale, so Studio could not launch the hub after the config-dir rename), the ~/.fai/* help strings become ~/.chain/*, FAI_REGISTRY_TOKEN -> CHAIN_REGISTRY_TOKEN, and the two in-app doc URLs point at the public fai/chain repo (fai/platform was renamed to the private fai/chain-private). The .fai module bundle extension is left unchanged (format phase). flutter analyze: no issues. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
126 lines
4.5 KiB
Dart
126 lines
4.5 KiB
Dart
import 'dart:io';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
/// Operator-managed gRPC bearer token used to authenticate
|
|
/// Studio against a hub that has `auth.tokens:` configured
|
|
/// (RBAC Level 2). Stored at `~/.chain/hub-auth-token` (mode
|
|
/// 0600 on Unix). Sister of [RegistryToken] — same on-disk
|
|
/// hygiene, different secret.
|
|
///
|
|
/// When the file is present and non-empty Studio constructs
|
|
/// `HubClient(authToken: ...)`, which attaches
|
|
/// `Authorization: Bearer <token>` to every gRPC and
|
|
/// gRPC-Web call. When the file is missing Studio stays in
|
|
/// anonymous mode; the hub then either accepts or rejects
|
|
/// based on its own `auth.tokens:` config.
|
|
class HubAuthToken {
|
|
static String _faiHome() {
|
|
final home =
|
|
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
|
if (home == null || home.isEmpty) {
|
|
throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)');
|
|
}
|
|
return p.join(home, '.fai');
|
|
}
|
|
|
|
/// Absolute path to the token file.
|
|
static String get path => p.join(_faiHome(), 'hub-auth-token');
|
|
|
|
/// True iff the file exists with a non-empty trimmed body.
|
|
static Future<bool> isConfigured() async {
|
|
final f = File(path);
|
|
if (!await f.exists()) return false;
|
|
final content = await f.readAsString();
|
|
return content.trim().isNotEmpty;
|
|
}
|
|
|
|
/// Length of the trimmed token, or null when the file is
|
|
/// missing / empty. Used for status display ("Configured
|
|
/// (40 chars)") without ever surfacing the secret.
|
|
static Future<int?> charCount() async {
|
|
final f = File(path);
|
|
if (!await f.exists()) return null;
|
|
final content = await f.readAsString();
|
|
final trimmed = content.trim();
|
|
return trimmed.isEmpty ? null : trimmed.length;
|
|
}
|
|
|
|
/// Read the trimmed token, or `null` when missing/empty.
|
|
/// Studio calls this at startup so [HubService] can supply
|
|
/// it to the underlying gRPC client.
|
|
static Future<String?> read() async {
|
|
final f = File(path);
|
|
if (!await f.exists()) return null;
|
|
final content = (await f.readAsString()).trim();
|
|
return content.isEmpty ? null : content;
|
|
}
|
|
|
|
/// Persist [token] to disk, creating `~/.chain/` if needed.
|
|
///
|
|
/// Atomic write: writes to `<path>.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 `~/.chain/` 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<void> write(String token) async {
|
|
final f = File(path);
|
|
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', 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();
|
|
}
|
|
}
|
|
|
|
/// Remove the token file. Returns true iff the file existed
|
|
/// before deletion.
|
|
static Future<bool> clear() async {
|
|
final f = File(path);
|
|
if (!await f.exists()) return false;
|
|
await f.delete();
|
|
return true;
|
|
}
|
|
}
|