Skip to content

Commit 0c21e4f

Browse files
authored
Enable credential complementation (#124)
1 parent 2105964 commit 0c21e4f

38 files changed

Lines changed: 1767 additions & 134 deletions

File tree

‎src/Core/SecureFolderFS.Core/Constants.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public static class Names
1212
public const string VAULT_CONTENT_FOLDERNAME = "content";
1313
public const string VAULT_KEYSTORE_FILENAME = $"keystore{CONFIGURATION_EXTENSION}";
1414
public const string VAULT_CONFIGURATION_FILENAME = $"sfconfig{CONFIGURATION_EXTENSION}";
15+
public const string VAULT_COMPLEMENTATION_FILENAME = $"sfcomplement{CONFIGURATION_EXTENSION}";
1516
}
1617

1718
public static class Authentication
@@ -45,7 +46,6 @@ public static class Associations
4546
public const string ASSOC_FILENAME_CIPHER_ID = "filenameCipherScheme";
4647
public const string ASSOC_FILENAME_ENCODING_ID = "filenameEncoding";
4748
public const string ASSOC_RECYCLE_SIZE = "recycleBinSize";
48-
public const string ASSOC_SPECIALIZATION = "spec";
4949
public const string ASSOC_AUTHENTICATION = "authMode";
5050
public const string ASSOC_VAULT_ID = "vaultId";
5151
public const string ASSOC_APP_PLATFORM = "appPlatform";
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text.Json.Serialization;
4+
5+
namespace SecureFolderFS.Core.DataModels
6+
{
7+
[Serializable]
8+
public sealed record class VaultSharesDataModel
9+
{
10+
[JsonPropertyName("shares")]
11+
public List<VaultShareDataModel>? Shares { get; init; }
12+
}
13+
14+
[Serializable]
15+
public sealed record class VaultShareDataModel
16+
{
17+
[JsonPropertyName("authOne")]
18+
public string? AuthenticationMethodId { get; init; }
19+
20+
[JsonPropertyName("nonce")]
21+
public byte[]? Nonce { get; init; }
22+
23+
[JsonPropertyName("c_complement")]
24+
public byte[]? WrappedComplementSecret { get; init; }
25+
26+
[JsonPropertyName("tag")]
27+
public byte[]? Tag { get; init; }
28+
}
29+
}

‎src/Core/SecureFolderFS.Core/Routines/Operational/ModifyComplementationRoutine.cs‎

Lines changed: 416 additions & 0 deletions
Large diffs are not rendered by default.

‎src/Core/SecureFolderFS.Core/Routines/Operational/UnlockRoutine.cs‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Linq;
3+
using System.Security.Cryptography;
24
using System.Threading;
35
using System.Threading.Tasks;
46
using SecureFolderFS.Core.Cryptography;
@@ -7,6 +9,7 @@
79
using SecureFolderFS.Core.Validators;
810
using SecureFolderFS.Core.VaultAccess;
911
using SecureFolderFS.Shared.ComponentModel;
12+
using SecureFolderFS.Shared.Models;
1013
using SecureFolderFS.Shared.SecureStore;
1114

1215
namespace SecureFolderFS.Core.Routines.Operational
@@ -17,6 +20,7 @@ internal sealed class UnlockRoutine : ICredentialsRoutine
1720
private readonly VaultReader _vaultReader;
1821
private V4VaultKeystoreDataModel? _keystoreDataModel;
1922
private V4VaultConfigurationDataModel? _configDataModel;
23+
private VaultSharesDataModel? _sharesDataModel;
2024
private SecureKey? _dekKey;
2125
private SecureKey? _macKey;
2226

@@ -30,6 +34,7 @@ public async Task InitAsync(CancellationToken cancellationToken)
3034
{
3135
_configDataModel = await _vaultReader.ReadV4ConfigurationAsync(cancellationToken);
3236
_keystoreDataModel = await _vaultReader.ReadKeystoreAsync<V4VaultKeystoreDataModel>(cancellationToken);
37+
_sharesDataModel = await _vaultReader.ReadComplementationAsync(cancellationToken);
3338
}
3439

3540
/// <inheritdoc/>
@@ -38,11 +43,75 @@ public void SetCredentials(IKeyUsage passkey)
3843
ArgumentNullException.ThrowIfNull(_configDataModel);
3944
ArgumentNullException.ThrowIfNull(_keystoreDataModel);
4045

