mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user