Update first manifest-v1 migration flow (#2159)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user