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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-13 20:36:10 -05:00 committed by screentinker
parent 1f794ff7b4
commit e1cd8591bb
4 changed files with 94 additions and 0 deletions

View file

@ -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.

View file

@ -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),

View file

@ -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",

View file

@ -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",