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;
}