41-
var derived = passkey.UseKey(key => VaultParser.V4DeriveKeystore(key, _keystoreDataModel));
46+
var authenticationMethod = AuthenticationMethod.FromString(_configDataModel.AuthenticationMethod);
47+
var derived = string.IsNullOrWhiteSpace(authenticationMethod.Complementation)
48+
? passkey.UseKey(key => VaultParser.V4DeriveKeystore(key, _keystoreDataModel))
49+
: DeriveComplementedKeystore(passkey, authenticationMethod);
50+
4251
_dekKey = SecureKey.TakeOwnership(derived.dekKey);
4352
_macKey = SecureKey.TakeOwnership(derived.macKey);
4453
}
4554

55+
private (byte[] dekKey, byte[] macKey) DeriveComplementedKeystore(IKeyUsage passkey, AuthenticationMethod authenticationMethod)
56+
{
57+
ArgumentNullException.ThrowIfNull(_configDataModel);
58+
ArgumentNullException.ThrowIfNull(_keystoreDataModel);
59+
60+
Exception? lastException = null;
61+
var primaryMethodId = authenticationMethod.Methods.FirstOrDefault() ?? throw new InvalidOperationException("Primary authentication is missing.");
62+
63+
try
64+
{
65+
return passkey.UseKey(key =>
66+
{
67+
Span<byte> complementSecret = stackalloc byte[32];
68+
try
69+
{
70+
VaultParser.V4DeriveComplementKey(key, _configDataModel.Uid, primaryMethodId, complementSecret);
71+
return VaultParser.V4DeriveKeystore(complementSecret, _keystoreDataModel);
72+
}
73+
finally
74+
{
75+
CryptographicOperations.ZeroMemory(complementSecret);
76+
}
77+
});
78+
}
79+
catch (CryptographicException ex)
80+
{
81+
lastException = ex;
82+
}
83+
84+
foreach (var share in _sharesDataModel?.Shares ?? [])
85+
{
86+
if (share.WrappedComplementSecret is null
87+
|| share.Tag is null
88+
|| share.Nonce is null
89+
|| share.AuthenticationMethodId is null)
90+
continue;
91+
92+
if (!string.Equals(share.AuthenticationMethodId, authenticationMethod.Complementation, StringComparison.Ordinal))
93+
continue;
94+
95+
byte[]? complementSecret = null;
96+
try
97+
{
98+
complementSecret = passkey.UseKey(key => VaultParser.V4UnwrapComplementSecret(key, _configDataModel.Uid, share));
99+
return VaultParser.V4DeriveKeystore(complementSecret, _keystoreDataModel);
100+
}
101+
catch (CryptographicException ex)
102+
{
103+
lastException = ex;
104+
}
105+
finally
106+
{
107+
if (complementSecret is not null)
108+
CryptographicOperations.ZeroMemory(complementSecret);
109+
}
110+
}
111+
112+
throw lastException ?? new CryptographicException("The complemented credentials could not unlock this vault.");
113+
}
114+
46115
/// <inheritdoc/>
47116
public async Task<IDisposable> FinalizeAsync(CancellationToken cancellationToken)
48117
{

‎src/Core/SecureFolderFS.Core/Routines/Operational/VaultRoutines.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ public IModifyCredentialsRoutine ModifyCredentials()
5757
return new ModifyCredentialsRoutine(VaultReader, VaultWriter);
5858
}
5959

60+
public ModifyComplementationRoutine ModifyComplementation()
61+
{
62+
CheckVaultValidation();
63+
return new ModifyComplementationRoutine(VaultReader, VaultWriter);
64+
}
65+
6066
private void CheckVaultValidation()
6167
{
6268
if (!_validationResult.Successful)

‎src/Core/SecureFolderFS.Core/VaultAccess/VaultParser.cs‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,86 @@ public static void V4DecryptSoftwareEntropy(
265265
softwareEntropy);
266266
}
267267

