Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions SecRandom.Core/Models/SubConfigs/SecuritySettingsConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
6 changes: 6 additions & 0 deletions SecRandom/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@

<NativeMenuItem Header="{x:Static langs:Resources.Menu_ExitProgram}"
Click="MenuItemExitProgram_OnClick" />

<NativeMenuItemSeparator />

<NativeMenuItem Header="{x:Static langs:Resources.Menu_ExitSudoMode}"
Click="MenuItemExitSudoMode_OnClick"
IsVisible="False" />
</NativeMenu>
</Application.Resources>
</Application>
50 changes: 50 additions & 0 deletions SecRandom/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@
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;
Expand Down Expand Up @@ -1119,7 +1122,14 @@
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<ISecurityService>().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<DesktopIntegrationService>().EnsureConfiguredIntegrations();
Expand All @@ -1142,6 +1152,10 @@

private async Task StopAsync(bool requestLifetimeShutdown)
{
IAppHost.TryGetService<ISecurityService>().SudoModeChanged -= RefreshSudoMenuItem;

Check warning on line 1155 in SecRandom/App.axaml.cs

View workflow job for this annotation

GitHub Actions / CodeQL 分析 (csharp, manual)

Dereference of a possibly null reference.

Check warning on line 1155 in SecRandom/App.axaml.cs

View workflow job for this annotation

GitHub Actions / CodeQL 分析 (csharp, manual)

Dereference of a possibly null reference.
_sudoModeRefreshTimer?.Dispose();
_sudoModeRefreshTimer = null;

lock (_shutdownGate)
{
if (_isStopping)
Expand Down Expand Up @@ -2080,6 +2094,8 @@
return;
}

RefreshSudoMenuItem();

var taskBarIconService = IAppHost.Host!.Services
.GetServices<IHostedService>().OfType<TaskBarIconService>().First();

Expand Down Expand Up @@ -2143,11 +2159,45 @@
: SecRandom.Langs.Common.Resources.Menu_ShowFloatingWindow;
}

