feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

+5 -1
View File
@@ -168,7 +168,11 @@ class AuthManager {
return this.ensureUserDEK(userId);
}
async authenticateWebAuthnUser(
/**
* Opens the user's data key with the server-held wrapping, for a sign-in
* that carries no password (a passkey, trusted proxy login).
*/
async unlockWithSystemKey(
userId: string,
_deviceType?: DeviceType,
): Promise<boolean> {
@@ -1,15 +1,13 @@
/**
* Moves sign-in identities and 2FA enrolment into their own tables.
* Moves sign-in identities into their own table.
*
* users.oidc_identifier packed provider and subject into one string:
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
* whose provider was users.sso_provider_id. Each becomes a
* user_external_identities row. users.totp_enabled becomes a
* user_second_factors row for core's TOTP factor, so a login can tell a user
* has a factor even after the plugin that runs it is gone.
* user_external_identities row. TOTP enrolment moves in totp-migration.ts.
*
* Lossless: the old column and flag are left as they are. Idempotent: rows
* that already exist are skipped, so it runs on every boot.
* Lossless: the old column is left as it is. Idempotent: rows that already
* exist are skipped, so it runs on every boot.
*/
import { databaseLogger } from "../logger.js";
@@ -23,7 +21,6 @@ const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
export interface ExternalIdentityMigrationResult {
identities: number;
factors: number;
skipped: number;
}
@@ -58,7 +55,6 @@ export function parseLegacyIdentifier(
export async function runExternalIdentityMigration(): Promise<ExternalIdentityMigrationResult> {
const result: ExternalIdentityMigrationResult = {
identities: 0,
factors: 0,
skipped: 0,
};
@@ -89,24 +85,10 @@ export async function runExternalIdentityMigration(): Promise<ExternalIdentityMi
}
}
}
if (user.totpEnabled) {
const enrolled = await auth.listSecondFactors(user.id);
if (
enrolled.some(
(row) => row.pluginId === "core" && row.factorId === "totp",
)
) {
result.skipped++;
} else {
await auth.recordSecondFactor(user.id, "core", "totp");
result.factors++;
}
}
}
if (result.identities > 0 || result.factors > 0) {
databaseLogger.info("Moved sign-in identities and 2FA enrolment", {
if (result.identities > 0) {
databaseLogger.info("Moved sign-in identities", {
operation: "external_identity_migration",
...result,
});
@@ -0,0 +1,168 @@
/**
* Moves 2.8 TOTP enrolments into the totp plugin.
*
* 1. Every user_second_factors row A8 wrote for core's TOTP ("core"/"totp")
* becomes the plugin's ("totp"/"totp"), and every user with
* users.totp_enabled gets one. This runs whether or not the plugin is
* enabled, so those users stay behind a second factor: with the plugin off
* the login fails closed instead of skipping it.
* 2. Once the plugin's p_totp_enrollments table exists, each enabled user's
* secret and backup codes are decrypted with their data key and written
* there sealed with the installation key (what ctx.secrets.seal writes),
* then the users columns are cleared.
*
* The columns stay in the database until 3.0.0, since core's drizzle
* migrations run before this does. A user whose data key cannot be opened
* at boot is skipped and tried again on the next one. Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { DataCrypto } from "../data-crypto.js";
import { LazyFieldEncryption } from "../lazy-field-encryption.js";
import { encryptSystemSecret } from "../system-secret-crypto.js";
import { runStatement, selectRows } from "./raw-rows.js";
const PLUGIN_ID = "totp";
const FACTOR_ID = "totp";
const ENROLLMENTS_TABLE = "p_totp_enrollments";
export interface TotpMigrationResult {
/** user_second_factors rows written or moved to the plugin. */
factors: number;
/** Secrets moved into the plugin's table. */
secrets: number;
}
interface LegacyTotpUser {
id: string;
totp_enabled: unknown;
totp_secret: string | null;
totp_backup_codes: string | null;
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[] | null> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the column or the table.
return null;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
function readBackupCodes(
stored: string | null,
dek: Buffer,
userId: string,
): string[] {
if (!stored) return [];
const raw = LazyFieldEncryption.safeGetFieldValue(
stored,
dek,
userId,
"totpBackupCodes",
);
try {
const codes = raw ? JSON.parse(raw) : [];
return Array.isArray(codes)
? codes.filter((code): code is string => typeof code === "string")
: [];
} catch {
return [];
}
}
async function factorUsers(pluginId: string): Promise<Set<string>> {
const rows = await selectRows<{ user_id: string }>(
sql`SELECT user_id FROM user_second_factors WHERE plugin_id = ${pluginId} AND factor_id = ${FACTOR_ID}`,
);
return new Set(rows.map((row) => row.user_id));
}
export async function runTotpMigration(): Promise<TotpMigrationResult> {
const result: TotpMigrationResult = { factors: 0, secrets: 0 };
try {
const enrolled = await factorUsers(PLUGIN_ID);
for (const userId of await factorUsers("core")) {
if (enrolled.has(userId)) {
await runStatement(
sql`DELETE FROM user_second_factors WHERE user_id = ${userId} AND plugin_id = 'core' AND factor_id = ${FACTOR_ID}`,
);
} else {
await runStatement(
sql`UPDATE user_second_factors SET plugin_id = ${PLUGIN_ID} WHERE user_id = ${userId} AND plugin_id = 'core' AND factor_id = ${FACTOR_ID}`,
);
enrolled.add(userId);
result.factors++;
}
}
const users = (
(await tryRows<LegacyTotpUser>(
sql`SELECT id, totp_enabled, totp_secret, totp_backup_codes FROM users`,
)) ?? []
).filter((user) => isOn(user.totp_enabled));
for (const user of users) {
if (enrolled.has(user.id)) continue;
await runStatement(
sql`INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES (${user.id}, ${PLUGIN_ID}, ${FACTOR_ID})`,
);
enrolled.add(user.id);
result.factors++;
}
const moved = await tryRows<{ user_id: string }>(
sql`SELECT user_id FROM ${sql.identifier(ENROLLMENTS_TABLE)}`,
);
if (moved) {
const done = new Set(moved.map((row) => row.user_id));
for (const user of users) {
if (done.has(user.id) || !user.totp_secret) continue;
try {
const dek = DataCrypto.getUserDataKey(user.id);
if (!dek) continue;
const secret = LazyFieldEncryption.safeGetFieldValue(
user.totp_secret,
dek,
user.id,
"totpSecret",
);
if (!secret) continue;
const codes = readBackupCodes(user.totp_backup_codes, dek, user.id);
await runStatement(
sql`INSERT INTO ${sql.identifier(ENROLLMENTS_TABLE)} (user_id, secret, backup_codes) VALUES (${user.id}, ${await encryptSystemSecret(secret)}, ${await encryptSystemSecret(JSON.stringify(codes))})`,
);
await runStatement(
sql`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_backup_codes = NULL WHERE id = ${user.id}`,
);
result.secrets++;
} catch (error) {
databaseLogger.warn("TOTP migration failed for a user", {
operation: "totp_migration",
userId: user.id,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
} catch (error) {
databaseLogger.error("TOTP migration failed", error, {
operation: "totp_migration",
});
}
if (result.factors > 0 || result.secrets > 0) {
databaseLogger.info("Moved TOTP enrolment into the totp plugin", {
operation: "totp_migration",
...result,
});
}
return result;
}
-23
View File
@@ -180,29 +180,6 @@ class DataCrypto {
}
}
const userRecord = store.getUserRecord(userId);
if (userRecord) {
const sensitiveFields =
LazyFieldEncryption.getSensitiveFieldsForTable("users");
const { updatedRecord, migratedFields, needsUpdate } =
LazyFieldEncryption.migrateRecordSensitiveFields(
userRecord,
sensitiveFields,
userDataKey,
userId,
);
if (needsUpdate) {
store.updateUserSensitiveFields(userId, updatedRecord);
migratedFieldsCount += migratedFields.length;
if (!migratedTables.includes("users")) {
migratedTables.push("users");
}
migrated = true;
}
}
return { migrated, migratedTables, migratedFieldsCount };
} catch (error) {
databaseLogger.error("User sensitive fields migration failed", error, {
+1 -7
View File
@@ -15,13 +15,7 @@ class FieldCrypto {
private static readonly SALT_LENGTH = 32;
private static readonly ENCRYPTED_FIELDS = {
users: new Set([
"passwordHash",
"clientSecret",
"totpSecret",
"totpBackupCodes",
"oidcIdentifier",
]),
users: new Set(["passwordHash", "clientSecret", "oidcIdentifier"]),
ssh_data: new Set([
"password",
"key",
@@ -286,8 +286,6 @@ export class LazyFieldEncryption {
rdpPassword: "rdp_password",
vncPassword: "vnc_password",
telnetPassword: "telnet_password",
totpSecret: "totp_secret",
totpBackupCodes: "totp_backup_codes",
clientSecret: "client_secret",
oidcIdentifier: "oidc_identifier",
};
@@ -314,7 +312,6 @@ export class LazyFieldEncryption {
"privateKey",
"publicKey",
],
users: ["totpSecret", "totpBackupCodes"],
};
return sensitiveFieldsMap[tableName] || [];
@@ -439,33 +436,6 @@ export class LazyFieldEncryption {
}
}
const user = store.getUserRecord(userId);
if (user) {
const sensitiveFields = this.getSensitiveFieldsForTable("users");
const userPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
const column = this.propertyToColumn(field);
const value = user[column];
if (
typeof value === "string" &&
value &&
this.fieldNeedsMigration(value, userKEK, userId, field)
) {
userPlaintextFields.push(field);
needsMigration = true;
}
}
if (userPlaintextFields.length > 0) {
plaintextFields.push({
table: "users",
recordId: userId,
fields: userPlaintextFields,
});
}
}
return { needsMigration, plaintextFields };
} catch (error) {
databaseLogger.error("Failed to check user migration needs", error, {
-4
View File
@@ -167,10 +167,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
name: "SSO",
description: "Single sign-on provider configuration",
},
{
name: "WebAuthn",
description: "Passkey registration and authentication",
},
{
name: "Vault",
description: "HashiCorp Vault SSH signing profiles",
@@ -8,7 +8,6 @@ export interface UserEncryptionMigrationRecord {
export interface UserEncryptionMigrationStore {
listHostRecords(userId: string): UserEncryptionMigrationRecord[];
listCredentialRecords(userId: string): UserEncryptionMigrationRecord[];
getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined;
updateHostSensitiveFields(
recordId: number | string,
record: Record<string, unknown>,
@@ -17,10 +16,6 @@ export interface UserEncryptionMigrationStore {
recordId: number | string,
record: Record<string, unknown>,
): void;
updateUserSensitiveFields(
userId: string,
record: Record<string, unknown>,
): void;
updatePasswordResetFields(
table: "ssh_data" | "ssh_credentials" | "users",
recordId: number | string,
@@ -52,10 +47,6 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
.all(userId);
}
getUserRecord(userId: string): UserEncryptionMigrationRecord | undefined {
return this.db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
}
updateHostSensitiveFields(
recordId: number | string,
record: Record<string, unknown>,
@@ -104,27 +95,6 @@ export class RawSqliteUserEncryptionMigrationStore implements UserEncryptionMigr
);
}
updateUserSensitiveFields(
userId: string,
record: Record<string, unknown>,
): void {
this.db
.prepare(
`
UPDATE users
SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ?
WHERE id = ?
`,
)
.run(
record.totp_secret || null,
record.totp_backup_codes || null,
record.client_secret || null,
record.oidc_identifier || null,
userId,
);
}
updatePasswordResetFields(
table: "ssh_data" | "ssh_credentials" | "users",
recordId: number | string,