268+
public static void V4DeriveComplementKey(
269+
ReadOnlySpan<byte> passkey,
270+
string vaultId,
271+
string authenticationMethodId,
272+
Span<byte> complementKey)
273+
{
274+
ArgumentException.ThrowIfNullOrWhiteSpace(vaultId);
275+
ArgumentException.ThrowIfNullOrWhiteSpace(authenticationMethodId);
276+
277+
var salt = Encoding.UTF8.GetBytes(vaultId);
278+
var info = Encoding.UTF8.GetBytes(authenticationMethodId);
279+
280+
HKDF.DeriveKey(
281+
HashAlgorithmName.SHA256,
282+
passkey,
283+
complementKey,
284+
salt,
285+
info);
286+
}
287+
288+
public static VaultShareDataModel V4WrapComplementSecret(
289+
ReadOnlySpan<byte> complementSecret,
290+
ReadOnlySpan<byte> wrappingKeyMaterial,
291+
string vaultId,
292+
string authenticationMethodId)
293+
{
294+
Span<byte> complementWrapKey = stackalloc byte[32];
295+
try
296+
{
297+
V4DeriveComplementKey(wrappingKeyMaterial, vaultId, authenticationMethodId, complementWrapKey);
298+
299+
var nonce = new byte[12];
300+
var tag = new byte[16];
301+
var wrapped = new byte[complementSecret.Length];
302+
RandomNumberGenerator.Fill(nonce);
303+
304+
using (var aes = new AesGcm(complementWrapKey, 16))
305+
aes.Encrypt(nonce, complementSecret, wrapped, tag);
306+
307+
return new()
308+
{
309+
AuthenticationMethodId = authenticationMethodId,
310+
Nonce = nonce,
311+
WrappedComplementSecret = wrapped,
312+
Tag = tag
313+
};
314+
}
315+
finally
316+
{
317+
CryptographicOperations.ZeroMemory(complementWrapKey);
318+
}
319+
}
320+
321+
public static byte[] V4UnwrapComplementSecret(
322+
ReadOnlySpan<byte> wrappingKeyMaterial,
323+
string vaultId,
324+
VaultShareDataModel shareDataModel)
325+
{
326+
ArgumentNullException.ThrowIfNull(shareDataModel.AuthenticationMethodId);
327+
ArgumentNullException.ThrowIfNull(shareDataModel.Nonce);
328+
ArgumentNullException.ThrowIfNull(shareDataModel.WrappedComplementSecret);
329+
ArgumentNullException.ThrowIfNull(shareDataModel.Tag);
330+
331+
Span<byte> complementWrapKey = stackalloc byte[32];
332+
try
333+
{
334+
V4DeriveComplementKey(wrappingKeyMaterial, vaultId, shareDataModel.AuthenticationMethodId, complementWrapKey);
335+
336+
var complementSecret = new byte[shareDataModel.WrappedComplementSecret.Length];
337+
using var aes = new AesGcm(complementWrapKey, 16);
338+
aes.Decrypt(shareDataModel.Nonce, shareDataModel.WrappedComplementSecret, shareDataModel.Tag, complementSecret);
339+
340+
return complementSecret;
341+
}
342+
finally
343+
{
344+
CryptographicOperations.ZeroMemory(complementWrapKey);
345+
}
346+
}
347+
268348
/// <summary>
269349
/// Shared implementation for both <see cref="V4EncryptKeystore"/> and <see cref="V4ReEncryptKeystore"/>.
270350
/// Encrypts the provided entropy under the passkey and wraps DEK/MAC under the augmented KEK.

‎src/Core/SecureFolderFS.Core/VaultAccess/VaultReader.cs‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,35 +30,36 @@ public async Task<TKeystore> ReadKeystoreAsync<TKeystore>(CancellationToken canc
3030
where TKeystore : class
3131
{
3232
// Get the keystore file
33-
if (await _vaultFolder.GetFirstByNameAsync(Constants.Vault.Names.VAULT_KEYSTORE_FILENAME, cancellationToken) is not IFile keystoreFile)
34-
throw new FileNotFoundException("The keystore file was not found.");
35-
33+
var keystoreFile = await _vaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_KEYSTORE_FILENAME, cancellationToken);
3634
return await ReadDataAsync<TKeystore>(keystoreFile, _serializer, cancellationToken);
3735
}
3836

3937
public async Task<VaultConfigurationDataModel> ReadConfigurationAsync(CancellationToken cancellationToken)
4038
{
4139
// Get configuration file
42-
if (await _vaultFolder.GetFirstByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken) is not IFile configFile)
43-
throw new FileNotFoundException("The configuration file was not found.");
44-
40+
var configFile = await _vaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken);
4541
return await ReadDataAsync<VaultConfigurationDataModel>(configFile, _serializer, cancellationToken);
4642
}
4743

4844
public async Task<V4VaultConfigurationDataModel> ReadV4ConfigurationAsync(CancellationToken cancellationToken)
4945
{
50-
if (await _vaultFolder.GetFirstByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken) is not IFile configFile)
51-
throw new FileNotFoundException("The configuration file was not found.");
52-
46+
var configFile = await _vaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken);
5347
return await ReadDataAsync<V4VaultConfigurationDataModel>(configFile, _serializer, cancellationToken);
5448
}
5549

