Skip to content

Commit 614eade

Browse files
Disable only-TOTP path
Disable standalone TOTP verification by default Standalone TOTP verification now requires either a Debug build or the explicit `--allow-standalone-totp` startup argument. This change removes support for plaintext TOTP seed storage (`totp-standalone.json`) in production builds. - Added `AllowStandaloneTotpVerification` in `GlobalConstants` with methods to enable it explicitly. - Modified `Program.cs` to parse and apply the opt-in argument. - Updated `SecurityCredentialStore.cs` to prevent reading plaintext seeds unless explicitly allowed. - Adjusted `SecurityService.cs` to enforce the new policy: - TOTP-only attempts are blocked unless explicitly enabled. - Legacy plaintext seeds are deleted on the next save. - Introduced `TestStandaloneTotpOptIn.cs` to ensure tests enable the TOTP-only path for coverage. Behavioral changes: - Default: TOTP cannot pass verification alone without a password or other factors. - Legacy plaintext seeds are ignored and removed on save. Security improvements: - Eliminates reliance on plaintext TOTP seeds in production. - Reduces attack surface by enforcing stricter verification policies. Pending: - Add a head-level toggle for mobile platforms to enable standalone TOTP debugging.
1 parent b008b15 commit 614eade

7 files changed

Lines changed: 90 additions & 2 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ Keep this map short and stable. When code moves, AI agents should re-read the mo
164164
- First-run OOBE is a top-level window shown immediately after `BuildHost()` and before floating-window/main-app initialization. Its carousel begins with a non-blocking, centered welcome screen with the application icon; it is outside the seven configuration pages, whose first item places the collapsed-by-default verifiable-draw notice before the privacy-policy and GPLv3 acknowledgement controls. Each requires explicit acknowledgement. The welcome page owns the lower-left language selector; changing it persists the language, refreshes ViewModel-derived localized values, then recreates only the OOBE visual tree into localized resources without restarting or leaving the first-run flow. An active import drawer defers the visual replacement until its preview/mapping flow closes. `GuideCompleted`, `AcceptedVerificationNoticeVersion`, `AcceptedPrivacyPolicyVersion`, and `AcceptedGplVersion` determine whether an OOBE acknowledgement is required. The fork's `AcceptedSecRandomServicesVersion` is versioned but deliberately excluded from that gate: its acknowledgement is optional during setup and is enforced later at the point of use by `SecRandomServicesConsent`. After the full guide has completed, a verifiable-draw notice, privacy-policy, or GPL version update opens only the acknowledgement page and its privacy controls, not unrelated setup pages. OOBE class/prize spreadsheet imports reuse the list-management import controls in its own right-side drawer, so their file mapping, preview, duplicate handling, and cancel behavior stay aligned with the settings list pages. Completing OOBE continues into normal startup and explicitly opens the primary main window; it must not exit merely because the OOBE window closes. Verifiable-draw notice, privacy-policy, and GPL changes increment their app-layer OOBE versions independently.
165165
- Settings JSON, full-data ZIP, and backup restore only accept manifest/envelope exports produced by SecRandom v3. Validate `producer_version` before taking an import snapshot or changing data; unsupported sources must be explained in a modal dialog. `producer_version` is the raw `GlobalConstants.Version`, so it keeps a SemVer pre-release/build suffix (`v3.0.0-alpha.2`); the gate must strip that suffix and compare only the numeric core's major, never feed the raw value to `Version.TryParse` — otherwise a pre-release build rejects the archives it produced itself.
166166
- 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. `SecurityCredentialStore` restricts the whole `data/config/security` directory to the current user once per process and on every save (Unix `0700` directory with `0600` files and temporary files; Windows breaks ACL inheritance and keeps only the current user plus SYSTEM and Administrators), because that directory also holds the readable `totp-standalone.json` copy; the hardening is best-effort and must never leave the directory unreadable to the app itself. Lockout state deliberately stays outside the AES-GCM authenticated data — a failed attempt happens without the encryption key, so `FailedAttempts`/`LockedUntilUtc` must remain writable through `SaveMetadata`, which also means anyone who can rewrite the credential file can reset the lockout: the lockout only restrains callers that cannot write that file.
167+
- **Fork policy: TOTP-only verification is disabled.** A TOTP code can only be checked without the password by keeping a plaintext seed copy, so `GlobalConstants.AllowStandaloneTotpVerification` is `false` outside a debug build and outside the explicit `--allow-standalone-totp` startup switch (wired in `SecRandom.Desktop/Program.Main`). When it is off, `SecurityCredentialStore.LoadStandaloneTotp` returns null unconditionally, `SecurityService` never counts `totpPassed` on its own, and the next credential save deletes any leftover `totp-standalone.json`. USB-only and password-augmented verification are unaffected, and the tests opt in through `SecRandom.Core.Tests/TestStandaloneTotpOptIn.cs` because CI runs Release.
167168
- While `SecuritySettingsConfig.RequireAllSelectedFactors` is off ("any selected method" mode) every selected factor verifies on its own, and TOTP cannot be checked without its seed, so that mode keeps a password-free seed copy in the sibling `data/config/security/totp-standalone.json` (written only while the mode is off and TOTP is configured; deleted when the mode turns on or the password is removed). The directory stays excluded from every export, cloud backup, and the Android provider, and the settings description discloses the trade-off. Never move that copy into `credentials.json`: its AES-GCM authentication data covers only `FormatVersion`, the password credential, `HasTotp`, and USB bindings, so a readable seed field there would be read before any authentication and would silently bypass the existing tamper detection.
168169
- `SecuritySettingsConfig.SettingsIntegrityCheckEnabled` (desktop-only, password-gated) records a SHA-256 fingerprint of the whole `data/config/settings.json` in the credential envelope's plaintext `SettingsIntegrity` record on every `MainConfigHandler.Save()`/`Reload()`, and `App` verifies it before desktop startup. A mismatch routes through `SettingsIntegrityAction`: `Confirm` (the default) keeps the confirmation gate that requires the security password before the app continues, otherwise it exits; `AutoRestore` hands startup to `SettingsIntegrityRecoveryService`, which walks the newest `data/backup/*.zip` and/or account cloud backups per the separately configured `SettingsIntegrityRestoreSource` (`Local` / `LocalThenCloud` / `CloudThenLocal`), takes `config/settings.json` from the first supported v3 archive that actually carries a loadable settings payload, copies the current file to `data/backup/integrity/settings_before_restore_*.json`, atomically replaces it, reloads the handler (which refreshes the fingerprint), and restarts the app; only the settings file is touched, and a failed recovery keeps the original file and falls back to the password gate with the failure reason. The fingerprint needs existing security credentials as its anchor, is cleared when protection is turned off, and — because it lives outside the AES-GCM authenticated data — detects accidental or casual edits rather than a determined local attacker who can rewrite both files.
169170
- 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.

‎DownStream_Change.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
| 7 | TSA 时间戳改为可显式关闭 + 沃通隐私提示 | 验证设置页、时间戳客户端、OOBE 隐私政策提示 | 中高(改了验证链路的网络行为开关) |
2929
| 8 | 新增「自有参考链(Own)」补充声明 | 新增 `*.ownproof.json`、自有链头、完整性报告字段 | **高**(新增落盘格式) |
3030
| 9 | 跨境数据传输闸门 + 抽取上传改为无默认二选一 | 新增枚举/配置、OOBE 与设置页、6 类 SECTL 出网路径、`SectlAuthService`/`SectlHeartbeatService`/`PlatformVersionReportService` 构造函数 | **高**(改动了所有出境路径的开关语义,并与上游的令牌轮换重写叠加) |
31+
| 10 | 禁用「仅 TOTP」单一验证 | `GlobalConstants` 调试开关、`SecurityService`/`SecurityCredentialStore`、桌面启动参数 | 中(改了安全验证的判定条件) |
3132

3233
---
3334

@@ -365,6 +366,33 @@
365366
1. `SectlAuthService.InitializeAsync` 中已登录会话的后台令牌刷新可能绕过 `SendAuthorizedAsync`,尚未收口到出境闸门。
366367
2. 未同意跨境时,`S_AttestationUpload` 单选组会置灰(`RefreshAttestationUpload` 读 `SectlTrafficPolicy`),但**不会**主动引导用户去隐私设置页补签;补签后需要重新进入该页才会解锁。
367368