private void RefreshSudoMenuItem()
{
if (_exitSudoModeMenuItem is not null)
{
var securityService = IAppHost.GetService<ISecurityService>();
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<ISecurityService>().DeactivateGlobalSudoMode();
}

private void MenuItemRestartProgram_OnClick(object? sender, EventArgs e)
{
ObserveTask(IAppHost.GetService<ISecurityService>().AuthorizeAsync(
Expand Down
6 changes: 6 additions & 0 deletions SecRandom/Langs/Common/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions SecRandom/Langs/Common/Resources.en-US.resx
Original file line number Diff line number Diff line change
Expand Up @@ -268,4 +268,7 @@ Move the entire portable package to a writable location, or adjust the directory
<data name="C_PickButtonText" xml:space="preserve">
<value>Go</value>
</data>
<data name="Menu_ExitSudoMode" xml:space="preserve">
<value>Exit Sudo Mode</value>
</data>
</root>
1 change: 1 addition & 0 deletions SecRandom/Langs/Common/Resources.ja-JP.resx
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,5 @@
<data name="C_PickButtonText" xml:space="preserve">
<value>抽</value>
</data>
<data name="Menu_ExitSudoMode" xml:space="preserve"><value>Sudo モードを終了</value></data>
</root>
3 changes: 3 additions & 0 deletions SecRandom/Langs/Common/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -268,4 +268,7 @@
<data name="C_PickButtonText" xml:space="preserve">
<value>抽</value>
</data>
<data name="Menu_ExitSudoMode" xml:space="preserve">
<value>退出 Sudo 模式</value>
</data>
</root>
5 changes: 5 additions & 0 deletions SecRandom/Langs/SettingsPages/Security/Resources.Security.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
5 changes: 5 additions & 0 deletions SecRandom/Langs/SettingsPages/Security/Resources.en-US.resx
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,9 @@
<data name="M_TotpSaveFailed" xml:space="preserve"><value>Invalid code. TOTP settings were not saved.</value></data>
<data name="M_UsbUpdated" xml:space="preserve"><value>USB binding updated.</value></data>
<data name="M_UsbUpdateFailed" xml:space="preserve"><value>Unable to update USB binding. Confirm that the drive is connected and writable.</value></data>
<data name="S_SudoModeDuration" xml:space="preserve"><value>Sudo Duration</value></data>
<data name="S_SudoModeDuration_D" xml:space="preserve"><value>How long sudo mode remains active after a successful verification; the settings page does not follow this duration and expires when closed</value></data>
<data name="S_SudoModeDurationUnit" xml:space="preserve"><value>seconds</value></data>
<data name="Menu_ExitSudoMode" xml:space="preserve"><value>Exit Sudo Mode</value></data>
<data name="M_SudoModeExited" xml:space="preserve"><value>Sudo mode deactivated</value></data>
</root>
7 changes: 6 additions & 1 deletion SecRandom/Langs/SettingsPages/Security/Resources.ja-JP.resx
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,10 @@
<data name="M_TotpSaved" xml:space="preserve"><value>TOTP 設定を保存しました。</value></data>
<data name="M_TotpSaveFailed" xml:space="preserve"><value>コードが無効です。TOTP 設定は保存されませんでした。</value></data>
<data name="M_UsbUpdated" xml:space="preserve"><value>USB 登録を更新しました。</value></data>
<data name="M_UsbUpdateFailed" xml:space="preserve"><value>USB 登録を更新できません。ドライブの接続と書き込み権限を確認してください。</value></data>
<data name="M_UsbUpdateFailed" xml:space="preserve"><value>USB 登録を更新できません。ドライブの接続と書き込み権限を確認してください。</value></data>
<data name="S_SudoModeDuration" xml:space="preserve"><value>Sudo 有効期間</value></data>
<data name="S_SudoModeDuration_D" xml:space="preserve"><value>認証後に Sudo モードが有効な期間。設定ページはこの設定に従わず、閉じると無効になります</value></data>
<data name="S_SudoModeDurationUnit" xml:space="preserve"><value>秒</value></data>
<data name="Menu_ExitSudoMode" xml:space="preserve"><value>Sudo モードを終了</value></data>
<data name="M_SudoModeExited" xml:space="preserve"><value>Sudo モードを無効にしました</value></data>
</root>
5 changes: 5 additions & 0 deletions SecRandom/Langs/SettingsPages/Security/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,9 @@
<data name="M_TotpCode" xml:space="preserve"><value>验证码</value></data>
<data name="C_Copy" xml:space="preserve"><value>复制</value></data>
<data name="M_Copied" xml:space="preserve"><value>已复制</value></data>
<data name="S_SudoModeDuration" xml:space="preserve"><value>Sudo 有效时长</value></data>
<data name="S_SudoModeDuration_D" xml:space="preserve"><value>验证通过后的免验证有效时长,设置页面不遵循此设置,关闭设置页面即失效</value></data>
<data name="S_SudoModeDurationUnit" xml:space="preserve"><value>秒</value></data>
<data name="Menu_ExitSudoMode" xml:space="preserve"><value>退出 Sudo 模式</value></data>
<data name="M_SudoModeExited" xml:space="preserve"><value>已退出 Sudo 模式</value></data>
</root>
6 changes: 6 additions & 0 deletions SecRandom/Services/Security/SecurityContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ Task<SecurityAuthorizationResult> AuthorizeSettingsAsync(
Task<bool> UnbindUsbAsync(string bindingId, CancellationToken cancellationToken = default);
Task<bool> 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);
Expand Down
85 changes: 85 additions & 0 deletions SecRandom/Services/Security/SecurityService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -128,6 +144,8 @@ public async Task<bool> AuthorizeAsync(
return false;
}

ActivateSudoMode();

await action();
return true;
}
Expand Down Expand Up @@ -195,6 +213,9 @@ public async Task<SecurityAuthorizationResult> AuthorizeSettingsAsync(
return new SecurityAuthorizationResult(false);
}

ActivateSudoMode();
_settingsSudoActive = true;

await action();
return new SecurityAuthorizationResult(true);
}
Expand All @@ -209,6 +230,18 @@ public Task<bool> 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)
Expand All @@ -218,6 +251,7 @@ public Task<bool> UpdateSecuritySettingsAsync(
configHandler.Save();
}

_settingsSudoActive = true;
return Task.FromResult(false);
}, cancellationToken);
}
Expand Down Expand Up @@ -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))
Expand Down
17 changes: 17 additions & 0 deletions SecRandom/Views/SettingsPages/General/SecuritySettingsPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@
<Separator Margin="0 12 0 4" />
<sr:IconText x:Name="S_Protection" Text="{x:Static lsp:Resources.S_Protection}" Glyph="{sr:Fi LockClosedFilled}" Margin="0 0 0 4" />

<fa:FASettingsExpander x:Name="S_SudoModeDuration"
Header="{x:Static lsp:Resources.S_SudoModeDuration}"
Description="{x:Static lsp:Resources.S_SudoModeDuration_D}"
IconSource="{sr:FluentIconSource {sr:Fi ClockFilled}}">
<fa:FASettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="8">
<NumericUpDown Minimum="10" Maximum="600" Increment="10"
Value="{Binding SudoModeDurationValue}"
FormatString="F0"
IsEnabled="{Binding CanEditProtectedOperations}"
ValueChanged="SudoModeDuration_OnValueChanged" />
<TextBlock Text="{x:Static lsp:Resources.S_SudoModeDurationUnit}"
VerticalAlignment="Center" />
</StackPanel>
</fa:FASettingsExpander.Footer>
</fa:FASettingsExpander>

<fa:FASettingsExpander x:Name="S_WindowOperations"
Header="{x:Static lsp:Resources.S_WindowOperations}"
Description="{x:Static lsp:Resources.S_WindowOperations_D}"
Expand Down
Loading
Loading