From ff0de976e3e80232cb0131d9ec6e84078ed19ca7 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 27 Jul 2026 07:54:01 +0200 Subject: [PATCH] Update user and folder encryption keys navigation naming (#2159) --- .../src/utils/VaultSyncService.ts | 4 ++-- .../tests/helpers/manifest-v2-api.ts | 2 +- .../Controllers/V1/VaultController.cs | 9 ++++---- .../AliasServerDb/AliasServerDbContext.cs | 21 ++++++++++++++++++ .../Models/WebApi/V2/Vault/EmailRouting.cs | 6 +++++ .../WebApi/V2/Vault/SharedEmailAddress.cs | 22 +++++++++++++++++++ .../Vault/SharedFolderEncryptionPublicKey.cs | 21 ++++++++++++++++++ .../WebApi/V2/Vault/VaultWriteRequest.cs | 10 +++++++-- core/rust/src/vault_codec/types.rs | 6 ++--- 9 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/SharedEmailAddress.cs create mode 100644 apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/SharedFolderEncryptionPublicKey.cs diff --git a/apps/browser-extension/src/utils/VaultSyncService.ts b/apps/browser-extension/src/utils/VaultSyncService.ts index 09a9eec90..d1a9c5a84 100644 --- a/apps/browser-extension/src/utils/VaultSyncService.ts +++ b/apps/browser-extension/src/utils/VaultSyncService.ts @@ -921,7 +921,7 @@ export class VaultSyncService { buckets: bucketDtos, newBlobs: [] as BlobDto[], emailRouting: { emailAddressList }, - encryptionPublicKey: '', + userEncryptionPublicKey: '', }; let resp = await webApi.post(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 => webApi.post, 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); diff --git a/apps/browser-extension/tests/helpers/manifest-v2-api.ts b/apps/browser-extension/tests/helpers/manifest-v2-api.ts index 2d2526903..8e1852d02 100644 --- a/apps/browser-extension/tests/helpers/manifest-v2-api.ts +++ b/apps/browser-extension/tests/helpers/manifest-v2-api.ts @@ -139,7 +139,7 @@ export async function pushManifest( newBlobs: [], blobReferences, emailRouting: { emailAddressList: [] }, - encryptionPublicKey: '', + userEncryptionPublicKey: '', }; const response = await fetch(`${apiBaseUrl.replace(/\/$/, '')}/v2/Vault`, { diff --git a/apps/server/AliasVault.Api/Controllers/V1/VaultController.cs b/apps/server/AliasVault.Api/Controllers/V1/VaultController.cs index 7a51b5425..594ef0a6d 100644 --- a/apps/server/AliasVault.Api/Controllers/V1/VaultController.cs +++ b/apps/server/AliasVault.Api/Controllers/V1/VaultController.cs @@ -547,9 +547,8 @@ public class VaultController(ILogger logger, IAliasServerDbCont /// A task representing the asynchronous operation. 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 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 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) { diff --git a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs index 778e86ede..2a74a8d80 100644 --- a/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs +++ b/apps/server/Databases/AliasServerDb/AliasServerDbContext.cs @@ -352,6 +352,27 @@ public class AliasServerDbContext : WorkerStatusDbContext, IDataProtectionKeyCon .HasForeignKey(l => l.UserId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity(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() + .HasOne(c => c.EncryptionKey) + .WithMany() + .HasForeignKey(c => c.EncryptionKeyId) + .OnDelete(DeleteBehavior.Restrict); + // Configure MobileLoginRequest - AliasVaultUser relationship modelBuilder.Entity() .HasOne(m => m.User) diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/EmailRouting.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/EmailRouting.cs index 73cbdab69..9d353f4fa 100644 --- a/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/EmailRouting.cs +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/EmailRouting.cs @@ -15,6 +15,12 @@ public class EmailRouting /// Gets or sets the user's claimed email addresses (forwarded inbound). public List EmailAddressList { get; set; } = []; + /// + /// Gets or sets the claimed addresses whose items live in a shared folder, each carrying the manifest + /// whose published keypair encrypts its mail. + /// + public List SharedEmailAddressList { get; set; } = []; + /// Gets or sets the private email domains available to this user. public List PrivateEmailDomainList { get; set; } = []; diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/SharedEmailAddress.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/SharedEmailAddress.cs new file mode 100644 index 000000000..2cbad4ded --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/SharedEmailAddress.cs @@ -0,0 +1,22 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.V2.Vault; + +/// +/// 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. +/// +public class SharedEmailAddress +{ + /// Gets or sets the full email address. + public required string Address { get; set; } + + /// Gets or sets the shared-folder manifest whose key encrypts mail for this address. + public required Guid ManifestId { get; set; } +} diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/SharedFolderEncryptionPublicKey.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/SharedFolderEncryptionPublicKey.cs new file mode 100644 index 000000000..18bacff1b --- /dev/null +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/SharedFolderEncryptionPublicKey.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi.V2.Vault; + +/// +/// 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. +/// +public class SharedFolderEncryptionPublicKey +{ + /// Gets or sets the shared-folder manifest this key belongs to. + public required Guid ManifestId { get; set; } + + /// Gets or sets the public key to publish as the folder's active delivery key. + public required string PublicKey { get; set; } +} diff --git a/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/VaultWriteRequest.cs b/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/VaultWriteRequest.cs index 65738ae10..131723e7d 100644 --- a/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/VaultWriteRequest.cs +++ b/apps/server/Shared/AliasVault.Shared/Models/WebApi/V2/Vault/VaultWriteRequest.cs @@ -27,6 +27,12 @@ public class VaultWriteRequest /// Gets or sets the email routing data to update server-side. public EmailRouting? EmailRouting { get; set; } - /// Gets or sets the public encryption key. - public string? EncryptionPublicKey { get; set; } + /// Gets or sets the public half of the user's own encryption keypair (client table `EncryptionKeys`). + public string? UserEncryptionPublicKey { get; set; } + + /// + /// Gets or sets the public halves of the shared folders' own encryption keypairs (client table + /// `SharedFolderEncryptionKeys`). + /// + public List SharedFolderEncryptionPublicKeys { get; set; } = []; } diff --git a/core/rust/src/vault_codec/types.rs b/core/rust/src/vault_codec/types.rs index 6375dbe7c..c99d8c12a 100644 --- a/core/rust/src/vault_codec/types.rs +++ b/core/rust/src/vault_codec/types.rs @@ -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"];