Skip to content
Open
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
19 changes: 19 additions & 0 deletions SecRandom.Core.Tests/SecurityServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
17 changes: 14 additions & 3 deletions SecRandom/Services/Security/SecurityCredentialStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ public SecurityCredentialMetadata LoadMetadata()
envelope.Nonce,
envelope.Tag,
envelope.Ciphertext,
isReadable: true);
isReadable: true,
totpSecret: envelope.TotpSecret);
}
catch (IOException)
{
Expand Down Expand Up @@ -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
};
}

Expand Down Expand Up @@ -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);
Expand All @@ -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)
Expand Down Expand Up @@ -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; }

/// <summary>
/// 明文 TOTP 密钥副本,仅在「任意已选验证方式」模式下由服务层写入,
/// 用于在不解锁加密负载的情况下校验 TOTP 验证码。全部验证模式下为 null,
/// 密钥仅存在于 AES-GCM 加密负载内。旧版凭据文件不含此字段,反序列化后为 null。
/// </summary>
public string? TotpSecret { get; init; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不解锁的情况下校验验证码本身就是一个不合理的行为。

}

internal sealed record SecurityCredentialAuthenticationData(
Expand Down
13 changes: 12 additions & 1 deletion SecRandom/Services/Security/SecurityService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,12 @@ public Task<bool> UpdateSecuritySettingsAsync(
{
lock (_gate)
{
var requireAllBefore = Settings.RequireAllSelectedFactors;
update();
NormalizeSettings(context.Credentials);
configHandler.Save();
if (requireAllBefore != Settings.RequireAllSelectedFactors)
TrySaveCredentials(context);
}

return Task.FromResult(false);
Expand Down Expand Up @@ -248,7 +251,10 @@ public Task<SecurityVerificationResult> 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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading