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

@@ -74,24 +74,19 @@ describe("parseLegacyIdentifier", () => {
});
describe("runExternalIdentityMigration", () => {
it("moves every identifier and TOTP enrolment without touching the old data", async () => {
it("moves every identifier without touching the old data", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 });
user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 });
user("oidc-user", {
oidcIdentifier: "sub-123",
ssoProviderId: 3,
totpEnabled: true,
});
user("env-user", { oidcIdentifier: "someone@example.com" });
user("local", {
isOidc: false,
passwordHash: "hash",
totpEnabled: true,
});
user("local", { isOidc: false, passwordHash: "hash" });
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 4, factors: 2, skipped: 0 });
expect(result).toEqual({ identities: 4, skipped: 0 });
expect(
h.state.identities.map(({ userId, providerId, subject }) => ({
userId,
@@ -108,21 +103,15 @@ describe("runExternalIdentityMigration", () => {
subject: "someone@example.com",
},
]);
expect(h.state.factors).toEqual([
{ userId: "oidc-user", pluginId: "core", factorId: "totp" },
{ userId: "local", pluginId: "core", factorId: "totp" },
]);
// Lossless: the old column and flag stay.
// Lossless: the old column stays.
expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob");
expect(h.state.users.get("local")!.totpEnabled).toBe(true);
});
it("is idempotent", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob", totpEnabled: true });
user("ldap-user", { oidcIdentifier: "ldap:4:bob" });
await runExternalIdentityMigration();
const second = await runExternalIdentityMigration();
expect(second).toEqual({ identities: 0, factors: 0, skipped: 2 });
expect(second).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
expect(h.state.factors).toHaveLength(1);
});
});
@@ -0,0 +1,196 @@
/**
* The 2.8 to 2.9 TOTP move. A user who had TOTP on must still be asked for it
* after upgrading: their enrolment row points at the totp plugin, and their
* secret and backup codes reach the plugin's table readable. Running it twice
* changes nothing, and without the plugin's table the enrolment still moves
* so the login fails closed.
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FieldCrypto } from "../../utils/field-crypto.js";
const h = vi.hoisted(() => ({
db: null as unknown,
deks: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runTotpMigration } =
await import("../../utils/crypto-migration/totp-migration.js");
let sqlite: Database.Database;
function setup({ pluginTable }: { pluginTable: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_backup_codes TEXT
);
CREATE TABLE user_second_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
factor_id TEXT NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, plugin_id, factor_id)
);
`);
if (pluginTable) {
sqlite.exec(`
CREATE TABLE p_totp_enrollments (
user_id TEXT PRIMARY KEY NOT NULL,
secret TEXT,
pending_secret TEXT,
backup_codes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
}
h.db = drizzle(sqlite);
}
function addTotpUser(id: string, secret: string, codes: string[]) {
const dek = crypto.randomBytes(32);
h.deks.set(id, dek);
sqlite
.prepare(
"INSERT INTO users (id, username, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, 1, ?)",
)
.run(
id,
id,
FieldCrypto.encryptField(secret, dek, id, "totpSecret"),
FieldCrypto.encryptField(
JSON.stringify(codes),
dek,
id,
"totpBackupCodes",
),
);
}
const factors = () =>
sqlite
.prepare(
"SELECT user_id, plugin_id, factor_id FROM user_second_factors ORDER BY user_id",
)
.all();
beforeEach(() => {
h.deks.clear();
});
describe("runTotpMigration", () => {
it("keeps a 2.8 TOTP user behind their factor, with a readable secret", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111", "BBBB2222"]);
addTotpUser("bob", "KRSXG5CTMVRXEZLU", []);
sqlite.exec(`INSERT INTO users (id, username) VALUES ('carol', 'carol')`);
// A8 recorded alice under core; bob only has the flag.
sqlite.exec(
`INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('alice', 'core', 'totp')`,
);
expect(await runTotpMigration()).toEqual({ factors: 2, secrets: 2 });
expect(factors()).toEqual([
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
{ user_id: "bob", plugin_id: "totp", factor_id: "totp" },
]);
const alice = sqlite
.prepare("SELECT * FROM p_totp_enrollments WHERE user_id = 'alice'")
.get() as Record<string, string>;
expect(alice.secret).toBe("sysenc:JBSWY3DPEHPK3PXP");
expect(alice.backup_codes).toBe(
`sysenc:${JSON.stringify(["AAAA1111", "BBBB2222"])}`,
);
expect(alice.pending_secret).toBeNull();
expect(
sqlite
.prepare(
"SELECT totp_enabled, totp_secret, totp_backup_codes FROM users WHERE id = 'alice'",
)
.get(),
).toEqual({ totp_enabled: 0, totp_secret: null, totp_backup_codes: null });
// A second boot changes nothing.
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
expect(factors()).toHaveLength(2);
expect(
sqlite.prepare("SELECT COUNT(*) AS n FROM p_totp_enrollments").get(),
).toEqual({ n: 2 });
});
it("moves the enrolment but keeps the columns while the plugin table is missing", async () => {
setup({ pluginTable: false });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111"]);
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
expect(factors()).toEqual([
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
]);
const row = sqlite
.prepare("SELECT totp_enabled, totp_secret FROM users WHERE id = 'alice'")
.get() as { totp_enabled: number; totp_secret: string };
expect(row.totp_enabled).toBe(1);
expect(row.totp_secret).toBeTruthy();
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
});
it("skips a user whose data key is unavailable and retries later", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", []);
const dek = h.deks.get("alice")!;
h.deks.delete("alice");
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
h.deks.set("alice", dek);
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 1 });
});
it("drops a duplicate core row when the plugin row already exists", async () => {
setup({ pluginTable: true });
sqlite.exec(`
INSERT INTO users (id, username) VALUES ('dave', 'dave');
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'core', 'totp');
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'totp', 'totp');
`);
await runTotpMigration();
expect(factors()).toEqual([
{ user_id: "dave", plugin_id: "totp", factor_id: "totp" },
]);
});
it("does nothing on a fresh install without the legacy columns", async () => {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL);
CREATE TABLE user_second_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL,
plugin_id TEXT NOT NULL, factor_id TEXT NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
h.db = drizzle(sqlite);
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
});
});
@@ -12,12 +12,11 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
}
it("owns legacy user encryption migration reads", () => {
const { db, all, get } = createDb();
const { db, all } = createDb();
const store = new RawSqliteUserEncryptionMigrationStore(db);
expect(store.listHostRecords("user-1")).toEqual([{ id: 1 }]);
expect(store.listCredentialRecords("user-1")).toEqual([{ id: 1 }]);
expect(store.getUserRecord("user-1")).toEqual({ id: "user-1" });
expect(db.prepare).toHaveBeenCalledWith(
"SELECT * FROM ssh_data WHERE user_id = ?",
@@ -25,9 +24,7 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
expect(db.prepare).toHaveBeenCalledWith(
"SELECT * FROM ssh_credentials WHERE user_id = ?",
);
expect(db.prepare).toHaveBeenCalledWith("SELECT * FROM users WHERE id = ?");
expect(all).toHaveBeenCalledWith("user-1");
expect(get).toHaveBeenCalledWith("user-1");
});
it("owns legacy sensitive field update statements", () => {
@@ -52,12 +49,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
public_key: "pub",
key_type: "rsa",
});
store.updateUserSensitiveFields("user-1", {
totp_secret: "totp",
totp_backup_codes: "codes",
client_secret: "client",
oidc_identifier: "oidc",
});
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE ssh_data"),
@@ -65,9 +56,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE ssh_credentials"),
);
expect(db.prepare).toHaveBeenCalledWith(
expect.stringContaining("UPDATE users"),
);
expect(run).toHaveBeenCalledWith(
"p",
"k",
@@ -80,13 +68,6 @@ describe("RawSqliteUserEncryptionMigrationStore", () => {
12,
);
expect(run).toHaveBeenCalledWith("p", "k", "kp", "priv", "pub", "rsa", 13);
expect(run).toHaveBeenCalledWith(
"totp",
"codes",
"client",
"oidc",
"user-1",
);
});
it("owns password-reset dynamic field updates", () => {