Skip to content

Commit 05dd60a

Browse files
committed
Fixed changing first-stage credentials in chained mode
1 parent bd63012 commit 05dd60a

2 files changed

Lines changed: 151 additions & 1 deletion

File tree

src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Credentials/CredentialsConfirmationViewModel.cs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Generic;
33
using System.ComponentModel;
44
using System.Linq;
5+
using System.Security.Cryptography;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using CommunityToolkit.Mvvm.ComponentModel;
@@ -18,6 +19,7 @@
1819
using SecureFolderFS.Shared.ComponentModel;
1920
using SecureFolderFS.Shared.Extensions;
2021
using SecureFolderFS.Shared.Models;
22+
using SecureFolderFS.Shared.SecureStore;
2123

2224
namespace SecureFolderFS.Sdk.ViewModels.Views.Credentials
2325
{
@@ -116,7 +118,10 @@ private async Task ChangeCredentialsAsync(IKeyUsage key, VaultOptions configured
116118
if (RequiresComplementationRoutine(configuredOptions.UnlockProcedure, unlockProcedure))
117119
await VaultManagerService.ModifyComplementationAsync(_vaultFolder, UnlockContract, CreateComplementationCredentials(key, configuredOptions.UnlockProcedure, unlockProcedure), updatedOptions, cancellationToken);
118120
else
119-
await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, UnlockContract, OldPasskey, key, updatedOptions, cancellationToken);
121+
{
122+
using var updatedPasskey = CreateUpdatedAuthenticationPasskey(key, unlockProcedure);
123+
await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, UnlockContract, OldPasskey, updatedPasskey, updatedOptions, cancellationToken);
124+
}
120125
}
121126
else
122127
await VaultManagerService.ModifyAuthenticationAsync(_vaultFolder, UnlockContract, key, updatedOptions, cancellationToken);
@@ -193,6 +198,64 @@ private static bool RequiresComplementationRoutine(AuthenticationMethod configur
193198
return complementationChanged || wasComplemented || willBeComplemented;
194199
}
195200

201+
private SecureKey CreateUpdatedAuthenticationPasskey(IKeyUsage providedCredential, AuthenticationMethod updatedProcedure)
202+
{
203+
ArgumentNullException.ThrowIfNull(OldPasskey);
204+
205+
var targetCredentials = new List<IKeyUsage>(updatedProcedure.Methods.Length);
206+
for (var i = 0; i < updatedProcedure.Methods.Length; i++)
207+
{
208+
var credential = GetNewCredentialForStage(providedCredential, i)
209+
?? GetCredentialAt(OldPasskey, i)
210+
?? throw new InvalidOperationException($"Credential material for authentication method '{updatedProcedure.Methods[i]}' is missing.");
211+
212+
targetCredentials.Add(credential);
213+
}
214+
215+
return CreateStandalonePasskey(targetCredentials);
216+
}
217+
218+
private IKeyUsage? GetNewCredentialForStage(IKeyUsage providedCredential, int targetIndex)
219+
{
220+
return _authenticationStage switch
221+
{
222+
AuthenticationStage.FirstStageOnly => targetIndex == 0 ? GetCredentialAt(providedCredential, 0) ?? providedCredential : null,
223+
AuthenticationStage.ProceedingStageOnly => targetIndex == 1 ? GetProceedingStageCredential(providedCredential) : null,
224+
_ => throw new ArgumentOutOfRangeException(nameof(_authenticationStage))
225+
};
226+
}
227+
228+
private static IKeyUsage? GetProceedingStageCredential(IKeyUsage providedCredential)
229+
{
230+
if (providedCredential is not KeySequence sequence)
231+
return providedCredential;
232+
233+
return sequence.Keys.ElementAtOrDefault(1) ?? (sequence.Count == 1 ? sequence.Keys.FirstOrDefault() : null);
234+
}
235+
236+
private static SecureKey CreateStandalonePasskey(IReadOnlyCollection<IKeyUsage> credentials)
237+
{
238+
var combinedKey = GC.AllocateArray<byte>(credentials.Sum(x => x.Length), pinned: true);
239+
240+
try
241+
{
242+
var offset = 0;
243+
foreach (var credential in credentials)
244+
{
245+
var capturedOffset = offset;
246+
credential.UseKey(span => span.CopyTo(combinedKey.AsSpan(capturedOffset, span.Length)));
247+
offset += credential.Length;
248+
}
249+
250+
return SecureKey.TakeOwnership(combinedKey);
251+
}
252+
catch
253+
{
254+
CryptographicOperations.ZeroMemory(combinedKey);
255+
throw;
256+
}
257+
}
258+
196259
private static IKeyUsage? GetCredentialAt(IKeyUsage key, int index)
197260
{
198261
return key is KeySequence sequence

tests/SecureFolderFS.Tests/VaultTests/CredentialTests.cs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
using SecureFolderFS.Core.VaultAccess;
66
using SecureFolderFS.Sdk.Enums;
77
using SecureFolderFS.Sdk.Services;
8+
using SecureFolderFS.Sdk.ViewModels.Controls;
89
using SecureFolderFS.Sdk.ViewModels.Controls.Authentication;
10+
using SecureFolderFS.Sdk.ViewModels.Views.Credentials;
911
using SecureFolderFS.Sdk.ViewModels.Views.Overlays;
1012
using SecureFolderFS.Shared;
1113
using SecureFolderFS.Shared.ComponentModel;
@@ -171,6 +173,91 @@ public async Task ModifyAuthentication_RemoveKeyFile_RequiresPasswordOnly()
171173
configuredOptions.UnlockProcedure.Should().BeEquivalentTo(passwordOnlyProcedure);
172174
}
173175