369+
---
370+
371+
## 13. 禁用「仅 TOTP」单一验证(可调试开关)
372+
373+
**意图**:TOTP 验证码要脱离主密码校验,就只能保留明文种子副本 `data/config/security/totp-standalone.json`(见 §4 与 AGENTS 的安全条目)。本分支不再接受这份可读种子:**仅 TOTP 的单一验证被彻底禁用**,只有调试构建或显式启动参数才允许启用。USB 单独验证、以及「密码 + 其他因素」的组合不受影响。
374+
375+
**新增文件**:
376+
377+
| 文件 | 说明 |
378+
|------|------|
379+
| `SecRandom.Core.Tests/TestStandaloneTotpOptIn.cs` | `[ModuleInitializer]` 调用 `GlobalConstants.EnableStandaloneTotpVerification()`;安全测试本来就是在验证「已启用」路径,而 CI 跑 Release,必须显式开启 |
380+
381+
**修改文件**:
382+
383+
| 文件 | 改动 |
384+
|------|------|
385+
| `SecRandom.Core/GlobalConstants.cs` | 新增 `AllowStandaloneTotpVerification`(`= IsDevelopment`,即仅 Debug 构建默认可用于 Windows 开发机)、`EnableStandaloneTotpVerification()`、`EnableStandaloneTotpVerificationIfRequested(args)`(识别 `--allow-standalone-totp`) |
386+
| `SecRandom.Desktop/Program.cs` | 启动时调用 `GlobalConstants.EnableStandaloneTotpVerificationIfRequested(args)`(与 `PluginManager.SetStartupArguments` 并列) |
387+
| `SecRandom/Services/Security/SecurityCredentialStore.cs` | `LoadStandaloneTotp()` 在未允许时**直接返回 `null`**,遗留副本不再被读取 |
388+
| `SecRandom/Services/Security/SecurityService.cs` | `totpPassed` 增加 `AllowStandaloneTotpVerification` 前置条件;凭据保存时未允许则改为 `DeleteStandaloneTotp()`,从而在下一次保存时清除遗留明文文件 |
389+
390+
**行为**:
391+
- 未允许时,"任意已选方式"模式下 TOTP **不能单独通过**;此时不带密码的尝试会走到既有的「免密尝试」分支并计入失败次数(保留对验证码暴破的限制)——该分支无需改动。
392+
- 已有安装升级后,遗留的 `totp-standalone.json` 在下次保存凭据时被删除;在此之前也不会被读取。
393+
394+
**待办**:移动端 head 没有命令行入口,目前只能靠 Debug 构建开启;若需要在移动端也能调试,需要再加一个 head 级开关。
395+
368396
**界面(已落地)**:
369397
- OOBE 轮播新增隐私页之后的独立第 3 页「跨境数据传输须知」:`StepCount` 由 8 改为 9,`IsCrossBorderStep => SelectedStep == 2`,页序为 欢迎(0) → 法务/隐私(1) → **跨境须知(2)** → 数据导入(3) → … → 完成(8)。
370398
- 该页含:须知正文(含 SECTL 节点属地/IP)、**加粗**的可选说明、非必选勾选(`AcceptedCrossBorderTransfer`),以及**无默认**的抽取上传单选对(`AttestationUploadEnabledChoice` / `AttestationUploadDisabledChoice`,两个 `RadioButton` 同组,未选择时两个都未选中;未勾选跨境时单选对置灰)。
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using System.Runtime.CompilerServices;
2+
using SecRandom.Core;
3+
4+
namespace SecRandom.Core.Tests;
5+
6+
/// <summary>
7+
/// The security tests deliberately exercise the TOTP-only verification path (the one that reads the
8+
/// readable <c>totp-standalone.json</c> seed copy). That path ships disabled outside a debug build, and
9+
/// CI runs Release, so the test process has to apply the same opt-in the desktop
10+
/// <c>--allow-standalone-totp</c> switch does. It only widens production behavior inside tests.
11+
/// </summary>
12+
internal static class TestStandaloneTotpOptIn
13+
{
14+
[ModuleInitializer]
15+
internal static void OptIn() => GlobalConstants.EnableStandaloneTotpVerification();
16+
}

‎SecRandom.Core/GlobalConstants.cs‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,39 @@ public static void SetVersionAssembly(Assembly assembly)
8484
public static bool IsDevelopment => false;
8585
#endif
8686

