feat(auth): non-destructive password resets and admin reset endpoint

Password resets no longer destroy user data: the DEK is system-wrapped, so
forgot-password and admin resets are just a hash update plus session revoke.
The wipe branch survives only for accounts that never logged in since the
encryption upgrade and now requires explicit confirmDataWipe (surfaced as a
409 DATA_WIPE_REQUIRED; the reset UI asks for confirmation). Adds
POST /users/admin/reset-password and removes the dead re-encryption paths.
This commit is contained in:
LukeGus
2026-07-16 00:36:14 -05:00
parent 1032a31e25
commit a2f6c90eae
8 changed files with 361 additions and 288 deletions
-7
View File
@@ -892,13 +892,6 @@ class AuthManager {
return this.userKeys.tryGetUserDEK(userId) !== null;
}
async resetUserPasswordWithPreservedDEK(
userId: string,
_newPassword?: string,
): Promise<boolean> {
return this.userKeys.tryGetUserDEK(userId) !== null;
}
async isTrustedDevice(
userId: string,
deviceFingerprint: string,
-175
View File
@@ -8,7 +8,6 @@ import {
RawSqliteUserEncryptionMigrationStore,
type LegacyDatabaseInstance,
type UserEncryptionMigrationStore,
type UserEncryptionMigrationRecord,
} from "./user-encryption-migration-store.js";
class DataCrypto {
@@ -238,180 +237,6 @@ class DataCrypto {
return this.userKeys.tryGetUserDEK(userId);
}
static async reencryptUserDataAfterPasswordReset(
userId: string,
newUserDataKey: Buffer,
db: LegacyDatabaseInstance,
): Promise<{
success: boolean;
reencryptedTables: string[];
reencryptedFieldsCount: number;
errors: string[];
}> {
const result = {
success: false,
reencryptedTables: [] as string[],
reencryptedFieldsCount: 0,
errors: [] as string[],
};
try {
const store = new RawSqliteUserEncryptionMigrationStore(db);
type PasswordResetTable = Parameters<
UserEncryptionMigrationStore["updatePasswordResetFields"]
>[0];
const tablesToReencrypt: Array<{
table: PasswordResetTable;
fields: string[];
}> = [
{
table: "ssh_data",
fields: [
"password",
"key",
"key_password",
"sudo_password",
"autostart_password",
"autostart_key",
"autostart_key_password",
],
},
{
table: "ssh_credentials",
fields: [
"password",
"key",
"private_key",
"public_key",
"key_password",
],
},
{
table: "users",
fields: [
"client_secret",
"totp_secret",
"totp_backup_codes",
"oidc_identifier",
],
},
];
for (const { table, fields } of tablesToReencrypt) {
try {
const records =
table === "ssh_data"
? store.listHostRecords(userId)
: table === "ssh_credentials"
? store.listCredentialRecords(userId)
: [store.getUserRecord(userId)].filter(
(record): record is UserEncryptionMigrationRecord =>
Boolean(record),
);
for (const record of records) {
const recordId = record.id.toString();
const updatedRecord: UserEncryptionMigrationRecord = { ...record };
let needsUpdate = false;
for (const fieldName of fields) {
const fieldValue = record[fieldName];
if (
fieldValue &&
typeof fieldValue === "string" &&
fieldValue.trim() !== ""
) {
try {
const reencryptedValue = FieldCrypto.encryptField(
fieldValue,
newUserDataKey,
recordId,
fieldName,
);
updatedRecord[fieldName] = reencryptedValue;
needsUpdate = true;
result.reencryptedFieldsCount++;
} catch (error) {
const errorMsg = `Failed to re-encrypt ${fieldName} for ${table} record ${recordId}: ${error instanceof Error ? error.message : "Unknown error"}`;
result.errors.push(errorMsg);
databaseLogger.warn(
"Field re-encryption failed during password reset",
{
operation: "password_reset_reencrypt_failed",
userId,
table,
recordId,
fieldName,
error:
error instanceof Error
? error.message
: "Unknown error",
},
);
}
}
}
if (needsUpdate) {
const updateFields = fields.filter(
(field) => updatedRecord[field] !== record[field],
);
if (updateFields.length > 0) {
store.updatePasswordResetFields(
table,
record.id,
updateFields,
updatedRecord,
);
if (!result.reencryptedTables.includes(table)) {
result.reencryptedTables.push(table);
}
}
}
}
} catch (tableError) {
const errorMsg = `Failed to re-encrypt table ${table}: ${tableError instanceof Error ? tableError.message : "Unknown error"}`;
result.errors.push(errorMsg);
databaseLogger.error(
"Table re-encryption failed during password reset",
tableError,
{
operation: "password_reset_table_reencrypt_failed",
userId,
table,
error:
tableError instanceof Error
? tableError.message
: "Unknown error",
},
);
}
}
result.success = result.errors.length === 0;
return result;
} catch (error) {
databaseLogger.error(
"User data re-encryption failed after password reset",
error,
{
operation: "password_reset_reencrypt_failed",
userId,
error: error instanceof Error ? error.message : "Unknown error",
},
);
result.errors.push(
`Critical error during re-encryption: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return result;
}
}
static validateUserAccess(userId: string): Buffer {
return this.userKeys.getUserDEK(userId);
}