From e1cd8591bbc3d14749647da018717a07281dc0a5 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 13 Jun 2026 20:36:10 -0500 Subject: [PATCH] chore(server): TOTP schema + otplib dep (#100) users.totp_secret_enc (secretbox-encrypted, reversible) + totp_enabled + totp_last_step (replay guard), and the totp_recovery_codes table (SHA-256 hashed, single-use). Migrations default everything off so existing accounts are untouched. otplib pinned ^12 (v13 is a breaking plugin-rewrite with no authenticator/checkDelta). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/db/database.js | 6 ++++ server/db/schema.sql | 17 ++++++++++ server/package-lock.json | 70 ++++++++++++++++++++++++++++++++++++++++ server/package.json | 1 + 4 files changed, 94 insertions(+) diff --git a/server/db/database.js b/server/db/database.js index c052f41..192dc5f 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -187,6 +187,12 @@ const migrations = [ "ALTER TABLE ai_settings ADD COLUMN image_provider TEXT", // #41: optional separate key for the image endpoint (for local-LLM + cloud-image setups). "ALTER TABLE ai_settings ADD COLUMN image_api_key_enc TEXT", + // #100: TOTP MFA. Columns default to "off" so every existing account is unaffected. + "ALTER TABLE users ADD COLUMN totp_secret_enc TEXT", + "ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0", + "CREATE TABLE IF NOT EXISTS totp_recovery_codes (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, code_hash TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), used_at INTEGER)", + "CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id)", ]; // Apply each ALTER idempotently. A "duplicate column name" / "already exists" // error means the column is already present (expected on a migrated DB) - benign. diff --git a/server/db/schema.sql b/server/db/schema.sql index 36bfd22..de5d2f5 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -37,10 +37,27 @@ CREATE TABLE IF NOT EXISTS users ( stripe_subscription_id TEXT, subscription_status TEXT DEFAULT 'active', subscription_ends INTEGER, + -- #100: TOTP MFA (opt-in, local accounts only). totp_secret_enc is secretbox- + -- encrypted (REVERSIBLE - the server recomputes codes). totp_last_step blocks + -- intra-window replay (a code from an already-consumed 30s step is rejected). + totp_secret_enc TEXT, + totp_enabled INTEGER NOT NULL DEFAULT 0, + totp_last_step INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); +-- #100: single-use TOTP recovery codes. SHA-256 hashed (same discipline as +-- api_tokens.token_hash); plaintext shown once at enrollment. used_at NULL = available. +CREATE TABLE IF NOT EXISTS totp_recovery_codes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + used_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_totp_recovery_user ON totp_recovery_codes(user_id); + CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, user_id TEXT REFERENCES users(id), diff --git a/server/package-lock.json b/server/package-lock.json index 565a10e..d0a711a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -19,6 +19,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", + "otplib": "^12.0.1", "sharp": "^0.33.2", "socket.io": "^4.7.2", "stripe": "^20.4.1", @@ -440,6 +441,56 @@ "node": ">=12" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2632,6 +2683,17 @@ "wrappy": "1" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3502,6 +3564,14 @@ "b4a": "^1.6.4" } }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/server/package.json b/server/package.json index e28f6cf..e919ad4 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,7 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^1.4.5-lts.1", + "otplib": "^12.0.1", "sharp": "^0.33.2", "socket.io": "^4.7.2", "stripe": "^20.4.1",