Update user and folder encryption keys navigation naming (#2159)

This commit is contained in:
Leendert de Borst
2026-07-27 07:54:01 +02:00
parent ffaf5d2580
commit ff0de976e3
9 changed files with 88 additions and 13 deletions
@@ -921,7 +921,7 @@ export class VaultSyncService {
buckets: bucketDtos,
newBlobs: [] as BlobDto[],
emailRouting: { emailAddressList },
encryptionPublicKey: '',
userEncryptionPublicKey: '',
};
let resp = await webApi.post<typeof payload, VaultWriteResponseDto>(VAULT_ENDPOINT, payload);
@@ -1148,7 +1148,7 @@ export class VaultSyncService {
const webApi = new WebApiService();
/** POST the single-bucket write with the given believed-current revision (called again on the rebase retry). */
const postBucket = (currentRevision: number): Promise<VaultWriteResponseDto> => webApi.post<Record<string, unknown>, VaultWriteResponseDto>(VAULT_ENDPOINT, {
username, manifests: [], buckets: [{ category, blob: ciphertext, ciphertextHash, currentRevision }], newBlobs: [], emailRouting: null, encryptionPublicKey: '',
username, manifests: [], buckets: [{ category, blob: ciphertext, ciphertextHash, currentRevision }], newBlobs: [], emailRouting: null, userEncryptionPublicKey: '',
});
let currentRevision = (((await storage.getItem(bucketRevisionStorageKey(category))) as number | null) ?? 0);
@@ -139,7 +139,7 @@ export async function pushManifest(
newBlobs: [],
blobReferences,
emailRouting: { emailAddressList: [] },
encryptionPublicKey: '',
userEncryptionPublicKey: '',
};
const response = await fetch(`${apiBaseUrl.replace(/\/$/, '')}/v2/Vault`, {
@@ -547,9 +547,8 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
/// <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);
.AnyAsync(x => x.UserId == userId && x.VaultManifestId == null && x.IsPrimary && x.PublicKey == newPublicKey);
// If the public key already exists and is marked as primary (default), do nothing.
if (publicKeyExists)
@@ -557,9 +556,9 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
return;
}
// Update all existing keys to not be primary.
// Update all existing personal keys to not be primary.
var otherKeys = await context.UserEncryptionKeys
.Where(x => x.UserId == userId)
.Where(x => x.UserId == userId && x.VaultManifestId == null)
.ToListAsync();
foreach (var key in otherKeys)
@@ -570,7 +569,7 @@ public class VaultController(ILogger<VaultController> logger, IAliasServerDbCont
// 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);
.FirstOrDefaultAsync(x => x.UserId == userId && x.VaultManifestId == null && x.PublicKey == newPublicKey);
if (existingPublicKey is not null)
{
@@ -352,6 +352,27 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon
.HasForeignKey(l => l.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<UserEncryptionKey>(builder =>
{
// A folder-scoped key is removed when its manifest is removed; a personal key (null manifest) is unaffected.
builder.HasOne(k => k.VaultManifest)
.WithMany()
.HasForeignKey(k => k.VaultManifestId)
.OnDelete(DeleteBehavior.Cascade);
// Delivery resolves the primary key for a scope on every inbound mail, so index the lookup.
builder.HasIndex(k => new { k.UserId, k.VaultManifestId, k.IsPrimary });
});
// Configure UserEmailClaim - UserEncryptionKey relationship. Restrict rather than cascade: an
// email claim outlives its key on purpose (claims are retained to prevent address re-use), so a
// key must be re-pointed or nulled before it can be removed.
modelBuilder.Entity<UserEmailClaim>()
.HasOne(c => c.EncryptionKey)
.WithMany()
.HasForeignKey(c => c.EncryptionKeyId)
.OnDelete(DeleteBehavior.Restrict);
// Configure MobileLoginRequest - AliasVaultUser relationship
modelBuilder.Entity<MobileLoginRequest>()
.HasOne(m => m.User)
@@ -15,6 +15,12 @@ public class EmailRouting
/// <summary>Gets or sets the user's claimed email addresses (forwarded inbound).</summary>
public List<string> EmailAddressList { get; set; } = [];
/// <summary>
/// Gets or sets the claimed addresses whose items live in a shared folder, each carrying the manifest
/// whose published keypair encrypts its mail.
/// </summary>
public List<SharedEmailAddress> SharedEmailAddressList { get; set; } = [];
/// <summary>Gets or sets the private email domains available to this user.</summary>
public List<string> PrivateEmailDomainList { get; set; } = [];
@@ -0,0 +1,22 @@
//-----------------------------------------------------------------------
// <copyright file="SharedEmailAddress.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.Shared.Models.WebApi.V2.Vault;
/// <summary>
/// An email alias whose item lives in a shared folder: mail for it is encrypted with the folder's
/// published keypair rather than the routing owner's personal key, so every member of the folder can
/// read it.
/// </summary>
public class SharedEmailAddress
{
/// <summary>Gets or sets the full email address.</summary>
public required string Address { get; set; }
/// <summary>Gets or sets the shared-folder manifest whose key encrypts mail for this address.</summary>
public required Guid ManifestId { get; set; }
}
@@ -0,0 +1,21 @@
//-----------------------------------------------------------------------
// <copyright file="SharedFolderEncryptionPublicKey.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.Shared.Models.WebApi.V2.Vault;
/// <summary>
/// The public half of a shared folder's email keypair, published so the SMTP service can encrypt mail
/// for the folder's aliases. The private half never leaves the folder's manifest.
/// </summary>
public class SharedFolderEncryptionPublicKey
{
/// <summary>Gets or sets the shared-folder manifest this key belongs to.</summary>
public required Guid ManifestId { get; set; }
/// <summary>Gets or sets the public key to publish as the folder's active delivery key.</summary>
public required string PublicKey { get; set; }
}
@@ -27,6 +27,12 @@ public class VaultWriteRequest
/// <summary>Gets or sets the email routing data to update server-side.</summary>
public EmailRouting? EmailRouting { get; set; }
/// <summary>Gets or sets the public encryption key.</summary>
public string? EncryptionPublicKey { get; set; }
/// <summary>Gets or sets the public half of the user's own encryption keypair (client table `EncryptionKeys`).</summary>
public string? UserEncryptionPublicKey { get; set; }
/// <summary>
/// Gets or sets the public halves of the shared folders' own encryption keypairs (client table
/// `SharedFolderEncryptionKeys`).
/// </summary>
public List<SharedFolderEncryptionPublicKey> SharedFolderEncryptionPublicKeys { get; set; } = [];
}
+3 -3
View File
@@ -35,9 +35,9 @@ pub static BUCKET_TABLES: &[(&str, &str)] = &[
/// are found in a shared manifest anyway.
pub static PERSONAL_TABLES: &[&str] = &["EncryptionKeys"];
/// Tables that belong exclusively to a *shared-folder* manifest and never to the root.
// `SharedFolderEncryptionKeys` carries a folder's own email keypair, so that every member
/// of the folder can decrypt mail addressed to the folder's aliases; it is encrypted under
/// Tables that belong exclusively to a *shared-folder* manifest and never to the root.
/// `SharedFolderEncryptionKeys` carries a folder's own email keypair, so that every member
/// of the folder can decrypt mail addressed to the folder's aliases; it is encrypted under
/// the folder VEK and therefore readable by exactly the folder's members.
pub static SHARED_ONLY_TABLES: &[&str] = &["SharedFolderEncryptionKeys"];