Update first manifest-v1 migration flow (#2159)

This commit is contained in:
Leendert de Borst
2026-07-25 12:33:16 +02:00
parent 6aeee16c7a
commit b9f6b9dc88
14 changed files with 166 additions and 115 deletions
@@ -35,7 +35,7 @@ import { type VaultMutationScope, ALL_VAULT_MUTATION_SCOPES, DEFAULT_VAULT_MUTAT
import { VaultCodec } from '@/utils/VaultCodec';
import { VaultKeyService, WRAPPED_VEK_STORAGE_KEY } from '@/utils/VaultKeyService';
import { vaultMergeService } from '@/utils/VaultMergeService';
import { vaultSyncService, SERVER_MANIFEST_REVISIONS_STORAGE_KEY } from '@/utils/VaultSyncService';
import { vaultSyncService, SERVER_MANIFEST_REVISIONS_STORAGE_KEY, CONTENT_FINGERPRINTS_STORAGE_KEY } from '@/utils/VaultSyncService';
import { WebApiService } from '@/utils/WebApiService';
import { t } from '@/i18n/StandaloneI18n';
@@ -278,10 +278,19 @@ async function fetchLatestVaultFromServer(): Promise<VaultResponse> {
/**
* Upload the current SQLite via the v2 storage format.
* @param sqliteClient - the in-memory SQLite client to upload
* @param forceFullWrite - bypass the content-fingerprint gating and rewrite every manifest and bucket (server rollback recovery)
*/
async function uploadVaultV2(sqliteClient: SqliteClient): Promise<VaultPostResponse> {
async function uploadVaultV2(sqliteClient: SqliteClient, forceFullWrite: boolean = false): Promise<VaultPostResponse> {
// Surfaces ServerUpdateRequiredError for servers that do not support the v2 API before we attempt the push.
const syncStatus = await vaultSyncService.checkStatus();
await vaultSyncService.checkStatus();
/*
* A vault key another device created must be adopted before we decide anything, otherwise this push would try to
* migrate an already-migrated vault and the server would reject it.
*/
if (!await adoptRemoteVaultKeyIfNeeded()) {
throw new Error(formatErrorWithCode('Vault encryption key out of sync with the server; please log in again', AppErrorCode.VAULT_DECRYPT_FAILED));
}
const encryptionKey = await handleGetEncryptionKey();
if (!encryptionKey) {
@@ -294,9 +303,11 @@ async function uploadVaultV2(sqliteClient: SqliteClient): Promise<VaultPostRespo
/*
* KEK/VEK migration: a not-yet-migrated (legacy sqlite-blob) user has no vault key, so their first manifest
* upload generates a VEK, re-encrypts everything with it, and creates the vault key server-side (wrapping the
* VEK with the current password-derived key).
* VEK with the current password-derived key). The local wrapped-VEK cache is the source of truth here: it is
* exactly "the session key is a VEK", which is the question this decision asks.
*/
const result = await vaultSyncService.push(sqliteClient, encryptionKey, username, emailAddresses, { createVaultKey: !syncStatus.isMigrated });
const hasVaultKey = await VaultKeyService.hasLocalVaultKey();
const result = await vaultSyncService.push(sqliteClient, encryptionKey, username, emailAddresses, { createVaultKey: !hasVaultKey, forceFullWrite });
if (result.status === 'ok') {
if (result.newEncryptionKey) {
@@ -468,6 +479,7 @@ export async function handleClearVaultData(): Promise<messageBoolResponse> {
'local:hiddenPrivateEmailDomains',
'local:serverRevision',
SERVER_MANIFEST_REVISIONS_STORAGE_KEY,
CONTENT_FINGERPRINTS_STORAGE_KEY,
'local:isDirty',
...ALL_VAULT_MUTATION_SCOPES.map(scope => dirtyScopeStorageKey(scope)),
'local:mutationSequence',
@@ -477,6 +489,9 @@ export async function handleClearVaultData(): Promise<messageBoolResponse> {
'local:username',
]);
// Clear the cached vault key and wrapped VEK.
VaultKeyService.clearCache();
// Clear all local preferences (site settings, login save settings, etc.)
await LocalPreferencesService.clearAll();
@@ -839,10 +854,10 @@ async function uploadDirtyBucketsOnly(sqliteClient: SqliteClient, scopes: VaultM
/*
* A bucket-only push encrypts with the current session key. A not-yet-migrated user has no vault key yet, so the
* full upload path must run instead to re-key the whole vault (KEK/VEK migration) in one atomic request.
* full upload path must run instead to re-key the whole vault (KEK/VEK migration) in one atomic request. Answered
* from the local wrapped-VEK cache, so the common (migrated) case costs no round-trip.
*/
const syncStatus = await vaultSyncService.checkStatus();
if (!syncStatus.isMigrated) {
if (!await VaultKeyService.hasLocalVaultKey()) {
devLog('[V2Push] User not yet migrated (no vault key), falling back to full vault upload to run the KEK/VEK migration.');
return (await uploadNewVaultToServer(sqliteClient)).response;
}
@@ -883,10 +898,13 @@ async function uploadDirtyBucketsOnly(sqliteClient: SqliteClient, scopes: VaultM
* Returns the upload status and captures the mutation sequence at start for race detection.
*
* Bucket-aware: when every pending mutation is scoped to a data bucket (e.g. Settings), only those buckets
* are pushed. A dirty 'manifest' scope (or a dirty state with no recorded scopes) triggers the full upload,
* which bundles all buckets as a safeguard too.
* are pushed. A dirty 'manifest' scope (or a dirty state with no recorded scopes) triggers the full upload path,
* whose content-fingerprint gating then narrows the write down to the manifests/buckets that actually changed.
* @param options - set forceFullWrite to rewrite every manifest and bucket regardless of change detection
* (server rollback recovery)
*/
export async function handleUploadVault(
options?: { forceFullWrite?: boolean }
) : Promise<messageVaultUploadResponse> {
try {
// Capture mutation sequence at start of upload for race detection
@@ -895,9 +913,10 @@ export async function handleUploadVault(
// Create sqlite client from the already-stored vault blob.
const sqliteClient = await createVaultSqliteClient();
// Upload to the server: bucket-only when possible, full vault otherwise.
// Upload to the server: bucket-only when possible, full vault otherwise. A forced full write skips the bucket-only shortcut.
const forceFullWrite = options?.forceFullWrite === true;
const dirtyScopes = await getDirtyScopes();
const bucketOnly = dirtyScopes.length > 0 && !dirtyScopes.some(isManifestScope);
const bucketOnly = !forceFullWrite && dirtyScopes.length > 0 && !dirtyScopes.some(isManifestScope);
if (bucketOnly) {
devLog(`[V2Push] All pending mutations are bucket-scoped (${dirtyScopes.join(', ')}), skipping manifest upload.`);
}
@@ -907,7 +926,7 @@ export async function handleUploadVault(
// Bucket-only pushes never prune (settings buckets carry no trash items).
response = await uploadDirtyBucketsOnly(sqliteClient, dirtyScopes);
} else {
({ response, vaultPruned } = await uploadNewVaultToServer(sqliteClient));
({ response, vaultPruned } = await uploadNewVaultToServer(sqliteClient, forceFullWrite));
}
return {
@@ -1012,8 +1031,10 @@ export async function handleClearPersistedFormValues(): Promise<void> {
/**
* Upload a new version of the vault to the server using the provided sqlite client.
* Prunes expired trash items before uploading.
* @param sqliteClient - the in-memory SQLite client to upload
* @param forceFullWrite - bypass the content-fingerprint gating and rewrite every manifest and bucket
*/
async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<{ response: VaultPostResponse; vaultPruned: boolean }> {
async function uploadNewVaultToServer(sqliteClient: SqliteClient, forceFullWrite: boolean = false) : Promise<{ response: VaultPostResponse; vaultPruned: boolean }> {
devLog('[VaultSync] Upload started');
let updatedVaultData = sqliteClient.exportToBase64();
let vaultPruned = false;
@@ -1050,7 +1071,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise<{ re
let v2Response: VaultPostResponse;
try {
v2Response = await uploadVaultV2(sqliteClient);
v2Response = await uploadVaultV2(sqliteClient, forceFullWrite);
} catch (err) {
if (err instanceof ServerUpdateRequiredError) {
throw new Error(formatErrorWithCode(await t('common.errors.serverVersionNotSupported'), AppErrorCode.SERVER_UPDATE_REQUIRED));
@@ -1289,25 +1310,19 @@ export async function handleGetServerRevision(): Promise<number> {
}
/**
* Catch this device up when ANOTHER device performed the KEK/VEK migration.
* Adopt a server-side vault key this device does not know about yet.
*
* After another device migrates, the server reports isMigrated=true but this device may still be holding the old
* password-derived key (the KEK) as its session key, with no cached wrapped VEK. Rather than force a re-login, this
* fetches the wrapped VEK, unwraps it with the session key (= the KEK), re-encrypts the locally persisted vault
* under the VEK, swaps the session key to the VEK, and caches the wrapped VEK. A no-op when there is nothing to
* adopt (no server key, already on the VEK, or vault locked).
* A missing local wrapped-VEK cache means one of three things: this user is genuinely still legacy (their next full
* push performs the KEK/VEK migration), another device migrated while this one held the old password-derived key, or
* this device never cached the key it registered with.
*
* TODO: this method can be removed once all users have migrated to the KEK/VEK model.
* TODO: this method can be removed once all users have migrated to the KEK/VEK model and we don't support legacy users anymore.
*
* @param statusResponse - the status response from the server (isMigrated=true signals another device migrated)
* @returns False only when this device is holding key material that matches neither the KEK nor the VEK, which
* requires a re-login; true in every other case, including offline (the next sync retries).
*/
async function catchUpAfterRemoteVaultKeyMigration(statusResponse: StatusResponseV2): Promise<boolean> {
if (!statusResponse.isMigrated) {
return true;
}
const wrappedVek = await storage.getItem(WRAPPED_VEK_STORAGE_KEY) as string | null;
if (wrappedVek) {
async function adoptRemoteVaultKeyIfNeeded(): Promise<boolean> {
if (await VaultKeyService.hasLocalVaultKey()) {
// Already on the KEK/VEK model: the session key is the VEK.
return true;
}
@@ -1318,30 +1333,56 @@ async function catchUpAfterRemoteVaultKeyMigration(statusResponse: StatusRespons
return true;
}
let fetchResult;
try {
const fetchResult = await VaultKeyService.fetchVaultKey();
if (!fetchResult.vaultKey) {
return true;
}
fetchResult = await VaultKeyService.fetchVaultKey();
} catch (error) {
// Server unreachable or the probe failed: state is unchanged and unknowable, so let the next sync retry.
devWarn('[VaultSync] Vault key probe failed, deferring vault key adoption:', error);
return true;
}
const vek = await EncryptionUtility.unwrapVaultEncryptionKey(fetchResult.vaultKey.wrappedVek, sessionKey);
if (!fetchResult.vaultKey) {
// Genuinely legacy: the next full push creates the vault key and re-encrypts the vault under a fresh VEK.
return true;
}
// Re-encrypt the locally persisted vault with the VEK before swapping the session key.
const encryptedVault = await storage.getItem('local:encryptedVault') as string | null;
const serverWrappedVek = fetchResult.vaultKey.wrappedVek;
const encryptedVault = await storage.getItem('local:encryptedVault') as string | null;
try {
const vek = await EncryptionUtility.unwrapVaultEncryptionKey(serverWrappedVek, sessionKey);
// The session key was the KEK: re-encrypt the locally persisted vault with the VEK before swapping the session key.
if (encryptedVault) {
const decrypted = await EncryptionUtility.symmetricDecrypt(encryptedVault, sessionKey);
await storage.setItem('local:encryptedVault', await EncryptionUtility.symmetricEncrypt(decrypted, vek));
}
await storage.setItem(WRAPPED_VEK_STORAGE_KEY, fetchResult.vaultKey.wrappedVek);
await storage.setItem(WRAPPED_VEK_STORAGE_KEY, serverWrappedVek);
await handleStoreEncryptionKey(vek);
cachedSqliteClient = null;
cachedVaultBlob = null;
devLog('[VaultSync] Adopted vault key created by another client; session key swapped to the VEK.');
return true;
} catch (error) {
devError('[VaultSync] Failed to adopt remote vault key, forcing re-login:', error);
return false;
} catch {
/*
* Unwrap failed, so the session key is not the KEK. The remaining possibility is that it already is the VEK and
* this device simply never cached the wrapped form (e.g. registered on the KEK/VEK model). Confirm against the
* local vault before trusting it: if the session key still decrypts the vault, cache the wrapped VEK and carry on.
*/
try {
if (encryptedVault) {
await EncryptionUtility.symmetricDecrypt(encryptedVault, sessionKey);
}
} catch (error) {
devError('[VaultSync] Session key matches neither the KEK nor the VEK, forcing re-login:', error);
return false;
}
await storage.setItem(WRAPPED_VEK_STORAGE_KEY, serverWrappedVek);
devLog('[VaultSync] Session key is already the VEK; cached the wrapped VEK for offline unlock.');
return true;
}
}
@@ -1423,7 +1464,7 @@ export async function handleCheckSyncStatus(): Promise<SyncStatusCheckResult> {
}
// If another device performed the KEK/VEK migration, catch up before any sync work happens.
if (!await catchUpAfterRemoteVaultKeyMigration(statusResponse)) {
if (!await adoptRemoteVaultKeyIfNeeded()) {
return { success: false, hasNewerVault: false, hasDirtyChanges: false, isOffline: false, requiresLogout: true, errorKey: 'passwordChanged' };
}
@@ -1556,7 +1597,7 @@ async function handleFullVaultSyncInternal(): Promise<FullVaultSyncResult> {
}
// If another device performed the KEK/VEK migration, catch up before any sync work happens.
if (!await catchUpAfterRemoteVaultKeyMigration(statusResponse)) {
if (!await adoptRemoteVaultKeyIfNeeded()) {
return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: true, errorKey: 'passwordChanged' };
}
@@ -1728,7 +1769,11 @@ async function handleFullVaultSyncInternal(): Promise<FullVaultSyncResult> {
`client at rev ${syncState.serverRevision}. Uploading to recover server state.`
);
const uploadResponse = await handleUploadVault();
/*
* Force a full write: the server's content is behind the client's baselines, so the fingerprint gating
* would wrongly report "unchanged" and skip exactly the targets that need to be restored.
*/
const uploadResponse = await handleUploadVault({ forceFullWrite: true });
if (uploadResponse.success && uploadResponse.status === 0) {
await handleMarkVaultClean({
@@ -21,6 +21,22 @@ export const WRAPPED_VEK_STORAGE_KEY = 'local:wrappedVek';
/** The key type for password-based vault keys, mirroring the server's VaultKey.KeyType value. */
export const VAULT_KEY_TYPE_PASSWORD = 'password';
/**
* How long a fetched password vault key response is reused before hitting the network again. Mirrors the /v2/Status
* cache: long enough to collapse the repeated probes a single sync cycle makes, short enough that it never spans
* two user actions.
*/
const VAULT_KEY_CACHE_TTL_MS = 1000;
/**
* Process-wide (background service worker) cache of the last successful password vault key response.
*
* This exists for NOT-yet-migrated users. A migrated device answers "do I have a vault key" from its local wrapped-VEK
* cache and never probes at all, but a legacy device has nothing to cache, so every caller that asks would otherwise
* issue its own GET — several per sync across the status check and the push path.
*/
let cachedVaultKey: { data: FetchVaultKeyResult; timestamp: number } | null = null;
/**
* Result of resolving the vault encryption key after deriving the password key.
*/
@@ -46,23 +62,33 @@ export type FetchVaultKeyResult = {
*/
export class VaultKeyService {
/**
* Fetch the current user's password vault key from the server.
* Fetch the current user's password vault key from the server. Successful responses are reused for
* {@link VAULT_KEY_CACHE_TTL_MS} so the several callers a single sync cycle makes share one request; errors are
* never cached, so a transient failure does not stick.
* @param webApi - the API client to use (popup context passes its own instance; background creates one)
*/
public static async fetchVaultKey(webApi?: WebApiService): Promise<FetchVaultKeyResult> {
if (cachedVaultKey && Date.now() - cachedVaultKey.timestamp < VAULT_KEY_CACHE_TTL_MS) {
return cachedVaultKey.data;
}
const api = webApi ?? new WebApiService();
let result: FetchVaultKeyResult;
try {
const response = await api.get<VaultKeyGetResponse>(`VaultKey/${VAULT_KEY_TYPE_PASSWORD}`);
return { supported: true, vaultKey: response.vaultKey ?? null };
result = { supported: true, vaultKey: response.vaultKey ?? null };
} catch (e) {
if (e instanceof ApiRequestError && e.statusCode === 404) {
return { supported: false, vaultKey: null };
result = { supported: false, vaultKey: null };
} else if (e instanceof Error && e.message.includes('status: 404')) {
result = { supported: false, vaultKey: null };
} else {
throw e;
}
if (e instanceof Error && e.message.includes('status: 404')) {
return { supported: false, vaultKey: null };
}
throw e;
}
cachedVaultKey = { data: result, timestamp: Date.now() };
return result;
}
/**
@@ -113,6 +139,25 @@ export class VaultKeyService {
return VaultKeyService.unwrapOrThrow(wrappedVek, derivedKeyBase64);
}
/**
* Drop the in-memory vault key response cache. Called when the account is cleared (logout), so a subsequent login
* as a different user can never be answered from the previous user's response inside the TTL window.
*/
public static clearCache(): void {
cachedVaultKey = null;
}
/**
* Whether this device holds a vault key, i.e. whether the session encryption key is a VEK rather than the raw
* password-derived key. This is the local source of truth for "am I on the KEK/VEK model": the cache is written
* by {@link resolveEncryptionKey} on every login and cleared when the server reports no vault key, so it needs no
* server round-trip. A false answer is only ever stale in one direction (another device migrated since the last
* login), which the background sync resolves by adopting the remote key before any vault work happens.
*/
public static async hasLocalVaultKey(): Promise<boolean> {
return (await storage.getItem(WRAPPED_VEK_STORAGE_KEY) as string | null) !== null;
}
/**
* Refresh the local wrapped-VEK cache from the server without needing the KEK.
* @param webApi - the API client to use
@@ -348,8 +348,7 @@ export class WebApiService {
clientVersionSupported: true,
serverVersion: '0.0.0',
manifestRevisions: [],
srpSalt: '',
isMigrated: false
srpSalt: ''
};
}
}
@@ -84,8 +84,6 @@ type StatusResponseV2 = {
serverVersion: string;
manifestRevisions: ManifestRevision[];
srpSalt: string;
/** Whether the user has migrated to the manifest-v1 storage format (and the KEK/VEK key model). */
isMigrated: boolean;
};
/**
@@ -459,7 +459,7 @@ export async function createTestUser(apiBaseUrl: string): Promise<TestUser> {
*/
export async function isApiAvailable(apiBaseUrl: string): Promise<boolean> {
try {
const response = await fetch(`${apiBaseUrl.replace(/\/$/, '')}/v2/Auth/status`, {
const response = await fetch(`${apiBaseUrl.replace(/\/$/, '')}/v2/Status`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -84,8 +84,6 @@ type StatusResponseV2 = {
serverVersion: string;
manifestRevisions: ManifestRevision[];
srpSalt: string;
/** Whether the user has migrated to the manifest-v1 storage format (and the KEK/VEK key model). */
isMigrated: boolean;
};
/**
@@ -51,9 +51,8 @@ public class StatusController(IAliasServerDbContextFactory dbContextFactory, Use
await using var context = await dbContextFactory.CreateDbContextAsync();
// Manifest revisions and migration status are built via the shared helper.
// Manifest revisions (own manifests plus those shared with this user) are built via the shared helper.
var manifestRevisions = await VaultStatusHelper.GetManifestRevisionsAsync(context, user.Id);
var isMigrated = await VaultStatusHelper.IsUserMigratedAsync(context, user.Id);
// Latest revision per bucket kind.
var bucketRevisions = await context.VaultDataBuckets
@@ -81,7 +80,6 @@ public class StatusController(IAliasServerDbContextFactory dbContextFactory, Use
ClientVersionSupported = clientSupported,
ServerVersion = AppInfo.GetFullVersion(),
SrpSalt = encryptionSettings.Salt,
IsMigrated = isMigrated,
ManifestRevisions = manifestRevisions,
BucketRevisions = bucketRevisions,
});
@@ -275,21 +275,25 @@ public class VaultController(
var rootWrite = model.Manifests.FirstOrDefault(m => m.IsRoot);
// KEK/VEK migration (CreateVaultKey) is a full root push and only valid alongside a root manifest.
/*
* KEK/VEK migration: the wrapped VEK rides on the root manifest write it unlocks. A non-root write must never
* carry one. We reject rather than silently ignore, so a misdirected key can never be dropped unnoticed.
* TODO: these guards can be removed once all user has migrated to the KEK/VEK model and we don't support legacy users anymore.
*/
var hasExistingVaultKey = await context.VaultKeys.AnyAsync(x => x.UserId == user.Id && x.KeyType == AuthHelper.VaultKeyTypePassword);
if (model.CreateVaultKey != null && (rootWrite == null || model.CreateVaultKey.KeyType != AuthHelper.VaultKeyTypePassword))
var migrationWrappedVek = rootWrite?.WrappedVek;
if (model.Manifests.Any(m => !m.IsRoot && !string.IsNullOrEmpty(m.WrappedVek)))
{
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.VAULT_KEY_NOT_FOUND, 400));
}
if (model.CreateVaultKey != null && hasExistingVaultKey)
if (!string.IsNullOrEmpty(migrationWrappedVek) && hasExistingVaultKey)
{
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.VAULT_KEY_ALREADY_EXISTS, 400));
}
// A root push from a not-yet-migrated user must carry CreateVaultKey.
// TODO: remove this guard once every user has migrated to the KEK/VEK model (no keyless users remain).
if (rootWrite != null && model.CreateVaultKey == null && !hasExistingVaultKey)
// A root push from a not-yet-migrated user must carry the wrapped VEK to migrate the vault into the manifest-v1 format.
if (rootWrite != null && string.IsNullOrEmpty(migrationWrappedVek) && !hasExistingVaultKey)
{
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.VAULT_KEY_NOT_FOUND, 400));
}
@@ -364,7 +368,7 @@ public class VaultController(
// 1) Upsert any new blob objects.
if (model.NewBlobs.Count > 0)
{
if (!await TryUpsertBlobObjectsAsync(context, user.Id, model.NewBlobs, overwrite: model.CreateVaultKey != null))
if (!await TryUpsertBlobObjectsAsync(context, user.Id, model.NewBlobs, overwrite: !string.IsNullOrEmpty(migrationWrappedVek)))
{
await tx.RollbackAsync();
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.VAULT_NOT_UP_TO_DATE, 400));
@@ -432,7 +436,7 @@ public class VaultController(
// Create the VaultKey row atomically with this write on the KEK/VEK migration (first push after the
// client re-encrypted the vault under a fresh VEK). Move the SRP credentials off the manifest row.
if (model.CreateVaultKey != null)
if (!string.IsNullOrEmpty(migrationWrappedVek))
{
context.VaultKeys.Add(new VaultKey
{
@@ -441,7 +445,7 @@ public class VaultController(
VaultManifestId = row.ManifestId,
KeyType = AuthHelper.VaultKeyTypePassword,
WrapScheme = AuthHelper.WrapSchemeAesGcmKek,
WrappedVek = model.CreateVaultKey.WrappedVek,
WrappedVek = migrationWrappedVek,
Salt = row.Salt,
Verifier = row.Verifier,
EncryptionType = row.EncryptionType,
@@ -32,17 +32,6 @@ public static class VaultStatusHelper
return revisions;
}
/// <summary>
/// Whether the user has migrated to the manifest-v1 storage format.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="userId">The id of the user to check migration status for.</param>
/// <returns>True when the user's own root manifest is in the manifest-v1 format.</returns>
public static async Task<bool> IsUserMigratedAsync(AliasServerDbContext context, string userId)
{
return await context.VaultManifests.AnyAsync(x => x.OwnerUserId == userId && x.IsRoot && x.StorageFormat == ManifestFormat);
}
/// <summary>
/// The revision entries for every manifest owned by <paramref name="userId"/>, across all storage formats.
/// </summary>
@@ -34,13 +34,6 @@ public class StatusResponse
/// </summary>
public required string SrpSalt { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user has migrated to the manifest-v1 storage format (and thus the
/// KEK/VEK key model). False while still on the legacy sqlite-blob format.
/// TODO: remove once every user has migrated off the legacy sqlite-blob format.
/// </summary>
public required bool IsMigrated { get; set; }
/// <summary>
/// Gets or sets the latest revision for each logical manifest the user has access to.
/// </summary>
@@ -1,22 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="CreateVaultKeyRequest.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>
/// Vault key creation payload carried inside <see cref="VaultWriteRequest"/> for the KEK/VEK migration.
/// TODO: remove this class once the legacy model is fully deprecated and upgrade path is no longer needed.
/// </summary>
public class CreateVaultKeyRequest
{
/// <summary>Gets or sets the unlock method type. Only "password" is supported.</summary>
public required string KeyType { get; set; }
/// <summary>Gets or sets the wrapped VEK: base64(IV | ciphertext | authTag) of the newly generated VEK
/// encrypted with the KEK using AES-256-GCM.</summary>
public required string WrappedVek { get; set; }
}
@@ -33,4 +33,13 @@ public class ManifestWrite
/// <summary>Gets or sets the complete list of blob hashes this manifest revision references. The server validates
/// each exists (in the caller's store for the root manifest; in any member's store for a shared manifest) before committing.</summary>
public List<BlobReference> BlobReferences { get; set; } = [];
/// <summary>
/// Gets or sets the wrapped VEK of the vault encryption key encrypted with the password-derived KEK using AES-256-GCM.
/// Set on the legacy user's first manifest-v1 write, where the client re-encrypts the whole vault under a fresh VEK;
/// the server creates the password VaultKey for this manifest in the same transaction. Null on every subsequent write.
/// Only valid on the root write; a non-root write carrying it is rejected rather than silently ignored.
/// TODO: remove once the legacy sqlite-blob format is fully deprecated and we don't support legacy users anymore.
/// </summary>
public string? WrappedVek { get; set; } // base64(IV | ciphertext | authTag)
}
@@ -29,7 +29,4 @@ public class VaultWriteRequest
/// <summary>Gets or sets the public encryption key.</summary>
public string? EncryptionPublicKey { get; set; }
/// <summary>Gets or sets the vault key creation request.</summary>
public CreateVaultKeyRequest? CreateVaultKey { get; set; }
}
@@ -15,6 +15,4 @@ export type StatusResponseV2 = {
serverVersion: string;
manifestRevisions: ManifestRevision[];
srpSalt: string;
/** Whether the user has migrated to the manifest-v1 storage format (and the KEK/VEK key model). */
isMigrated: boolean;
}