87+
// Fork policy: a TOTP code can only be checked without the user's password by keeping a plaintext
88+
// seed copy in `data/config/security/totp-standalone.json`. That readable seed is the one credential
89+
// this fork refuses to keep, so TOTP-only verification is off unless the build is a debug build or the
90+
// process was started with the explicit opt-in below. USB-only and password-augmented verification are
91+
// unaffected.
92+
private static bool _allowStandaloneTotpVerification = IsDevelopment;
93+
94+
public static bool AllowStandaloneTotpVerification => _allowStandaloneTotpVerification;
95+
96+
/// <summary>Startup opt-in used by <c>--allow-standalone-totp</c>.</summary>
97+
public static void EnableStandaloneTotpVerification() => _allowStandaloneTotpVerification = true;
98+
99+
/// <summary>
100+
/// Enables TOTP-only verification when the process was started with <c>--allow-standalone-totp</c>.
101+
/// Debug builds already allow it, and this must only ever widen, never narrow, that.
102+
/// </summary>
103+
public static bool EnableStandaloneTotpVerificationIfRequested(IEnumerable<string>? arguments)
104+
{
105+
if (arguments is null)
106+
return false;
107+
108+
foreach (var argument in arguments)
109+
{
110+
if (!string.Equals(argument, "--allow-standalone-totp", StringComparison.OrdinalIgnoreCase))
111+
continue;
112+
113+
EnableStandaloneTotpVerification();
114+
return true;
115+
}
116+
117+
return false;
118+
}
119+
87120
public static FontFamily FluentIconsFontFamily { get; } =
88121
new(@"avares://SecRandom/Assets/Fonts/#FluentSystemIcons-Resizable");
89122

‎SecRandom.Desktop/Program.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public static void Main(string[] args)
4646
ProtocolActivation.SetStartupArguments(args);
4747
CrashRecoveryRuntime.SetStartupArguments(args);
4848
PluginManager.SetStartupArguments(args);
49+
// Fork opt-in for TOTP-only verification; debug builds already allow it (GlobalConstants).
50+
GlobalConstants.EnableStandaloneTotpVerificationIfRequested(args);
4951
AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException;
5052

5153
try

‎SecRandom/Services/Security/SecurityCredentialStore.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@ public void Delete()
236236
/// </summary>
237237
public string? LoadStandaloneTotp()
238238
{
239+
// The plaintext seed copy is only ever honoured when TOTP-only verification is allowed; outside a
240+
// debug build or an explicit startup opt-in this returns null so a leftover file cannot be used.
241+
if (!GlobalConstants.AllowStandaloneTotpVerification)
242+
return null;
243+
239244
try
240245
{
241246
var path = StandaloneTotpPath;

‎SecRandom/Services/Security/SecurityService.cs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,8 @@ public Task<SecurityVerificationResult> VerifyAsync(
290290
metadata.UsbBindings.Any(IsBindingPresent);
291291
// 「任意已选验证方式」模式下 TOTP 也能单独通过:该模式会用凭据文件旁的
292292
// 免密副本校验验证码,因此不需要先解开信封
293-
var totpPassed = factors.Contains(SecurityFactor.Totp) &&
293+
var totpPassed = GlobalConstants.AllowStandaloneTotpVerification &&
294+
factors.Contains(SecurityFactor.Totp) &&
294295
credentialStore.LoadStandaloneTotp() is { } standaloneTotp &&
295296
TotpService.Verify(standaloneTotp, response.TotpCode, _timeProvider.GetUtcNow());
296297
if (!Settings.RequireAllSelectedFactors && (usbPassed || totpPassed))
@@ -882,7 +883,9 @@ private bool TrySaveCredentials(SecurityCredentialContext context)
882883
// 信封先落盘,再同步免密 TOTP 副本,避免出现「新种子 / 旧信封」的错配
883884
try
884885
{
885-
if (Settings.RequireAllSelectedFactors || string.IsNullOrWhiteSpace(context.Credentials.TotpSecret))
886+
if (!GlobalConstants.AllowStandaloneTotpVerification
887+
|| Settings.RequireAllSelectedFactors
888+
|| string.IsNullOrWhiteSpace(context.Credentials.TotpSecret))
886889
credentialStore.DeleteStandaloneTotp();
887890
else
888891
credentialStore.SaveStandaloneTotp(context.Credentials.TotpSecret);

0 commit comments

Comments
 (0)