50+
public async Task<VaultSharesDataModel?> ReadComplementationAsync(CancellationToken cancellationToken)
51+
{
52+
var complementFile = await _vaultFolder.TryGetFileByNameAsync(Constants.Vault.Names.VAULT_COMPLEMENTATION_FILENAME, cancellationToken);
53+
if (complementFile is null)
54+
return null;
55+
56+
return await ReadDataAsync<VaultSharesDataModel?>(complementFile, _serializer, cancellationToken);
57+
}
58+
5659
public async Task<VersionDataModel> ReadVersionAsync(CancellationToken cancellationToken)
5760
{
5861
// Get configuration file
59-
if (await _vaultFolder.GetFirstByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken) is not IFile configFile)
60-
throw new FileNotFoundException("The configuration file was not found.");
61-
62+
var configFile = await _vaultFolder.GetFileByNameAsync(Constants.Vault.Names.VAULT_CONFIGURATION_FILENAME, cancellationToken);
6263
return await ReadDataAsync<VersionDataModel>(configFile, _serializer, cancellationToken);
6364
}
6465

‎src/Core/SecureFolderFS.Core/VaultAccess/VaultWriter.cs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using SecureFolderFS.Core.DataModels;
33
using SecureFolderFS.Shared.ComponentModel;
44
using SecureFolderFS.Shared.Extensions;
5+
using SecureFolderFS.Storage.Extensions;
56
using System.IO;
67
using System.Threading;
78
using System.Threading.Tasks;
@@ -57,6 +58,29 @@ public async Task WriteV4ConfigurationAsync(V4VaultConfigurationDataModel? confi
5758
await WriteDataAsync(configFile, configDataModel, cancellationToken);
5859
}
5960

61+
public async Task WriteComplementationAsync(VaultSharesDataModel? sharesDataModel, CancellationToken cancellationToken)
62+
{
63+
if (sharesDataModel is null)
64+
{
65+
if (_vaultFolder is not IModifiableFolder modifiableFolder)
66+
return;
67+
68+
var existingFile = await modifiableFolder.TryGetFileByNameAsync(Constants.Vault.Names.VAULT_COMPLEMENTATION_FILENAME, cancellationToken);
69+
if (existingFile is not null)
70+
await modifiableFolder.DeleteAsync(existingFile, cancellationToken);
71+
72+
return;
73+
}
74+
75+
var complementFile = _vaultFolder switch
76+
{
77+
IModifiableFolder modifiableFolder => await modifiableFolder.CreateFileAsync(Constants.Vault.Names.VAULT_COMPLEMENTATION_FILENAME, true, cancellationToken),
78+
_ => await _vaultFolder.GetFirstByNameAsync(Constants.Vault.Names.VAULT_COMPLEMENTATION_FILENAME, cancellationToken) as IFile
79+
};
80+
81+
await WriteDataAsync(complementFile, sharesDataModel, cancellationToken);
82+
}
83+
6084
public async Task WriteAuthenticationAsync<TCapability>(string fileName, TCapability? authDataModel, CancellationToken cancellationToken)
6185
where TCapability : VaultCapabilityDataModel
6286
{

‎src/Platforms/SecureFolderFS.Maui/Platforms/Android/ServiceImplementation/AndroidVaultCredentialsService.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ protected override async IAsyncEnumerable<AuthenticationViewModel> GetLoginAsync
3636
string vaultId,
3737
[EnumeratorCancellation] CancellationToken cancellationToken = default)
3838
{
39-
foreach (var item in unlockProcedure.Methods)
39+
foreach (var item in EnumerateLoginMethods(unlockProcedure))
4040
{
4141
yield return item switch
4242
{

‎src/Platforms/SecureFolderFS.Maui/Platforms/iOS/ServiceImplementation/IOSVaultCredentialsService.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ protected override async IAsyncEnumerable<AuthenticationViewModel> GetLoginAsync
4343
[EnumeratorCancellation] CancellationToken cancellationToken = default)
4444
{
4545
await Task.CompletedTask;
46-
foreach (var item in unlockProcedure.Methods)
46+
foreach (var item in EnumerateLoginMethods(unlockProcedure))
4747
{
4848
yield return item switch
4949
{

0 commit comments

Comments
 (0)