mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-19 15:39:13 -05:00
518 lines
23 KiB
C#
518 lines
23 KiB
C#
//-----------------------------------------------------------------------
|
|
// <copyright file="VaultController.cs" company="aliasvault">
|
|
// Copyright (c) aliasvault. All rights reserved.
|
|
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
|
// </copyright>
|
|
//-----------------------------------------------------------------------
|
|
|
|
namespace AliasVault.Api.Controllers;
|
|
|
|
using System.ComponentModel.DataAnnotations;
|
|
using AliasServerDb;
|
|
using AliasVault.Api.Controllers.Abstracts;
|
|
using AliasVault.Api.Helpers;
|
|
using AliasVault.Api.Vault;
|
|
using AliasVault.Api.Vault.RetentionRules;
|
|
using AliasVault.Auth;
|
|
using AliasVault.Cryptography.Client;
|
|
using AliasVault.Shared.Models.Enums;
|
|
using AliasVault.Shared.Models.WebApi;
|
|
using AliasVault.Shared.Models.WebApi.PasswordChange;
|
|
using AliasVault.Shared.Models.WebApi.Vault;
|
|
using AliasVault.Shared.Providers.Time;
|
|
using Asp.Versioning;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
|
|
/// <summary>
|
|
/// Vault controller for handling CRUD operations on the database for encrypted vault entities.
|
|
/// </summary>
|
|
/// <param name="logger">ILogger instance.</param>
|
|
/// <param name="dbContextFactory">DbContext instance.</param>
|
|
/// <param name="userManager">UserManager instance.</param>
|
|
/// <param name="timeProvider">ITimeProvider instance.</param>
|
|
/// <param name="authLoggingService">AuthLoggingService instance.</param>
|
|
/// <param name="cache">IMemoryCache instance.</param>
|
|
/// <param name="config">Config instance.</param>
|
|
[ApiVersion("1")]
|
|
public class VaultController(ILogger<VaultController> logger, IAliasServerDbContextFactory dbContextFactory, UserManager<AliasVaultUser> userManager, ITimeProvider timeProvider, AuthLoggingService authLoggingService, IMemoryCache cache, Config config) : AuthenticatedRequestController(userManager)
|
|
{
|
|
/// <summary>
|
|
/// Default retention policy for vaults.
|
|
/// </summary>
|
|
private readonly RetentionPolicy _retentionPolicy = new()
|
|
{
|
|
Rules =
|
|
[
|
|
new RevisionRetentionRule { RevisionsToKeep = 3 },
|
|
new DailyRetentionRule { DaysToKeep = 2 },
|
|
new WeeklyRetentionRule { WeeksToKeep = 1 },
|
|
new MonthlyRetentionRule { MonthsToKeep = 1 },
|
|
new DbVersionRetentionRule { VersionsToKeep = 2 },
|
|
new LoginCredentialRetentionRule { CredentialsToKeep = 2 },
|
|
],
|
|
};
|
|
|
|
/// <summary>
|
|
/// Get the newest version of the vault for the current user.
|
|
/// </summary>
|
|
/// <returns>List of aliases in JSON format.</returns>
|
|
[HttpGet("")]
|
|
public async Task<IActionResult> GetVault()
|
|
{
|
|
await using var context = await dbContextFactory.CreateDbContextAsync();
|
|
|
|
var user = await GetCurrentUserAsync();
|
|
if (user == null)
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
// Logic to retrieve vault for the user.
|
|
var vault = await context.Vaults
|
|
.Where(x => x.UserId == user.Id)
|
|
.OrderByDescending(x => x.RevisionNumber)
|
|
.FirstOrDefaultAsync();
|
|
|
|
// If no vault is found on server, return an empty object. This means the client will use an empty vault
|
|
// as starting point.
|
|
if (vault == null)
|
|
{
|
|
return Ok(new Shared.Models.WebApi.Vault.VaultGetResponse
|
|
{
|
|
Status = VaultStatus.Ok,
|
|
Vault = new Shared.Models.WebApi.Vault.Vault
|
|
{
|
|
Username = user.UserName!,
|
|
Blob = string.Empty,
|
|
Version = string.Empty,
|
|
CurrentRevisionNumber = 0,
|
|
CredentialsCount = 0,
|
|
CreatedAt = DateTime.MinValue,
|
|
UpdatedAt = DateTime.MinValue,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Get dynamic list of private email domains from config.
|
|
var privateEmailDomainList = config.PrivateEmailDomains;
|
|
var hiddenPrivateEmailDomainList = config.HiddenPrivateEmailDomains;
|
|
|
|
// Hardcoded list of public (SpamOK) email domains that are available to the client.
|
|
var publicEmailDomainList = new List<string>(["spamok.com", "solarflarecorp.com", "spamok.nl", "3060.nl",
|
|
"landmail.nl", "asdasd.nl", "spamok.de", "spamok.com.ua", "spamok.es", "spamok.fr"]);
|
|
|
|
return Ok(new Shared.Models.WebApi.Vault.VaultGetResponse
|
|
{
|
|
Status = VaultStatus.Ok,
|
|
Vault = new Shared.Models.WebApi.Vault.Vault
|
|
{
|
|
Username = user.UserName!,
|
|
Blob = vault.VaultBlob,
|
|
Version = vault.Version,
|
|
CurrentRevisionNumber = vault.RevisionNumber,
|
|
EncryptionPublicKey = string.Empty,
|
|
CredentialsCount = 0,
|
|
PrivateEmailDomainList = privateEmailDomainList,
|
|
HiddenPrivateEmailDomainList = hiddenPrivateEmailDomainList,
|
|
PublicEmailDomainList = publicEmailDomainList,
|
|
CreatedAt = vault.CreatedAt,
|
|
UpdatedAt = vault.UpdatedAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save a new vault to the database for the current user.
|
|
/// </summary>
|
|
/// <param name="model">Vault model.</param>
|
|
/// <param name="clientHeader">Client header.</param>
|
|
/// <returns>IActionResult.</returns>
|
|
[HttpPost("")]
|
|
public async Task<IActionResult> Update([FromBody] Shared.Models.WebApi.Vault.Vault model, [FromHeader(Name = "X-AliasVault-Client")] string? clientHeader)
|
|
{
|
|
await using var context = await dbContextFactory.CreateDbContextAsync();
|
|
|
|
var user = await GetCurrentUserAsync();
|
|
if (user == null)
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
// Compare the logged-in username with the username in the provided vault model.
|
|
// If they do not match reject the request. This is important because it's
|
|
// possible that a user has logged in with a different username than the one
|
|
// that is being used to update the vault (e.g. if working with multiple tabs).
|
|
if (user.UserName != model.Username)
|
|
{
|
|
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USERNAME_MISMATCH, 400));
|
|
}
|
|
|
|
// Retrieve latest vault of user which contains the current encryption settings.
|
|
var latestVault = user.Vaults.OrderByDescending(x => x.RevisionNumber).Select(x => new { x.Salt, x.Verifier, x.EncryptionType, x.EncryptionSettings, x.RevisionNumber, x.Version }).First();
|
|
|
|
// Reject vaults with a version that is lower than the last vault version.
|
|
if (VersionHelper.IsVersionOlder(model.Version, latestVault.Version))
|
|
{
|
|
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.VAULT_NOT_UP_TO_DATE, 400));
|
|
}
|
|
|
|
// Calculate the new revision number for the vault.
|
|
var newRevisionNumber = model.CurrentRevisionNumber + 1;
|
|
|
|
// Check if the latest vault revision number is equal to or higher than the new revision number.
|
|
// If so it means the client's vault is outdated and the client should fetch the latest vault from the server before saving can continue.
|
|
if (latestVault.RevisionNumber >= newRevisionNumber)
|
|
{
|
|
return Ok(new VaultUpdateResponse { Status = VaultStatus.Outdated, NewRevisionNumber = latestVault.RevisionNumber });
|
|
}
|
|
|
|
// Create new vault entry with salt and verifier of current vault.
|
|
var newVault = new AliasServerDb.Vault
|
|
{
|
|
UserId = user.Id,
|
|
VaultBlob = model.Blob,
|
|
Version = model.Version,
|
|
RevisionNumber = newRevisionNumber,
|
|
FileSize = FileHelper.Base64StringToKilobytes(model.Blob),
|
|
CredentialsCount = model.CredentialsCount,
|
|
EmailClaimsCount = model.EmailAddressList.Count,
|
|
Salt = latestVault.Salt,
|
|
Verifier = latestVault.Verifier,
|
|
EncryptionType = latestVault.EncryptionType,
|
|
EncryptionSettings = latestVault.EncryptionSettings,
|
|
Client = clientHeader,
|
|
CreatedAt = timeProvider.UtcNow,
|
|
UpdatedAt = timeProvider.UtcNow,
|
|
};
|
|
|
|
// Run the vault retention manager to clean up old vaults.
|
|
await ApplyVaultRetention(context, user.Id, newVault);
|
|
|
|
// Add the new vault and commit to database.
|
|
context.Vaults.Add(newVault);
|
|
await context.SaveChangesAsync();
|
|
|
|
// Update user email claims if email addresses have been supplied.
|
|
if (model.EmailAddressList.Count > 0)
|
|
{
|
|
await UpdateUserEmailClaims(context, user, model.EmailAddressList);
|
|
}
|
|
|
|
// Sync user public key if supplied.
|
|
if (!string.IsNullOrEmpty(model.EncryptionPublicKey))
|
|
{
|
|
await UpdateUserPublicKey(context, user.Id, model.EncryptionPublicKey);
|
|
}
|
|
|
|
return Ok(new VaultUpdateResponse { Status = VaultStatus.Ok, NewRevisionNumber = newRevisionNumber });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save a new vault to the database based on a new encryption password for the current user.
|
|
/// </summary>
|
|
/// <param name="model">Vault model.</param>
|
|
/// <param name="clientHeader">Client header.</param>
|
|
/// <returns>IActionResult.</returns>
|
|
[HttpPost("change-password")]
|
|
public async Task<IActionResult> UpdateChangePassword(
|
|
[FromBody] VaultPasswordChangeRequest model,
|
|
[FromHeader(Name = "X-AliasVault-Client")] string? clientHeader)
|
|
{
|
|
await using var context = await dbContextFactory.CreateDbContextAsync();
|
|
|
|
var user = await GetCurrentUserAsync();
|
|
if (user == null)
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
// Compare the logged-in username with the username in the provided vault model.
|
|
// If they do not match reject the request. This is important because it's
|
|
// possible that a user has logged in with a different username than the one
|
|
// that is being used to update the vault (e.g. if working with multiple tabs).
|
|
if (model.Username != user.UserName)
|
|
{
|
|
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.USERNAME_MISMATCH, 400));
|
|
}
|
|
|
|
// Validate the SRP session (actual password check).
|
|
var serverSession = AuthHelper.ValidateSrpSession(cache, user, model.CurrentClientPublicEphemeral, model.CurrentClientSessionProof);
|
|
if (serverSession is null)
|
|
{
|
|
// Increment failed login attempts in order to lock out the account when the limit is reached.
|
|
await GetUserManager().AccessFailedAsync(user);
|
|
|
|
await authLoggingService.LogAuthEventFailAsync(user.UserName!, AuthEventType.PasswordChange, AuthFailureReason.InvalidPassword);
|
|
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.PASSWORD_MISMATCH, 400));
|
|
}
|
|
|
|
// Check if the provided revision number is equal to the latest revision number.
|
|
// If not, then the client is trying to update an older vault which we don't allow to prevent data loss.
|
|
var latestVault = user.Vaults.OrderByDescending(x => x.RevisionNumber).First();
|
|
if (VersionHelper.IsVersionOlder(model.Version, latestVault.Version))
|
|
{
|
|
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.VAULT_NOT_UP_TO_DATE, 400));
|
|
}
|
|
|
|
// Calculate the new revision number for the vault.
|
|
var newRevisionNumber = model.CurrentRevisionNumber + 1;
|
|
|
|
// Check if the latest vault revision number is equal to or higher than the new revision number.
|
|
// If so it means the client's vault is outdated and the client should fetch the latest vault from the server before saving can continue.
|
|
if (latestVault.RevisionNumber >= newRevisionNumber)
|
|
{
|
|
return Ok(new VaultUpdateResponse { Status = VaultStatus.Outdated, NewRevisionNumber = latestVault.RevisionNumber });
|
|
}
|
|
|
|
// Create new vault entry with salt and verifier of current vault.
|
|
var newVault = new AliasServerDb.Vault
|
|
{
|
|
UserId = user.Id,
|
|
VaultBlob = model.Blob,
|
|
Version = model.Version,
|
|
RevisionNumber = newRevisionNumber,
|
|
CredentialsCount = model.CredentialsCount,
|
|
EmailClaimsCount = model.EmailAddressList.Count,
|
|
FileSize = FileHelper.Base64StringToKilobytes(model.Blob),
|
|
Salt = model.NewPasswordSalt,
|
|
Verifier = model.NewPasswordVerifier,
|
|
EncryptionType = Defaults.EncryptionType,
|
|
EncryptionSettings = Defaults.EncryptionSettings,
|
|
Client = clientHeader,
|
|
CreatedAt = timeProvider.UtcNow,
|
|
UpdatedAt = timeProvider.UtcNow,
|
|
};
|
|
|
|
// Run the vault retention manager to clean up old vaults.
|
|
await ApplyVaultRetention(context, user.Id, newVault);
|
|
|
|
// Add the new vault and commit to database.
|
|
context.Vaults.Add(newVault);
|
|
await context.SaveChangesAsync();
|
|
|
|
// Update the password last changed at timestamp for user.
|
|
user.PasswordChangedAt = timeProvider.UtcNow;
|
|
await GetUserManager().UpdateAsync(user);
|
|
|
|
await authLoggingService.LogAuthEventSuccessAsync(user.UserName!, AuthEventType.PasswordChange);
|
|
|
|
// Force revoke all user logged in sessions except current one.
|
|
// This means that other clients which have not already updated to the new password will be logged out.
|
|
// This ensures that all clients login again with the new password to refresh their encryption keys for future vault mutations.
|
|
var deviceIdentifier = AuthHelper.GenerateDeviceIdentifier(Request);
|
|
await context.AliasVaultUserRefreshTokens.Where(x => x.UserId == user.Id && x.DeviceIdentifier != deviceIdentifier).ExecuteDeleteAsync();
|
|
|
|
return Ok(new VaultUpdateResponse { Status = VaultStatus.Ok, NewRevisionNumber = newRevisionNumber });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply vault retention policies to the user's vaults and delete the ones that are not covered
|
|
/// by the retention policies.
|
|
/// </summary>
|
|
/// <param name="context">Database context.</param>
|
|
/// <param name="userId">User ID.</param>
|
|
/// <param name="newVault">New vault object.</param>
|
|
private async Task ApplyVaultRetention(AliasServerDbContext context, string userId, AliasServerDb.Vault newVault)
|
|
{
|
|
// Run the vault retention manager to keep the required vaults according
|
|
// to the applied retention policies and delete the rest.
|
|
// We only select the Id and UpdatedAt fields to reduce the amount of data transferred from the database.
|
|
var existingVaults = await context.Vaults
|
|
.Where(x => x.UserId == userId)
|
|
.OrderByDescending(v => v.UpdatedAt)
|
|
.Select(x => new AliasServerDb.Vault
|
|
{
|
|
Id = x.Id,
|
|
UserId = x.UserId,
|
|
VaultBlob = string.Empty,
|
|
Version = x.Version,
|
|
RevisionNumber = x.RevisionNumber,
|
|
FileSize = x.FileSize,
|
|
CredentialsCount = x.CredentialsCount,
|
|
EmailClaimsCount = x.EmailClaimsCount,
|
|
Salt = x.Salt,
|
|
Verifier = x.Verifier,
|
|
EncryptionType = x.EncryptionType,
|
|
EncryptionSettings = x.EncryptionSettings,
|
|
Client = x.Client,
|
|
CreatedAt = x.CreatedAt,
|
|
UpdatedAt = x.UpdatedAt,
|
|
})
|
|
.ToListAsync();
|
|
|
|
var vaultsToDelete = VaultRetentionManager.ApplyRetention(_retentionPolicy, existingVaults, timeProvider.UtcNow, newVault);
|
|
|
|
// Delete vaults that are not needed anymore.
|
|
context.Vaults.RemoveRange(vaultsToDelete);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the user's email claims based on the provided email address list.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="user">The user object.</param>
|
|
/// <param name="newEmailAddresses">The list of new email addresses to claim.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
private async Task UpdateUserEmailClaims(AliasServerDbContext context, AliasVaultUser user, List<string> newEmailAddresses)
|
|
{
|
|
// Deduplicate email addresses to prevent unique constraint violations when
|
|
// multiple credentials share the same private email address.
|
|
newEmailAddresses = newEmailAddresses.Select(EmailHelper.SanitizeEmail).Distinct().ToList();
|
|
|
|
// Get all existing user email claims.
|
|
var userOwnedEmailClaims = await context.UserEmailClaims
|
|
.Where(x => x.UserId == user.Id)
|
|
.ToListAsync();
|
|
|
|
// Keep track of processed and sanitized email addresses to know which ones still exist.
|
|
var processedEmailAddresses = new List<string>();
|
|
|
|
// Get list of supported private domains from config
|
|
var supportedPrivateDomains = config.PrivateEmailDomains;
|
|
|
|
// Register new email addresses.
|
|
foreach (var email in newEmailAddresses)
|
|
{
|
|
// Sanitize email address.
|
|
var sanitizedEmail = EmailHelper.SanitizeEmail(email);
|
|
processedEmailAddresses.Add(sanitizedEmail);
|
|
|
|
// If email address is invalid according to the EmailAddressAttribute, skip it.
|
|
if (!new EmailAddressAttribute().IsValid(sanitizedEmail))
|
|
{
|
|
logger.LogWarning("{User} tried to claim invalid email address: {Email}", user.UserName, sanitizedEmail);
|
|
continue;
|
|
}
|
|
|
|
// Extract domain from email
|
|
var domain = sanitizedEmail.Split('@')[1];
|
|
|
|
// Skip if domain is not in supported private domains list
|
|
if (!supportedPrivateDomains.Contains(domain))
|
|
{
|
|
logger.LogWarning("{User} tried to claim email with unsupported private domain: {Email}", user.UserName, sanitizedEmail);
|
|
continue;
|
|
}
|
|
|
|
// If email address is already claimed by current user, we don't need to claim it again.
|
|
var existingUserClaim = userOwnedEmailClaims.FirstOrDefault(x => x.Address == sanitizedEmail);
|
|
if (existingUserClaim != null)
|
|
{
|
|
// Claim already exists but is disabled, so we can re-enable it.
|
|
if (existingUserClaim.Disabled)
|
|
{
|
|
existingUserClaim.Disabled = false;
|
|
existingUserClaim.UpdatedAt = timeProvider.UtcNow;
|
|
}
|
|
|
|
// If the claim already exists and is not disabled, everything is good, we don't need to do anything.
|
|
continue;
|
|
}
|
|
|
|
// Check if the email address is already claimed (by another user).
|
|
var existingForeignClaim = await context.UserEmailClaims.FirstOrDefaultAsync(x => x.Address == sanitizedEmail);
|
|
if (existingForeignClaim != null && existingForeignClaim.UserId != user.Id)
|
|
{
|
|
// Email address is already claimed by another user. Log the error and continue.
|
|
logger.LogWarning("{User} tried to claim email address: {Email} but it is already claimed by another user.", user.UserName, sanitizedEmail);
|
|
continue;
|
|
}
|
|
|
|
// If we get to this point, the email address is new and not claimed by another user, so we can add it.
|
|
try
|
|
{
|
|
context.UserEmailClaims.Add(new UserEmailClaim
|
|
{
|
|
UserId = user.Id,
|
|
Address = sanitizedEmail,
|
|
AddressLocal = sanitizedEmail.Split('@')[0],
|
|
AddressDomain = sanitizedEmail.Split('@')[1],
|
|
CreatedAt = timeProvider.UtcNow,
|
|
UpdatedAt = timeProvider.UtcNow,
|
|
});
|
|
}
|
|
catch (DbUpdateException ex)
|
|
{
|
|
// Error while adding email claim. Log the error and continue.
|
|
logger.LogWarning(ex, "Error while adding UserEmailClaim with email: {Email} for user: {UserId}.", sanitizedEmail, user.UserName);
|
|
}
|
|
}
|
|
|
|
// Disable email claims that are no longer in the new list and have not been disabled yet.
|
|
// Important: we do not delete email claims ever, as they may be re-used by the user in the future.
|
|
// We also don't want to allow other users to re-use emails used by other users.
|
|
// Email claims are considered permanent.
|
|
foreach (var existingClaim in userOwnedEmailClaims.Where(x => !x.Disabled).ToList())
|
|
{
|
|
if (!processedEmailAddresses.Contains(existingClaim.Address))
|
|
{
|
|
// Email address is no longer in the new list and has not been disabled yet, so disable it.
|
|
existingClaim.Disabled = true;
|
|
existingClaim.UpdatedAt = timeProvider.UtcNow;
|
|
}
|
|
}
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the user's public key based on the provided public key. If it already exists, do nothing.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="userId">The ID of the user.</param>
|
|
/// <param name="newPublicKey">The new public key to sync and set as default.</param>
|
|
/// <returns>A task representing the asynchronous operation.</returns>
|
|
private async Task UpdateUserPublicKey(AliasServerDbContext context, string userId, string newPublicKey)
|
|
{
|
|
// Get all existing user public keys.
|
|
var publicKeyExists = await context.UserEncryptionKeys
|
|
.AnyAsync(x => x.UserId == userId && x.IsPrimary && x.PublicKey == newPublicKey);
|
|
|
|
// If the public key already exists and is marked as primary (default), do nothing.
|
|
if (publicKeyExists)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Update all existing keys to not be primary.
|
|
var otherKeys = await context.UserEncryptionKeys
|
|
.Where(x => x.UserId == userId)
|
|
.ToListAsync();
|
|
|
|
foreach (var key in otherKeys)
|
|
{
|
|
key.IsPrimary = false;
|
|
key.UpdatedAt = timeProvider.UtcNow;
|
|
}
|
|
|
|
// Check if the new public key already exists but is not marked as primary.
|
|
var existingPublicKey = await context.UserEncryptionKeys
|
|
.FirstOrDefaultAsync(x => x.UserId == userId && x.PublicKey == newPublicKey);
|
|
|
|
if (existingPublicKey is not null)
|
|
{
|
|
// Set the existing key to be primary.
|
|
existingPublicKey.IsPrimary = true;
|
|
existingPublicKey.UpdatedAt = timeProvider.UtcNow;
|
|
await context.SaveChangesAsync();
|
|
return;
|
|
}
|
|
|
|
// Public key is new, so create it.
|
|
var newPublicKeyEntry = new UserEncryptionKey
|
|
{
|
|
UserId = userId,
|
|
PublicKey = newPublicKey,
|
|
IsPrimary = true,
|
|
CreatedAt = timeProvider.UtcNow,
|
|
UpdatedAt = timeProvider.UtcNow,
|
|
};
|
|
context.UserEncryptionKeys.Add(newPublicKeyEntry);
|
|
|
|
await context.SaveChangesAsync();
|
|
}
|
|
}
|