176+
[Test]
177+
public async Task CredentialsConfirmation_ChangeChainedPassword_PreservesKeyFile()
178+
{
179+
// Arrange
180+
var vaultFolder = CreateVaultFolder();
181+
var manager = DI.Service<IVaultManagerService>();
182+
var vaultService = DI.Service<IVaultService>();
183+
var vaultId = Guid.NewGuid().ToString("N");
184+
185+
var compositeProcedure = new AuthenticationMethod([AUTH_PASSWORD, AUTH_KEYFILE], null);
186+
using var initialCompositePasskey = await GetCreationCompositeCredentialAsync(vaultFolder, "Password#1", vaultId);
187+
using var _ = await manager.CreateAsync(vaultFolder, initialCompositePasskey, CreateOptions(compositeProcedure, vaultId));
188+
189+
using var unlockPasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "Password#1", vaultId);
190+
using var unlockContract = await manager.UnlockAsync(vaultFolder, unlockPasskey);
191+
using var registerViewModel = new RegisterViewModel(AuthenticationStage.FirstStageOnly);
192+
using var confirmationViewModel = new CredentialsConfirmationViewModel(vaultFolder, registerViewModel, AuthenticationStage.FirstStageOnly)
193+
{
194+
UnlockContract = unlockContract,
195+
OldPasskey = unlockPasskey,
196+
OldAuthenticationMethodIds = [AUTH_PASSWORD, AUTH_KEYFILE]
197+
};
198+
199+
registerViewModel.CurrentViewModel = CreatePasswordCreationViewModel("Password#2");
200+
201+
// Act
202+
await confirmationViewModel.ConfirmAsync(CancellationToken.None);
203+
204+
// Assert
205+
using var oldCompositePasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "Password#1", vaultId);
206+
using var updatedCompositePasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "Password#2", vaultId);
207+
using var updatedPasswordOnlyPasskey = await GetPasswordLoginCredentialAsync("Password#2");
208+
209+
(await CanUnlockAsync(manager, vaultFolder, oldCompositePasskey)).Should().BeFalse();
210+
(await CanUnlockAsync(manager, vaultFolder, updatedCompositePasskey)).Should().BeTrue();
211+
(await CanUnlockAsync(manager, vaultFolder, updatedPasswordOnlyPasskey)).Should().BeFalse();
212+
213+
var configuredOptions = await vaultService.GetVaultOptionsAsync(vaultFolder);
214+
configuredOptions.UnlockProcedure.Should().BeEquivalentTo(compositeProcedure);
215+
}
216+
217+
[Test]
218+
public async Task CredentialsConfirmation_ChangeChainedKeyFile_PreservesPassword()
219+
{
220+
// Arrange
221+
var vaultFolder = CreateVaultFolder();
222+
var manager = DI.Service<IVaultManagerService>();
223+
var vaultService = DI.Service<IVaultService>();
224+
var vaultId = Guid.NewGuid().ToString("N");
225+
226+
var compositeProcedure = new AuthenticationMethod([AUTH_PASSWORD, AUTH_KEYFILE], null);
227+
using var initialCompositePasskey = await GetCreationCompositeCredentialAsync(vaultFolder, "Password#1", vaultId);
228+
using var _ = await manager.CreateAsync(vaultFolder, initialCompositePasskey, CreateOptions(compositeProcedure, vaultId));
229+
230+
using var unlockPasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "Password#1", vaultId);
231+
using var oldKeyFile = unlockPasskey.Keys.ElementAt(1).CreateCopy();
232+
using var unlockContract = await manager.UnlockAsync(vaultFolder, unlockPasskey);
233+
using var registerViewModel = new RegisterViewModel(AuthenticationStage.ProceedingStageOnly);
234+
using var confirmationViewModel = new CredentialsConfirmationViewModel(vaultFolder, registerViewModel, AuthenticationStage.ProceedingStageOnly)
235+
{
236+
UnlockContract = unlockContract,
237+
OldPasskey = unlockPasskey,
238+
OldAuthenticationMethodIds = [AUTH_PASSWORD, AUTH_KEYFILE]
239+
};
240+
241+
var newKeyFile = await GetKeyFileCreationCredentialAsync(vaultId);
242+
registerViewModel.Credentials.Add(newKeyFile);
243+
registerViewModel.CurrentViewModel = new KeyFileCreationViewModel(vaultId);
244+
245+
// Act
246+
await confirmationViewModel.ConfirmAsync(CancellationToken.None);
247+
248+
// Assert
249+
using var updatedCompositePasskey = await GetLoginCompositeCredentialAsync(vaultFolder, "Password#1", vaultId);
250+
using var oldCompositePasskey = new KeySequence();
251+
oldCompositePasskey.Add(await GetPasswordLoginCredentialAsync("Password#1"));
252+
oldCompositePasskey.Add(oldKeyFile.CreateCopy());
253+
254+
(await CanUnlockAsync(manager, vaultFolder, updatedCompositePasskey)).Should().BeTrue();
255+
(await CanUnlockAsync(manager, vaultFolder, oldCompositePasskey)).Should().BeFalse();
256+
257+
var configuredOptions = await vaultService.GetVaultOptionsAsync(vaultFolder);
258+
configuredOptions.UnlockProcedure.Should().BeEquivalentTo(compositeProcedure);
259+
}
260+
174261
[Test]
175262
public async Task ModifyAuthentication_InvalidUnlockContract_ThrowsArgumentException()
176263
{

0 commit comments

Comments
 (0)