diff --git a/AGENTS.md b/AGENTS.md index 1724ba4a..74681882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,7 @@ Keep this map short and stable. When code moves, AI agents should re-read the mo - Security credentials must never be stored in `MainConfigModel` or `settings.json`. Keep passwords, TOTP seeds, USB binding tokens, and lockout state in `SecRandom/Services/Security`'s separate credential store; ordinary settings only select factors and protected operations. The sole credential file is `data/config/security/credentials.json`: its internal `FormatVersion` is authoritative, uses Argon2id-derived key material plus AES-256-GCM for portable storage, and must not depend on DPAPI, Keychain, `secret-tool`, or a versioned filename. Do not read or migrate older credential files. - OmniTTS cloud speech API keys are credentials too: they live in `data/config/voice/omnitts-keys.json` via `OmniTtsCredentialStore` (app-layer, atomic replace), never in `VoiceSettingsConfig`/`settings.json`, IPC payloads, logs, telemetry, backups, or the Android DocumentsProvider. OmniTTS is voice engine 2 (`OmniTtsSpeechProvider.OmniEngine`), sits beside System SAPI (0) and Edge TTS (1), and is a unified cloud TTS engine whose providers are OpenAI, Gemini, FishAudio, MiMo, and Custom OpenAI-compatible. OpenAI, Gemini, and MiMo voice choices use documented preset sets; Gemini uses native `/v1beta/interactions` synthesis and wraps its 24 kHz PCM response as WAV. MiMo voice-design and voice-clone models use model-specific chat-completions payloads; clone references are WAV files kept privately under `data/config/voice/mimo-voice-reference.wav`, identified by a hash, and excluded from settings, archives, IPC, and logs. Models are fetched from provider APIs or typed manually. The voice settings page shows OmniTTS-specific rows (provider, base URL, key, model, voice, batch cache) only while `VoiceEngine == 2`. `data/config/voice/` is excluded from archives and protected from the Android provider/directory launcher. iOS keeps the voice settings page unregistered (`!OperatingSystem.IsIOS()`); OmniTTS targets Windows, Linux, macOS, and Android. - Security authorization always flows through `ISecurityService`; do not add direct validation checks to tray handlers, windows, ViewModels, or linkage code. Passwords require at least 6 characters, with no artificial character-class rule. +- Sudo mode is always enabled: after a successful authentication for draw/window operations, protected operations skip re-verification for a configurable duration (`SudoModeDurationSeconds`, default 60s). The settings page has its own independent sudo lifetime: it activates after successful verification when opening settings (if "Open Settings" protected) OR on first setting change requiring verification (if "Open Settings" unprotected). Once active, all subsequent settings operations skip verification until the settings window closes. TOTP setup (`BeginTotpSetupAsync`) must always require fresh verification and must never be bypassed by sudo mode. The tray menu exposes an "Exit Sudo Mode" option (visible only when global sudo is active) that calls `DeactivateGlobalSudoMode()` to clear the global timer only; it cannot clear the settings-page sudo state. - Full IPC/URL compatibility is app-layer routed through `ProtocolCommandRouter`: structured current-user named-pipe IPC is additive to legacy `ShowMainWindow`/`Restart`/`Url:` delivery, and all external mutations must use `ISecurityService`. `data/*` queries must use non-mutating profile snapshots, never active-profile loading APIs. - `HistoryItem.DrawRoundId` identifies every record committed by one logical draw. Populate it for new history writes; IPC history projections group by it and must never expose internal `RecordId`. - Draw commits must go through `IDrawCommitService` (`DrawCommitCoordinator`): one logical draw gets exactly one `DrawRoundId` (a caller may supply one), temporary records commit before persistent history, a mid-commit failure rolls back through snapshot compensation, and commits serialize behind the coordinator gate. Never reintroduce bare two-step writes of `IProfileService.Record*History` plus temporary-record calls; the optional `drawRoundId` parameter (and `drawMethod` on `RecordPrizeHistory`) exists for coordinator use. diff --git a/SecRandom.Core/Models/SubConfigs/SecuritySettingsConfig.cs b/SecRandom.Core/Models/SubConfigs/SecuritySettingsConfig.cs index 6d204183..dba673b1 100644 --- a/SecRandom.Core/Models/SubConfigs/SecuritySettingsConfig.cs +++ b/SecRandom.Core/Models/SubConfigs/SecuritySettingsConfig.cs @@ -24,6 +24,8 @@ public partial class SecuritySettingsConfig : ObservableObject [ObservableProperty] private bool _protectLotteryReset; [ObservableProperty] private bool _protectLinkage; + [ObservableProperty] private int _sudoModeDurationSeconds = 60; + // Compatibility bridges for the original placeholder fields. public bool VerifyBeforeSensitiveOperations { diff --git a/SecRandom/App.axaml b/SecRandom/App.axaml index 8197df39..1d4122a8 100644 --- a/SecRandom/App.axaml +++ b/SecRandom/App.axaml @@ -50,6 +50,12 @@ + + + + diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 9d3aba70..9369e1cc 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -114,6 +114,9 @@ public partial class App : Application private static MainWindow? _settingsWindow; private static Task? _runtimeServicesStartupTask; private NativeMenuItem? _floatingWindowMenuItem; + private NativeMenuItem? _exitSudoModeMenuItem; + private NativeMenuItem? _exitSudoModeSeparator; + private Timer? _sudoModeRefreshTimer; private static IClassicDesktopStyleApplicationLifetime? _desktopLifetime; private IHost? _mobileHost; private ISingleViewApplicationLifetime? _singleViewLifetime; @@ -1119,7 +1122,14 @@ public void InitializeApp() var menu = this.FindResource(@"AppMenu") as NativeMenu; taskBarIconService.MainTaskBarIcon.Menu = menu; _floatingWindowMenuItem = menu?.Items.ElementAtOrDefault(3) as NativeMenuItem; + _exitSudoModeMenuItem = menu?.Items.ElementAtOrDefault(9) as NativeMenuItem; + _exitSudoModeSeparator = menu?.Items.ElementAtOrDefault(8) as NativeMenuItem; RefreshTrayWindowMenuItems(); + RefreshSudoMenuItem(); + + IAppHost.GetService().SudoModeChanged += RefreshSudoMenuItem; + + _sudoModeRefreshTimer = new Timer(_ => Dispatcher.UIThread.Post(RefreshSudoMenuItem), null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)); taskBarIconService.MainTaskBarIcon.IsVisible = true; taskBarIconService.MainTaskBarIcon.Clicked += MainTaskBarIconOnClicked; IAppHost.GetService().EnsureConfiguredIntegrations(); @@ -1142,6 +1152,10 @@ public async Task StopAsync() private async Task StopAsync(bool requestLifetimeShutdown) { + IAppHost.TryGetService().SudoModeChanged -= RefreshSudoMenuItem; + _sudoModeRefreshTimer?.Dispose(); + _sudoModeRefreshTimer = null; + lock (_shutdownGate) { if (_isStopping) @@ -2080,6 +2094,8 @@ private void MainTaskBarIconOnClicked(object? sender, EventArgs e) return; } + RefreshSudoMenuItem(); + var taskBarIconService = IAppHost.Host!.Services .GetServices().OfType().First(); @@ -2143,11 +2159,45 @@ private void RefreshTrayWindowMenuItems() : SecRandom.Langs.Common.Resources.Menu_ShowFloatingWindow; } + private void RefreshSudoMenuItem() + { + if (_exitSudoModeMenuItem is not null) + { + var securityService = IAppHost.GetService(); + var isVisible = securityService.IsGlobalSudoModeActive(); + _exitSudoModeMenuItem.IsVisible = isVisible; + + if (_exitSudoModeSeparator is not null) + { + var menu = this.FindResource(@"AppMenu") as NativeMenu; + if (menu is not null) + { + if (isVisible && !menu.Items.Contains(_exitSudoModeSeparator)) + { + // Insert separator before Exit Sudo Mode item + var index = menu.Items.IndexOf(_exitSudoModeMenuItem); + if (index >= 0) + menu.Items.Insert(index, _exitSudoModeSeparator); + } + else if (!isVisible && menu.Items.Contains(_exitSudoModeSeparator)) + { + menu.Items.Remove(_exitSudoModeSeparator); + } + } + } + } + } + private void MenuItemOpenSettings_OnClick(object? sender, EventArgs e) { ShowSettingsWindow(); } + private void MenuItemExitSudoMode_OnClick(object? sender, EventArgs e) + { + IAppHost.GetService().DeactivateGlobalSudoMode(); + } + private void MenuItemRestartProgram_OnClick(object? sender, EventArgs e) { ObserveTask(IAppHost.GetService().AuthorizeAsync( diff --git a/SecRandom/Langs/Common/Resources.Designer.cs b/SecRandom/Langs/Common/Resources.Designer.cs index 09b6711f..c47fc0f3 100644 --- a/SecRandom/Langs/Common/Resources.Designer.cs +++ b/SecRandom/Langs/Common/Resources.Designer.cs @@ -237,6 +237,12 @@ public static string Menu_ExitProgram { } } + public static string Menu_ExitSudoMode { + get { + return ResourceManager.GetString("Menu_ExitSudoMode", resourceCulture); + } + } + public static string App_Description { get { return ResourceManager.GetString("App_Description", resourceCulture); diff --git a/SecRandom/Langs/Common/Resources.en-US.resx b/SecRandom/Langs/Common/Resources.en-US.resx index 0f51a826..77c4fd6d 100644 --- a/SecRandom/Langs/Common/Resources.en-US.resx +++ b/SecRandom/Langs/Common/Resources.en-US.resx @@ -268,4 +268,7 @@ Move the entire portable package to a writable location, or adjust the directory Go + + Exit Sudo Mode + diff --git a/SecRandom/Langs/Common/Resources.ja-JP.resx b/SecRandom/Langs/Common/Resources.ja-JP.resx index 0e579f12..caa86207 100644 --- a/SecRandom/Langs/Common/Resources.ja-JP.resx +++ b/SecRandom/Langs/Common/Resources.ja-JP.resx @@ -122,4 +122,5 @@ + Sudo モードを終了 diff --git a/SecRandom/Langs/Common/Resources.resx b/SecRandom/Langs/Common/Resources.resx index 5907f50f..111e583b 100644 --- a/SecRandom/Langs/Common/Resources.resx +++ b/SecRandom/Langs/Common/Resources.resx @@ -268,4 +268,7 @@ + + 退出 Sudo 模式 + diff --git a/SecRandom/Langs/SettingsPages/Security/Resources.Security.cs b/SecRandom/Langs/SettingsPages/Security/Resources.Security.cs index 174270e9..7b60359a 100644 --- a/SecRandom/Langs/SettingsPages/Security/Resources.Security.cs +++ b/SecRandom/Langs/SettingsPages/Security/Resources.Security.cs @@ -98,4 +98,9 @@ public partial class Resources public static string M_UsbUpdated => Text(nameof(M_UsbUpdated)); public static string M_UsbUpdateFailed => Text(nameof(M_UsbUpdateFailed)); public static string M_Copied => Text(nameof(M_Copied)); + public static string S_SudoModeDuration => Text(nameof(S_SudoModeDuration)); + public static string S_SudoModeDuration_D => Text(nameof(S_SudoModeDuration_D)); + public static string S_SudoModeDurationUnit => Text(nameof(S_SudoModeDurationUnit)); + public static string Menu_ExitSudoMode => Text(nameof(Menu_ExitSudoMode)); + public static string M_SudoModeExited => Text(nameof(M_SudoModeExited)); } diff --git a/SecRandom/Langs/SettingsPages/Security/Resources.en-US.resx b/SecRandom/Langs/SettingsPages/Security/Resources.en-US.resx index dbcbecbb..6103fabc 100644 --- a/SecRandom/Langs/SettingsPages/Security/Resources.en-US.resx +++ b/SecRandom/Langs/SettingsPages/Security/Resources.en-US.resx @@ -111,4 +111,9 @@ Invalid code. TOTP settings were not saved. USB binding updated. Unable to update USB binding. Confirm that the drive is connected and writable. + Sudo Duration + How long sudo mode remains active after a successful verification; the settings page does not follow this duration and expires when closed + seconds + Exit Sudo Mode + Sudo mode deactivated diff --git a/SecRandom/Langs/SettingsPages/Security/Resources.ja-JP.resx b/SecRandom/Langs/SettingsPages/Security/Resources.ja-JP.resx index 897ea9a6..fc6ef53b 100644 --- a/SecRandom/Langs/SettingsPages/Security/Resources.ja-JP.resx +++ b/SecRandom/Langs/SettingsPages/Security/Resources.ja-JP.resx @@ -110,5 +110,10 @@ TOTP 設定を保存しました。 コードが無効です。TOTP 設定は保存されませんでした。 USB 登録を更新しました。 - USB 登録を更新できません。ドライブの接続と書き込み権限を確認してください。 +USB 登録を更新できません。ドライブの接続と書き込み権限を確認してください。 + Sudo 有効期間 + 認証後に Sudo モードが有効な期間。設定ページはこの設定に従わず、閉じると無効になります + + Sudo モードを終了 + Sudo モードを無効にしました diff --git a/SecRandom/Langs/SettingsPages/Security/Resources.resx b/SecRandom/Langs/SettingsPages/Security/Resources.resx index 83090dcc..fb11c868 100644 --- a/SecRandom/Langs/SettingsPages/Security/Resources.resx +++ b/SecRandom/Langs/SettingsPages/Security/Resources.resx @@ -140,4 +140,9 @@ 验证码 复制 已复制 + Sudo 有效时长 + 验证通过后的免验证有效时长,设置页面不遵循此设置,关闭设置页面即失效 + + 退出 Sudo 模式 + 已退出 Sudo 模式 diff --git a/SecRandom/Services/Security/SecurityContracts.cs b/SecRandom/Services/Security/SecurityContracts.cs index 119520ab..ea5f3b6c 100644 --- a/SecRandom/Services/Security/SecurityContracts.cs +++ b/SecRandom/Services/Security/SecurityContracts.cs @@ -94,6 +94,12 @@ Task AuthorizeSettingsAsync( Task UnbindUsbAsync(string bindingId, CancellationToken cancellationToken = default); Task UnbindUsbAsync(TopLevel xamlRoot, string bindingId, CancellationToken cancellationToken = default); bool TryUpdateSettings(Action update); + bool IsSudoModeActive(); + bool IsGlobalSudoModeActive(); + void DeactivateGlobalSudoMode(); + void DeactivateSudoMode(); + void DeactivateSettingsSudoMode(); + event Action? SudoModeChanged; } public sealed record UsbBindingInfo(string Id, string DisplayName, bool IsPresent); diff --git a/SecRandom/Services/Security/SecurityService.cs b/SecRandom/Services/Security/SecurityService.cs index 19dfae2d..7b46b0ef 100644 --- a/SecRandom/Services/Security/SecurityService.cs +++ b/SecRandom/Services/Security/SecurityService.cs @@ -33,6 +33,10 @@ internal sealed class SecurityService( private readonly SemaphoreSlim _authorizationGate = new(1, 1); private string? _pendingTotpSecret; private SecurityCredentialContext? _pendingTotpContext; + private DateTimeOffset? _sudoModeExpirationUtc; + private bool _settingsSudoActive; + + public event Action? SudoModeChanged; private SecuritySettingsConfig Settings => configHandler.Data.SecuritySettings; @@ -62,6 +66,18 @@ public bool RequiresVerification(SecurityOperation operation) if (!Settings.SecurityEnabled) return false; + // Check the appropriate sudo mode based on operation + if (operation == SecurityOperation.OpenSettings) + { + if (_settingsSudoActive) + return false; + } + else + { + if (_sudoModeExpirationUtc is { } expiration && _timeProvider.GetUtcNow() < expiration) + return false; + } + var metadata = credentialStore.LoadMetadata(); if (!metadata.IsReadable) return true; @@ -128,6 +144,8 @@ public async Task AuthorizeAsync( return false; } + ActivateSudoMode(); + await action(); return true; } @@ -195,6 +213,9 @@ public async Task AuthorizeSettingsAsync( return new SecurityAuthorizationResult(false); } + ActivateSudoMode(); + _settingsSudoActive = true; + await action(); return new SecurityAuthorizationResult(true); } @@ -209,6 +230,18 @@ public Task UpdateSecuritySettingsAsync( Action update, CancellationToken cancellationToken = default) { + if (_settingsSudoActive) + { + lock (_gate) + { + update(); + var metadata = credentialStore.LoadMetadata(); + NormalizeSettings(metadata); + configHandler.Save(); + } + return Task.FromResult(true); + } + return AuthorizePasswordCoreAsync(xamlRoot, context => { lock (_gate) @@ -218,6 +251,7 @@ public Task UpdateSecuritySettingsAsync( configHandler.Save(); } + _settingsSudoActive = true; return Task.FromResult(false); }, cancellationToken); } @@ -984,6 +1018,57 @@ private void DisableOperationProtections() Settings.ProtectLinkage = false; } + public bool IsSudoModeActive() + { + if (_settingsSudoActive) + return true; + + if (_sudoModeExpirationUtc is { } expiration) + return _timeProvider.GetUtcNow() < expiration; + + return false; + } + + public bool IsGlobalSudoModeActive() + { + if (_sudoModeExpirationUtc is { } expiration) + { + var now = _timeProvider.GetUtcNow(); + if (now >= expiration) + { + _sudoModeExpirationUtc = null; + SudoModeChanged?.Invoke(); + return false; + } + return true; + } + return false; + } + + public void DeactivateGlobalSudoMode() + { + _sudoModeExpirationUtc = null; + SudoModeChanged?.Invoke(); + } + + public void DeactivateSudoMode() + { + _settingsSudoActive = false; + _sudoModeExpirationUtc = null; + SudoModeChanged?.Invoke(); + } + + public void DeactivateSettingsSudoMode() + { + _settingsSudoActive = false; + } + + private void ActivateSudoMode() + { + _sudoModeExpirationUtc = _timeProvider.GetUtcNow().AddSeconds(Settings.SudoModeDurationSeconds); + SudoModeChanged?.Invoke(); + } + private UsbDriveInfo? FindDevice(string deviceId) { if (string.IsNullOrWhiteSpace(deviceId)) diff --git a/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml b/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml index 24ddc7ee..670d7c6f 100644 --- a/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml +++ b/SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml @@ -95,6 +95,23 @@ + + + + + + + + + Settings.PasswordEnabled, value => Settings.PasswordEnabled = value), @@ -59,6 +60,7 @@ public SecuritySettingsPage() public string TotpButtonText { get; private set; } = SR.C_SetTotp; public bool IsLockedOut { get; private set; } public string LockoutText { get; private set; } = string.Empty; + public double SudoModeDurationValue { get; set; } event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged { @@ -277,6 +279,18 @@ private async void ManageUsb_OnClick(object? sender, RoutedEventArgs e) RefreshSecurityState(); } + private void SudoModeDuration_OnValueChanged(object? sender, NumericUpDownValueChangedEventArgs e) + { + if (_refreshing) + return; + + if (e.NewValue is { } newValue && (int)newValue != Settings.SudoModeDurationSeconds) + { + Settings.SudoModeDurationSeconds = (int)newValue; + ConfigHandler.Save(); + } + } + private async Task ApplySecuritySettingsUpdateAsync(TopLevel xamlRoot, Action update, Action restoreView) { _refreshing = true; diff --git a/SecRandom/Views/SettingsView.axaml.cs b/SecRandom/Views/SettingsView.axaml.cs index 6ef86887..4f6d1f9c 100644 --- a/SecRandom/Views/SettingsView.axaml.cs +++ b/SecRandom/Views/SettingsView.axaml.cs @@ -100,6 +100,7 @@ public SettingsView() RestorePreviewControls(); if (_isMobile) RefreshMobileDrawSessions(); + IAppHost.TryGetService()?.DeactivateSettingsSudoMode(); if (ReferenceEquals(Current, this)) Current = null; };