mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move totp and passkeys into plugins (C1)
This commit is contained in:
1 parent
f4f26a9b8b
commit
5c18435105
235 files changed
+22545
-5610
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user