diff --git a/package-lock.json b/package-lock.json
index 7f68bf2..a9ecd6b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,86 @@
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"jsonwebtoken": "^9.0.3",
- "multer": "^2.1.1"
+ "multer": "^2.1.1",
+ "otplib": "^13.4.0",
+ "qrcode": "^1.5.4"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@otplib/core": {
+ "version": "13.4.0",
+ "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz",
+ "integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==",
+ "license": "MIT"
+ },
+ "node_modules/@otplib/hotp": {
+ "version": "13.4.0",
+ "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz",
+ "integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.0",
+ "@otplib/uri": "13.4.0"
+ }
+ },
+ "node_modules/@otplib/plugin-base32-scure": {
+ "version": "13.4.0",
+ "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz",
+ "integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.0",
+ "@scure/base": "^2.0.0"
+ }
+ },
+ "node_modules/@otplib/plugin-crypto-noble": {
+ "version": "13.4.0",
+ "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz",
+ "integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "^2.0.1",
+ "@otplib/core": "13.4.0"
+ }
+ },
+ "node_modules/@otplib/totp": {
+ "version": "13.4.0",
+ "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz",
+ "integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.0",
+ "@otplib/hotp": "13.4.0",
+ "@otplib/uri": "13.4.0"
+ }
+ },
+ "node_modules/@otplib/uri": {
+ "version": "13.4.0",
+ "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz",
+ "integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.0"
+ }
+ },
+ "node_modules/@scure/base": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
+ "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
}
},
"node_modules/accepts": {
@@ -31,6 +110,30 @@
"node": ">= 0.6"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -209,12 +312,50 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -297,6 +438,15 @@
}
}
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -339,6 +489,12 @@
"node": ">=8"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -368,6 +524,12 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -537,6 +699,19 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -570,6 +745,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -735,6 +919,15 @@
"node": ">= 0.10"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -784,6 +977,18 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1036,6 +1241,56 @@
"wrappy": "1"
}
},
+ "node_modules/otplib": {
+ "version": "13.4.0",
+ "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz",
+ "integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==",
+ "license": "MIT",
+ "dependencies": {
+ "@otplib/core": "13.4.0",
+ "@otplib/hotp": "13.4.0",
+ "@otplib/plugin-base32-scure": "13.4.0",
+ "@otplib/plugin-crypto-noble": "13.4.0",
+ "@otplib/totp": "13.4.0",
+ "@otplib/uri": "13.4.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1045,6 +1300,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -1055,6 +1319,15 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1105,6 +1378,23 @@
"once": "^1.3.1"
}
},
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
@@ -1173,6 +1463,21 @@
"node": ">= 6"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -1272,6 +1577,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1421,6 +1732,32 @@
"safe-buffer": "~5.2.0"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -1523,11 +1860,72 @@
"node": ">= 0.8"
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
}
}
}
diff --git a/package.json b/package.json
index 6de29c9..91f7d31 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,8 @@
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"jsonwebtoken": "^9.0.3",
- "multer": "^2.1.1"
+ "multer": "^2.1.1",
+ "otplib": "^13.4.0",
+ "qrcode": "^1.5.4"
}
}
diff --git a/public/e2ee.js b/public/e2ee.js
new file mode 100644
index 0000000..ebd292e
--- /dev/null
+++ b/public/e2ee.js
@@ -0,0 +1,197 @@
+'use strict';
+// E2EE module — Signal-style hybrid encryption for group chat.
+// Uses Web Crypto API only. Private keys never leave this device.
+const E2EE = (() => {
+ const GROUP_ID = 'info1';
+ const IDB_NAME = 'ifb_e2ee_v1';
+ const IDB_STORE = 'k';
+ const sub = crypto.subtle;
+ const te = new TextEncoder();
+ const td = new TextDecoder();
+ const EC = { name: 'ECDH', namedCurve: 'P-256' };
+
+ // ── IndexedDB ─────────────────────────────────────────────────
+ function openIDB() {
+ return new Promise((res, rej) => {
+ const r = indexedDB.open(IDB_NAME, 1);
+ r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
+ r.onsuccess = e => res(e.target.result);
+ r.onerror = () => rej(r.error);
+ });
+ }
+ async function idbGet(key) {
+ const db = await openIDB();
+ return new Promise((res, rej) => {
+ const t = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(key);
+ t.onsuccess = () => res(t.result);
+ t.onerror = () => rej(t.error);
+ });
+ }
+ async function idbSet(key, val) {
+ const db = await openIDB();
+ return new Promise((res, rej) => {
+ const t = db.transaction(IDB_STORE, 'readwrite').objectStore(IDB_STORE).put(val, key);
+ t.onsuccess = () => res();
+ t.onerror = () => rej(t.error);
+ });
+ }
+
+ // ── REST helper ───────────────────────────────────────────────
+ async function rpc(method, path, body) {
+ try {
+ const r = await fetch('/api/' + path, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined
+ });
+ return r.json();
+ } catch (e) {
+ console.error('[E2EE] rpc', method, path, e);
+ return null;
+ }
+ }
+
+ // ── Binary helpers ────────────────────────────────────────────
+ function toB64(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))); }
+ function fromB64(str) { return Uint8Array.from(atob(str), c => c.charCodeAt(0)); }
+ function randomId() {
+ try { return crypto.randomUUID(); }
+ catch { return toB64(crypto.getRandomValues(new Uint8Array(18))); }
+ }
+
+ // ── Key generation & import ───────────────────────────────────
+ const genECDH = () => sub.generateKey(EC, true, ['deriveKey', 'deriveBits']);
+ const importECPub = jwk => sub.importKey('jwk', jwk, EC, true, []);
+ const importECPriv = jwk => sub.importKey('jwk', jwk, EC, true, ['deriveKey', 'deriveBits']);
+ const importAES = raw => sub.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+ const genAES = () => sub.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
+ const exportRaw = key => sub.exportKey('raw', key);
+
+ // ECDH(myPriv, theirPub) → HKDF → AES-256-GCM wrap key
+ async function deriveWrapKey(myPriv, theirPub) {
+ const bits = await sub.deriveBits({ name: 'ECDH', public: theirPub }, myPriv, 256);
+ const hk = await sub.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
+ return sub.deriveKey(
+ { name: 'HKDF', hash: 'SHA-256', salt: te.encode('ifb-e2ee-v1'), info: te.encode(GROUP_ID) },
+ hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
+ );
+ }
+
+ // ── AES-GCM encrypt/decrypt ───────────────────────────────────
+ async function aesEncrypt(key, data) {
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const ct = await sub.encrypt({ name: 'AES-GCM', iv }, key, data);
+ return { iv: toB64(iv), ct: toB64(ct) };
+ }
+ async function aesDecrypt(key, ivB64, ctB64) {
+ return sub.decrypt({ name: 'AES-GCM', iv: fromB64(ivB64) }, key, fromB64(ctB64));
+ }
+
+ // ── Sender key wrap/unwrap ────────────────────────────────────
+ async function wrapSenderKey(rawKey, myPriv, theirPub) {
+ const wk = await deriveWrapKey(myPriv, theirPub);
+ const { iv, ct } = await aesEncrypt(wk, rawKey);
+ return JSON.stringify({ iv, ct });
+ }
+ async function unwrapSenderKey(encJson, myPriv, distPub) {
+ const { iv, ct } = JSON.parse(encJson);
+ const wk = await deriveWrapKey(myPriv, distPub);
+ return aesDecrypt(wk, iv, ct);
+ }
+
+ // ── State ─────────────────────────────────────────────────────
+ let myKeyPair = null; // { priv: CryptoKey, pub: CryptoKey }
+ let myUserId = null;
+ let activeSK = null; // { key: CryptoKey, kid: string }
+ let kidCache = {}; // kid → CryptoKey
+
+ // ── Core logic ────────────────────────────────────────────────
+ async function init(userId) {
+ myUserId = userId;
+ try {
+ // Load or generate P-256 identity key pair in IndexedDB
+ let privJwk = await idbGet('priv_' + userId);
+ let pubJwk = await idbGet('pub_' + userId);
+ if (!privJwk || !pubJwk) {
+ const kp = await genECDH();
+ privJwk = await sub.exportKey('jwk', kp.privateKey);
+ pubJwk = await sub.exportKey('jwk', kp.publicKey);
+ await idbSet('priv_' + userId, privJwk);
+ await idbSet('pub_' + userId, pubJwk);
+ }
+ myKeyPair = { priv: await importECPriv(privJwk), pub: await importECPub(pubJwk) };
+
+ // Publish public key (server upserts, idempotent)
+ const pubResp = await rpc('POST', 'e2ee/public-key', { public_key_jwk: JSON.stringify(pubJwk) });
+ if (!pubResp || pubResp.error) console.warn('[E2EE] public-key publish failed', pubResp);
+
+ // Fresh sender key per session → forward secrecy
+ await rotateSenderKey();
+ } catch (e) {
+ console.error('[E2EE] init failed', e);
+ throw e;
+ }
+ }
+
+ async function rotateSenderKey() {
+ const kid = randomId();
+ const senderKey = await genAES();
+ const raw = await exportRaw(senderKey);
+
+ // Distribute encrypted copy to every member who has a public key
+ const usersResp = await rpc('GET', 'e2ee/users');
+ const users = Array.isArray(usersResp) ? usersResp : [];
+
+ const dists = [];
+ for (const u of users) {
+ try {
+ const theirPub = await importECPub(JSON.parse(u.public_key_jwk));
+ dists.push({ user_id: u.id, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, theirPub) });
+ } catch {}
+ }
+ // Always include self (may not be in users list yet)
+ if (!dists.some(d => d.user_id === myUserId)) {
+ dists.push({ user_id: myUserId, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, myKeyPair.pub) });
+ }
+
+ await rpc('POST', 'e2ee/group-keys', { group_id: GROUP_ID, kid, keys: dists });
+ activeSK = { key: senderKey, kid };
+ kidCache[kid] = senderKey;
+ }
+
+ async function resolveKid(kid) {
+ if (kidCache[kid]) return kidCache[kid];
+ const row = await rpc('GET', `e2ee/group-key?group_id=${GROUP_ID}&kid=${encodeURIComponent(kid)}`);
+ if (!row || row.error) return null;
+ try {
+ const distPubResp = await rpc('GET', 'e2ee/public-key/' + row.distributor_user_id);
+ if (!distPubResp?.public_key_jwk) return null;
+ const distPub = await importECPub(JSON.parse(distPubResp.public_key_jwk));
+ const rawBuf = await unwrapSenderKey(row.encrypted_key, myKeyPair.priv, distPub);
+ const key = await importAES(rawBuf);
+ kidCache[kid] = key;
+ return key;
+ } catch { return null; }
+ }
+
+ async function encrypt(plaintext) {
+ if (!activeSK) throw new Error('E2EE not initialized');
+ const { iv, ct } = await aesEncrypt(activeSK.key, te.encode(plaintext));
+ // ts included for replay detection on client side
+ return JSON.stringify({ v: 1, kid: activeSK.kid, iv, ct, ts: Date.now() });
+ }
+
+ async function decrypt(content) {
+ let p;
+ try { p = JSON.parse(content); } catch { return content; } // not encrypted
+ if (!p.v || !p.kid || !p.iv || !p.ct) return content; // legacy plaintext
+ const key = await resolveKid(p.kid);
+ if (!key) return '[Nachricht nicht entschlusselbar]';
+ try {
+ const buf = await aesDecrypt(key, p.iv, p.ct);
+ return td.decode(buf);
+ } catch { return '[Entschlusselung fehlgeschlagen]'; }
+ }
+
+ return { init, encrypt, decrypt, rotateSenderKey };
+})();
diff --git a/public/index.html b/public/index.html
index aadd42d..80bde4d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,6 +8,7 @@
+