From 1077620873544144d06506341dee92db6669425d Mon Sep 17 00:00:00 2001 From: CreeperAWA Date: Sun, 13 Sep 2026 17:28:00 +0800 Subject: [PATCH] feat(security): add totp standalone auth and improve credential storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增「任意验证模式」下免密码使用TOTP授权的测试用例 2. 重构验证逻辑,支持仅通过TOTP完成身份验证 3. 新增TOTP密钥明文副本存储,适配免密校验场景 4. 调整凭据保存逻辑,当验证模式变更时同步更新凭据 --- SecRandom.Core.Tests/SecurityServiceTests.cs | 19 +++++++++++++++++++ .../Security/SecurityCredentialStore.cs | 17 ++++++++++++++--- .../Services/Security/SecurityService.cs | 13 ++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/SecRandom.Core.Tests/SecurityServiceTests.cs b/SecRandom.Core.Tests/SecurityServiceTests.cs index e2cfe66d..0e28dece 100644 --- a/SecRandom.Core.Tests/SecurityServiceTests.cs +++ b/SecRandom.Core.Tests/SecurityServiceTests.cs @@ -306,6 +306,25 @@ public async Task VerifyAsync_WhenAllSelectedFactorModeHasOnlyUsb_RejectsAuthori Assert.Equal(SecurityVerificationFailure.InvalidCredentials, result.Failure); } + [Fact] + public async Task VerifyAsync_WhenAnySelectedFactorModeHasOnlyTotp_AuthorizesWithoutPassword() + { + var fixture = CreateFixture(Password("secret1")); + await fixture.Service.SetPasswordAsync("secret1", cancellationToken: TestContext.Current.CancellationToken); + var secret = await fixture.Service.BeginTotpSetupAsync(null!, TestContext.Current.CancellationToken); + Assert.NotNull(secret); + Assert.True(await fixture.Service.ConfirmTotpAsync(secret, CreateTotpCode(secret), TestContext.Current.CancellationToken)); + fixture.ConfigHandler.Data.SecuritySettings.SecurityEnabled = true; + fixture.ConfigHandler.Data.SecuritySettings.TotpEnabled = true; + fixture.ConfigHandler.Data.SecuritySettings.RequireAllSelectedFactors = false; + + var result = await fixture.Service.VerifyAsync( + new SecurityVerificationResponse(string.Empty, CreateTotpCode(secret), UsbPresent: false), + TestContext.Current.CancellationToken); + + Assert.True(result.IsAuthorized); + } + [Fact] public void SecurityVerificationEligibility_RequiresAnyOrAllSelectedFactorInput() { diff --git a/SecRandom/Services/Security/SecurityCredentialStore.cs b/SecRandom/Services/Security/SecurityCredentialStore.cs index b4597f08..ef6b3dd2 100644 --- a/SecRandom/Services/Security/SecurityCredentialStore.cs +++ b/SecRandom/Services/Security/SecurityCredentialStore.cs @@ -66,7 +66,8 @@ public SecurityCredentialMetadata LoadMetadata() envelope.Nonce, envelope.Tag, envelope.Ciphertext, - isReadable: true); + isReadable: true, + totpSecret: envelope.TotpSecret); } catch (IOException) { @@ -290,7 +291,8 @@ private static SecurityCredentialEnvelope CreateEnvelope(SecurityCredentialMetad LockedUntilUtc = metadata.LockedUntilUtc, Nonce = metadata.Nonce!, Tag = metadata.Tag!, - Ciphertext = metadata.Ciphertext! + Ciphertext = metadata.Ciphertext!, + TotpSecret = metadata.TotpSecret }; } @@ -443,7 +445,8 @@ internal sealed class SecurityCredentialMetadata( string? nonce, string? tag, string? ciphertext, - bool isReadable) + bool isReadable, + string? totpSecret = null) { public static SecurityCredentialMetadata CreateEmpty() => new(null, false, [], 0, null, null, null, null, true); public static SecurityCredentialMetadata CreateInvalid() => new(null, false, [], 0, null, null, null, null, false); @@ -456,6 +459,7 @@ internal sealed class SecurityCredentialMetadata( public string? Nonce { get; set; } = nonce; public string? Tag { get; set; } = tag; public string? Ciphertext { get; set; } = ciphertext; + public string? TotpSecret { get; set; } = totpSecret; public bool IsReadable { get; } = isReadable; private static UsbBindingCredential CloneBinding(UsbBindingCredential binding) @@ -503,6 +507,13 @@ internal sealed class SecurityCredentialEnvelope public required string Nonce { get; init; } public required string Tag { get; init; } public required string Ciphertext { get; init; } + + /// + /// 明文 TOTP 密钥副本,仅在「任意已选验证方式」模式下由服务层写入, + /// 用于在不解锁加密负载的情况下校验 TOTP 验证码。全部验证模式下为 null, + /// 密钥仅存在于 AES-GCM 加密负载内。旧版凭据文件不含此字段,反序列化后为 null。 + /// + public string? TotpSecret { get; init; } } internal sealed record SecurityCredentialAuthenticationData( diff --git a/SecRandom/Services/Security/SecurityService.cs b/SecRandom/Services/Security/SecurityService.cs index e918cd90..9d45c54b 100644 --- a/SecRandom/Services/Security/SecurityService.cs +++ b/SecRandom/Services/Security/SecurityService.cs @@ -216,9 +216,12 @@ public Task UpdateSecuritySettingsAsync( { lock (_gate) { + var requireAllBefore = Settings.RequireAllSelectedFactors; update(); NormalizeSettings(context.Credentials); configHandler.Save(); + if (requireAllBefore != Settings.RequireAllSelectedFactors) + TrySaveCredentials(context); } return Task.FromResult(false); @@ -248,7 +251,10 @@ public Task VerifyAsync( var usbPassed = factors.Contains(SecurityFactor.Usb) && response.UsbPresent && metadata.UsbBindings.Any(IsBindingPresent); - if (!Settings.RequireAllSelectedFactors && usbPassed) + var totpStandalone = factors.Contains(SecurityFactor.Totp) && + metadata.TotpSecret is not null && + TotpService.Verify(metadata.TotpSecret, response.TotpCode, _timeProvider.GetUtcNow()); + if (!Settings.RequireAllSelectedFactors && (usbPassed || totpStandalone)) { metadata.FailedAttempts = 0; metadata.LockedUntilUtc = null; @@ -783,6 +789,11 @@ private bool TrySaveCredentials(SecurityCredentialContext context) { try { + // 「全部已选验证方式均需验证」模式下 TOTP 密钥仅存在于加密负载内; + // 「任意已选验证方式」模式下同时在信封写入明文副本,供免密校验 TOTP 使用。 + context.Metadata.TotpSecret = Settings.RequireAllSelectedFactors + ? null + : context.Credentials.TotpSecret; credentialStore.Save(context); return true; }