aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2024-05-31 15:22:19 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2024-05-31 15:22:19 -0400
commite8548467d945ccb286da595a02c816abb596439d (patch)
tree918bfc190e8c72f496676e5ce909e90c4bbb4ea1
parent1ff86b7540b8de9ae5a0acb80dd5c79ea6c51823 (diff)
feat: Adding fido as an mfa type
-rw-r--r--lib/vnlib.browser/Taskfile.yaml5
-rw-r--r--lib/vnlib.browser/package-lock.json405
-rw-r--r--lib/vnlib.browser/package.json14
-rw-r--r--lib/vnlib.browser/src/index.ts1
-rw-r--r--lib/vnlib.browser/src/mfa/config.ts7
-rw-r--r--lib/vnlib.browser/src/mfa/fido.ts184
-rw-r--r--lib/vnlib.browser/src/mfa/login.ts10
-rw-r--r--lib/vnlib.browser/src/session/internal.ts16
-rw-r--r--lib/vnlib.browser/src/webcrypto.ts41
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs21
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs282
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs178
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs74
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs3
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs29
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json12
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs62
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs50
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs50
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs4
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs40
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs85
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs44
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs181
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs4
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs4
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs43
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs13
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs136
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs43
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs126
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs248
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaChallenge.cs (renamed from plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs)8
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/OtpAuthPublicKey.cs (renamed from plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs)10
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs167
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs100
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs186
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs84
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs99
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs490
-rw-r--r--plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj1
41 files changed, 2531 insertions, 1029 deletions
diff --git a/lib/vnlib.browser/Taskfile.yaml b/lib/vnlib.browser/Taskfile.yaml
index bdac379..4046432 100644
--- a/lib/vnlib.browser/Taskfile.yaml
+++ b/lib/vnlib.browser/Taskfile.yaml
@@ -31,6 +31,5 @@ tasks:
ignore_error: true
cmds:
#delete dist folder
- - cmd: powershell -Command "Remove-Item -Recurse node_modules"
- - cmd: powershell -Command "Remove-Item -Recurse dist"
- - cmd: powershell -Command "Remove-Item -Recurse -Force bin" \ No newline at end of file
+ - for: ['bin/', 'dist/', 'node_modules/']
+ cmd: powershell -Command "Remove-Item -Recurse -Force {{.ITEM_NAME}}" \ No newline at end of file
diff --git a/lib/vnlib.browser/package-lock.json b/lib/vnlib.browser/package-lock.json
index c38b152..9433fb7 100644
--- a/lib/vnlib.browser/package-lock.json
+++ b/lib/vnlib.browser/package-lock.json
@@ -10,11 +10,13 @@
"license": "MIT",
"devDependencies": {
"@babel/types": "^7.x",
+ "@simplewebauthn/types": "^10.0.0",
"@types/lodash-es": "^4.14.x",
"@types/node": "^20.5.1",
"@typescript-eslint/eslint-plugin": "^7.x.x"
},
"peerDependencies": {
+ "@simplewebauthn/browser": "^10.0.0",
"@vueuse/core": "^10.x",
"axios": "^1.x",
"eslint": "^8.39.0",
@@ -24,37 +26,28 @@
"vue": "^3.x"
}
},
- "node_modules/@aashutoshrathi/word-wrap": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
- "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
- "peer": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/@babel/helper-string-parser": {
- "version": "7.24.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz",
- "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==",
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.6.tgz",
+ "integrity": "sha512-WdJjwMEkmBicq5T9fm/cHND3+UlFa2Yj8ALLgmoSQAJZysYbBjw+azChSGPN4DSPLXOcooGRvDwZWMcF/mLO2Q==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.22.20",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
- "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.6.tgz",
+ "integrity": "sha512-4yA7s865JHaqUdRbnaxarZREuPTHrjpDT+pXoAZ1yhyo6uFnIEpS8VMu16siFOHDpZNKYv5BObhsB//ycbICyw==",
"dev": true,
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.24.4",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz",
- "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==",
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.6.tgz",
+ "integrity": "sha512-eNZXdfU35nJC2h24RznROuOpO94h6x8sg9ju0tT9biNtLZ2vuP8SduLqqV+/8+cebSLV9SJEAN5Z3zQbJG/M+Q==",
"peer": true,
"bin": {
"parser": "bin/babel-parser.js"
@@ -64,13 +57,13 @@
}
},
"node_modules/@babel/types": {
- "version": "7.24.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz",
- "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==",
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.6.tgz",
+ "integrity": "sha512-WaMsgi6Q8zMgMth93GvWPXkhAIEobfsIkLTacoVZoK1J0CevIPGYY2Vo5YvJGqyHqXM6P4ppOYGsIRU8MM9pFQ==",
"dev": true,
"dependencies": {
- "@babel/helper-string-parser": "^7.23.4",
- "@babel/helper-validator-identifier": "^7.22.20",
+ "@babel/helper-string-parser": "^7.24.6",
+ "@babel/helper-validator-identifier": "^7.24.6",
"to-fast-properties": "^2.0.0"
},
"engines": {
@@ -246,22 +239,30 @@
"node": ">= 8"
}
},
+ "node_modules/@simplewebauthn/browser": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz",
+ "integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==",
+ "peer": true,
+ "dependencies": {
+ "@simplewebauthn/types": "^10.0.0"
+ }
+ },
+ "node_modules/@simplewebauthn/types": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
+ "integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ=="
+ },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"peer": true
},
- "node_modules/@types/json-schema": {
- "version": "7.0.15",
- "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
- "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true
- },
"node_modules/@types/lodash": {
- "version": "4.17.0",
- "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
- "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==",
+ "version": "4.17.4",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.4.tgz",
+ "integrity": "sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==",
"dev": true
},
"node_modules/@types/lodash-es": {
@@ -274,20 +275,14 @@
}
},
"node_modules/@types/node": {
- "version": "20.12.7",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz",
- "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==",
+ "version": "20.12.14",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
+ "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
- "node_modules/@types/semver": {
- "version": "7.5.8",
- "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
- "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
- "dev": true
- },
"node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
@@ -295,21 +290,19 @@
"peer": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "7.7.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.0.tgz",
- "integrity": "sha512-GJWR0YnfrKnsRoluVO3PRb9r5aMZriiMMM/RHj5nnTrBy1/wIgk76XCtCKcnXGjpZQJQRFtGV9/0JJ6n30uwpQ==",
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz",
+ "integrity": "sha512-P+qEahbgeHW4JQ/87FuItjBj8O3MYv5gELDzr8QaQ7fsll1gSMTYb6j87MYyxwf3DtD7uGFB9ShwgmCJB5KmaQ==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "7.7.0",
- "@typescript-eslint/type-utils": "7.7.0",
- "@typescript-eslint/utils": "7.7.0",
- "@typescript-eslint/visitor-keys": "7.7.0",
- "debug": "^4.3.4",
+ "@typescript-eslint/scope-manager": "7.11.0",
+ "@typescript-eslint/type-utils": "7.11.0",
+ "@typescript-eslint/utils": "7.11.0",
+ "@typescript-eslint/visitor-keys": "7.11.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
- "semver": "^7.6.0",
"ts-api-utils": "^1.3.0"
},
"engines": {
@@ -330,16 +323,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "7.7.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.7.0.tgz",
- "integrity": "sha512-fNcDm3wSwVM8QYL4HKVBggdIPAy9Q41vcvC/GtDobw3c4ndVT3K6cqudUmjHPw8EAp4ufax0o58/xvWaP2FmTg==",
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.11.0.tgz",
+ "integrity": "sha512-yimw99teuaXVWsBcPO1Ais02kwJ1jmNA1KxE7ng0aT7ndr1pT1wqj0OJnsYVGKKlc4QJai86l/025L6z8CljOg==",
"dev": true,
"peer": true,
"dependencies": {
- "@typescript-eslint/scope-manager": "7.7.0",
- "@typescript-eslint/types": "7.7.0",
- "@typescript-eslint/typescript-estree": "7.7.0",
- "@typescript-eslint/visitor-keys": "7.7.0",
+ "@typescript-eslint/scope-manager": "7.11.0",
+ "@typescript-eslint/types": "7.11.0",
+ "@typescript-eslint/typescript-estree": "7.11.0",
+ "@typescript-eslint/visitor-keys": "7.11.0",
"debug": "^4.3.4"
},
"engines": {
@@ -359,13 +352,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "7.7.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.0.tgz",
- "integrity": "sha512-/8INDn0YLInbe9Wt7dK4cXLDYp0fNHP5xKLHvZl3mOT5X17rK/YShXaiNmorl+/U4VKCVIjJnx4Ri5b0y+HClw==",
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.11.0.tgz",
+ "integrity": "sha512-27tGdVEiutD4POirLZX4YzT180vevUURJl4wJGmm6TrQoiYwuxTIY98PBp6L2oN+JQxzE0URvYlzJaBHIekXAw==",
"dev": true,
"dependencies": {
- "@typescript-eslint/types": "7.7.0",
- "@typescript-eslint/visitor-keys": "7.7.0"
+ "@typescript-eslint/types": "7.11.0",
+ "@typescript-eslint/visitor-keys": "7.11.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -376,13 +369,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "7.7.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.7.0.tgz",
- "integrity": "sha512-bOp3ejoRYrhAlnT/bozNQi3nio9tIgv3U5C0mVDdZC7cpcQEDZXvq8inrHYghLVwuNABRqrMW5tzAv88Vy77Sg==",
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.11.0.tgz",
+ "integrity": "sha512-WmppUEgYy+y1NTseNMJ6mCFxt03/7jTOy08bcg7bxJJdsM4nuhnchyBbE8vryveaJUf62noH7LodPSo5Z0WUCg==",
"dev": true,
"dependencies": {
- "@typescript-eslint/typescript-estree": "7.7.0",
- "@typescript-eslint/utils": "7.7.0",
+ "@typescript-eslint/typescript-estree": "7.11.0",
+ "@typescript-eslint/utils": "7.11.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -403,9 +396,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "7.7.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.0.tgz",
- "integrity": "sha512-G01YPZ1Bd2hn+KPpIbrAhEWOn5lQBrjxkzHkWvP6NucMXFtfXoevK82hzQdpfuQYuhkvFDeQYbzXCjR1z9Z03w==",
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.11.0.tgz",
+ "integrity": "sha512-MPEsDRZTyCiXkD4vd3zywDCifi7tatc4K37KqTprCvaXptP7Xlpdw0NR2hRJTetG5TxbWDB79Ys4kLmHliEo/w==",
"dev": true,
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -416,13 +409,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "7.7.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.0.tgz",
- "integrity": "sha512-8p71HQPE6CbxIBy2kWHqM1KGrC07pk6RJn40n0DSc6bMOBBREZxSDJ+BmRzc8B5OdaMh1ty3mkuWRg4sCFiDQQ==",
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.11.0.tgz",
+ "integrity": "sha512-cxkhZ2C/iyi3/6U9EPc5y+a6csqHItndvN/CzbNXTNrsC3/ASoYQZEt9uMaEp+xFNjasqQyszp5TumAVKKvJeQ==",
"dev": true,
"dependencies": {
- "@typescript-eslint/types": "7.7.0",
- "@typescript-eslint/visitor-keys": "7.7.0",
+ "@typescript-eslint/types": "7.11.0",
+ "@typescript-eslint/visitor-keys": "7.11.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -444,18 +437,15 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "7.7.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.7.0.tgz",
- "integrity": "sha512-LKGAXMPQs8U/zMRFXDZOzmMKgFv3COlxUQ+2NMPhbqgVm6R1w+nU1i4836Pmxu9jZAuIeyySNrN/6Rc657ggig==",
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.11.0.tgz",
+ "integrity": "sha512-xlAWwPleNRHwF37AhrZurOxA1wyXowW4PqVXZVUNCLjB48CqdPJoJWkrpH2nij9Q3Lb7rtWindtoXwxjxlKKCA==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
- "@types/json-schema": "^7.0.15",
- "@types/semver": "^7.5.8",
- "@typescript-eslint/scope-manager": "7.7.0",
- "@typescript-eslint/types": "7.7.0",
- "@typescript-eslint/typescript-estree": "7.7.0",
- "semver": "^7.6.0"
+ "@typescript-eslint/scope-manager": "7.11.0",
+ "@typescript-eslint/types": "7.11.0",
+ "@typescript-eslint/typescript-estree": "7.11.0"
},
"engines": {
"node": "^18.18.0 || >=20.0.0"
@@ -469,12 +459,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "7.7.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.0.tgz",
- "integrity": "sha512-h0WHOj8MhdhY8YWkzIF30R379y0NqyOHExI9N9KCzvmu05EgG4FumeYa3ccfKUSphyWkWQE1ybVrgz/Pbam6YA==",
+ "version": "7.11.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.11.0.tgz",
+ "integrity": "sha512-7syYk4MzjxTEk0g/w3iqtgxnFQspDJfn6QKD36xMuuhTzjcxY7F8EmBLnALjVyaOF1/bVocu3bS/2/F7rXrveQ==",
"dev": true,
"dependencies": {
- "@typescript-eslint/types": "7.7.0",
+ "@typescript-eslint/types": "7.11.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@@ -492,113 +482,113 @@
"peer": true
},
"node_modules/@vue/compiler-core": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.23.tgz",
- "integrity": "sha512-HAFmuVEwNqNdmk+w4VCQ2pkLk1Vw4XYiiyxEp3z/xvl14aLTUBw2OfVH3vBcx+FtGsynQLkkhK410Nah1N2yyQ==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.27.tgz",
+ "integrity": "sha512-E+RyqY24KnyDXsCuQrI+mlcdW3ALND6U7Gqa/+bVwbcpcR3BRRIckFoz7Qyd4TTlnugtwuI7YgjbvsLmxb+yvg==",
"peer": true,
"dependencies": {
- "@babel/parser": "^7.24.1",
- "@vue/shared": "3.4.23",
+ "@babel/parser": "^7.24.4",
+ "@vue/shared": "3.4.27",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-dom": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.23.tgz",
- "integrity": "sha512-t0b9WSTnCRrzsBGrDd1LNR5HGzYTr7LX3z6nNBG+KGvZLqrT0mY6NsMzOqlVMBKKXKVuusbbB5aOOFgTY+senw==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.27.tgz",
+ "integrity": "sha512-kUTvochG/oVgE1w5ViSr3KUBh9X7CWirebA3bezTbB5ZKBQZwR2Mwj9uoSKRMFcz4gSMzzLXBPD6KpCLb9nvWw==",
"peer": true,
"dependencies": {
- "@vue/compiler-core": "3.4.23",
- "@vue/shared": "3.4.23"
+ "@vue/compiler-core": "3.4.27",
+ "@vue/shared": "3.4.27"
}
},
"node_modules/@vue/compiler-sfc": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.23.tgz",
- "integrity": "sha512-fSDTKTfzaRX1kNAUiaj8JB4AokikzStWgHooMhaxyjZerw624L+IAP/fvI4ZwMpwIh8f08PVzEnu4rg8/Npssw==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.27.tgz",
+ "integrity": "sha512-nDwntUEADssW8e0rrmE0+OrONwmRlegDA1pD6QhVeXxjIytV03yDqTey9SBDiALsvAd5U4ZrEKbMyVXhX6mCGA==",
"peer": true,
"dependencies": {
- "@babel/parser": "^7.24.1",
- "@vue/compiler-core": "3.4.23",
- "@vue/compiler-dom": "3.4.23",
- "@vue/compiler-ssr": "3.4.23",
- "@vue/shared": "3.4.23",
+ "@babel/parser": "^7.24.4",
+ "@vue/compiler-core": "3.4.27",
+ "@vue/compiler-dom": "3.4.27",
+ "@vue/compiler-ssr": "3.4.27",
+ "@vue/shared": "3.4.27",
"estree-walker": "^2.0.2",
- "magic-string": "^0.30.8",
+ "magic-string": "^0.30.10",
"postcss": "^8.4.38",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-ssr": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.23.tgz",
- "integrity": "sha512-hb6Uj2cYs+tfqz71Wj6h3E5t6OKvb4MVcM2Nl5i/z1nv1gjEhw+zYaNOV+Xwn+SSN/VZM0DgANw5TuJfxfezPg==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.27.tgz",
+ "integrity": "sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==",
"peer": true,
"dependencies": {
- "@vue/compiler-dom": "3.4.23",
- "@vue/shared": "3.4.23"
+ "@vue/compiler-dom": "3.4.27",
+ "@vue/shared": "3.4.27"
}
},
"node_modules/@vue/reactivity": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.23.tgz",
- "integrity": "sha512-GlXR9PL+23fQ3IqnbSQ8OQKLodjqCyoCrmdLKZk3BP7jN6prWheAfU7a3mrltewTkoBm+N7qMEb372VHIkQRMQ==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.27.tgz",
+ "integrity": "sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==",
"peer": true,
"dependencies": {
- "@vue/shared": "3.4.23"
+ "@vue/shared": "3.4.27"
}
},
"node_modules/@vue/runtime-core": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.23.tgz",
- "integrity": "sha512-FeQ9MZEXoFzFkFiw9MQQ/FWs3srvrP+SjDKSeRIiQHIhtkzoj0X4rWQlRNHbGuSwLra6pMyjAttwixNMjc/xLw==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.27.tgz",
+ "integrity": "sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==",
"peer": true,
"dependencies": {
- "@vue/reactivity": "3.4.23",
- "@vue/shared": "3.4.23"
+ "@vue/reactivity": "3.4.27",
+ "@vue/shared": "3.4.27"
}
},
"node_modules/@vue/runtime-dom": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.23.tgz",
- "integrity": "sha512-RXJFwwykZWBkMiTPSLEWU3kgVLNAfActBfWFlZd0y79FTUxexogd0PLG4HH2LfOktjRxV47Nulygh0JFXe5f9A==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.27.tgz",
+ "integrity": "sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==",
"peer": true,
"dependencies": {
- "@vue/runtime-core": "3.4.23",
- "@vue/shared": "3.4.23",
+ "@vue/runtime-core": "3.4.27",
+ "@vue/shared": "3.4.27",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.23.tgz",
- "integrity": "sha512-LDwGHtnIzvKFNS8dPJ1SSU5Gvm36p2ck8wCZc52fc3k/IfjKcwCyrWEf0Yag/2wTFUBXrqizfhK9c/mC367dXQ==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.27.tgz",
+ "integrity": "sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==",
"peer": true,
"dependencies": {
- "@vue/compiler-ssr": "3.4.23",
- "@vue/shared": "3.4.23"
+ "@vue/compiler-ssr": "3.4.27",
+ "@vue/shared": "3.4.27"
},
"peerDependencies": {
- "vue": "3.4.23"
+ "vue": "3.4.27"
}
},
"node_modules/@vue/shared": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.23.tgz",
- "integrity": "sha512-wBQ0gvf+SMwsCQOyusNw/GoXPV47WGd1xB5A1Pgzy0sQ3Bi5r5xm3n+92y3gCnB3MWqnRDdvfkRGxhKtbBRNgg==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.27.tgz",
+ "integrity": "sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==",
"peer": true
},
"node_modules/@vueuse/core": {
- "version": "10.9.0",
- "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz",
- "integrity": "sha512-/1vjTol8SXnx6xewDEKfS0Ra//ncg4Hb0DaZiwKf7drgfMsKFExQ+FnnENcN6efPen+1kIzhLQoGSy0eDUVOMg==",
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.10.0.tgz",
+ "integrity": "sha512-vexJ/YXYs2S42B783rI95lMt3GzEwkxzC8Hb0Ndpd8rD+p+Lk/Za4bd797Ym7yq4jXqdSyj3JLChunF/vyYjUw==",
"peer": true,
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
- "@vueuse/metadata": "10.9.0",
- "@vueuse/shared": "10.9.0",
+ "@vueuse/metadata": "10.10.0",
+ "@vueuse/shared": "10.10.0",
"vue-demi": ">=0.14.7"
},
"funding": {
@@ -606,9 +596,9 @@
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
- "version": "0.14.7",
- "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz",
- "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==",
+ "version": "0.14.8",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
+ "integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"hasInstallScript": true,
"peer": true,
"bin": {
@@ -632,18 +622,18 @@
}
},
"node_modules/@vueuse/metadata": {
- "version": "10.9.0",
- "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.9.0.tgz",
- "integrity": "sha512-iddNbg3yZM0X7qFY2sAotomgdHK7YJ6sKUvQqbvwnf7TmaVPxS4EJydcNsVejNdS8iWCtDk+fYXr7E32nyTnGA==",
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.10.0.tgz",
+ "integrity": "sha512-UNAo2sTCAW5ge6OErPEHb5z7NEAg3XcO9Cj7OK45aZXfLLH1QkexDcZD77HBi5zvEiLOm1An+p/4b5K3Worpug==",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
- "version": "10.9.0",
- "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.9.0.tgz",
- "integrity": "sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==",
+ "version": "10.10.0",
+ "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.10.0.tgz",
+ "integrity": "sha512-2aW33Ac0Uk0U+9yo3Ypg9s5KcR42cuehRWl7vnUHadQyFvCktseyxxEPBi1Eiq4D2yBGACOnqLZpx1eMc7g5Og==",
"peer": true,
"dependencies": {
"vue-demi": ">=0.14.7"
@@ -653,9 +643,9 @@
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
- "version": "0.14.7",
- "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz",
- "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==",
+ "version": "0.14.8",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
+ "integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"hasInstallScript": true,
"peer": true,
"bin": {
@@ -761,9 +751,9 @@
"peer": true
},
"node_modules/axios": {
- "version": "1.6.8",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
- "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==",
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
+ "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -786,12 +776,12 @@
}
},
"node_modules/braces": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
- "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"dependencies": {
- "fill-range": "^7.0.1"
+ "fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
@@ -888,9 +878,9 @@
"peer": true
},
"node_modules/debug": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
- "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
+ "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
"dependencies": {
"ms": "2.1.2"
},
@@ -1202,9 +1192,9 @@
}
},
"node_modules/fill-range": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
- "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -1293,6 +1283,7 @@
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
"peer": true,
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -1429,6 +1420,7 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"peer": true,
"dependencies": {
"once": "^1.3.0",
@@ -1485,9 +1477,9 @@
"peer": true
},
"node_modules/jose": {
- "version": "5.2.4",
- "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.4.tgz",
- "integrity": "sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==",
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.3.0.tgz",
+ "integrity": "sha512-IChe9AtAE79ru084ow8jzkN2lNrG3Ntfiv65Cvj9uOCE2m5LNsdHG+9EbxWxAoWRF9TgDOqLN5jm08++owDVRg==",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -1572,18 +1564,6 @@
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"peer": true
},
- "node_modules/lru-cache": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
- "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "dev": true,
- "dependencies": {
- "yallist": "^4.0.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/magic-string": {
"version": "0.30.10",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz",
@@ -1603,12 +1583,12 @@
}
},
"node_modules/micromatch": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
- "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
+ "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
"dev": true,
"dependencies": {
- "braces": "^3.0.2",
+ "braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
@@ -1689,17 +1669,17 @@
}
},
"node_modules/optionator": {
- "version": "0.9.3",
- "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
- "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==",
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"peer": true,
"dependencies": {
- "@aashutoshrathi/word-wrap": "^1.2.3",
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
"levn": "^0.4.1",
"prelude-ls": "^1.2.1",
- "type-check": "^0.4.0"
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
},
"engines": {
"node": ">= 0.8.0"
@@ -1784,9 +1764,9 @@
}
},
"node_modules/picocolors": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
- "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
+ "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
"peer": true
},
"node_modules/picomatch": {
@@ -1894,6 +1874,7 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
@@ -1928,13 +1909,10 @@
}
},
"node_modules/semver": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
- "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "version": "7.6.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
+ "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
"dev": true,
- "dependencies": {
- "lru-cache": "^6.0.0"
- },
"bin": {
"semver": "bin/semver.js"
},
@@ -2120,16 +2098,16 @@
}
},
"node_modules/vue": {
- "version": "3.4.23",
- "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.23.tgz",
- "integrity": "sha512-X1y6yyGJ28LMUBJ0k/qIeKHstGd+BlWQEOT40x3auJFTmpIhpbKLgN7EFsqalnJXq1Km5ybDEsp6BhuWKciUDg==",
+ "version": "3.4.27",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.27.tgz",
+ "integrity": "sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==",
"peer": true,
"dependencies": {
- "@vue/compiler-dom": "3.4.23",
- "@vue/compiler-sfc": "3.4.23",
- "@vue/runtime-dom": "3.4.23",
- "@vue/server-renderer": "3.4.23",
- "@vue/shared": "3.4.23"
+ "@vue/compiler-dom": "3.4.27",
+ "@vue/compiler-sfc": "3.4.27",
+ "@vue/runtime-dom": "3.4.27",
+ "@vue/server-renderer": "3.4.27",
+ "@vue/shared": "3.4.27"
},
"peerDependencies": {
"typescript": "*"
@@ -2155,18 +2133,21 @@
"node": ">= 8"
}
},
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"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==",
"peer": true
},
- "node_modules/yallist": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true
- },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/lib/vnlib.browser/package.json b/lib/vnlib.browser/package.json
index 795b36a..653444f 100644
--- a/lib/vnlib.browser/package.json
+++ b/lib/vnlib.browser/package.json
@@ -3,13 +3,13 @@
"version": "0.1.13",
"author": "Vaughn Nugent",
"description": "Client JavaScript helper library for vuejs3 web-apps for connecting with Essentials.Accounts plugin and vuejs helpers.",
- "repository":"https://github.com/VnUgE/Plugins.Essentials/tree/master/lib/vnlib.browser",
+ "repository": "https://github.com/VnUgE/Plugins.Essentials/tree/master/lib/vnlib.browser",
"copyright":"Copyright \u00A9 2024 Vaughn Nugent",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"typings": "./dist/index.d.ts",
- "output":"bin",
+ "output": "bin",
"scripts": {
"lint": "eslint --ext .js,.ts src",
"build": "tsc",
@@ -20,17 +20,19 @@
"@babel/types": "^7.x",
"@types/lodash-es": "^4.14.x",
"@types/node": "^20.5.1",
- "@typescript-eslint/eslint-plugin": "^7.x.x"
+ "@typescript-eslint/eslint-plugin": "^7.x.x",
+ "@simplewebauthn/types": "^10.0.0"
},
"peerDependencies": {
+ "@simplewebauthn/browser": "^10.0.0",
"@vueuse/core": "^10.x",
- "lodash-es": "^4.x",
- "vue": "^3.x",
"axios": "^1.x",
"eslint": "^8.39.0",
"jose": "^5.x",
- "universal-cookie": "^7.0.x"
+ "lodash-es": "^4.x",
+ "universal-cookie": "^7.0.x",
+ "vue": "^3.x"
},
"eslintConfig": {
diff --git a/lib/vnlib.browser/src/index.ts b/lib/vnlib.browser/src/index.ts
index de0f651..d450dba 100644
--- a/lib/vnlib.browser/src/index.ts
+++ b/lib/vnlib.browser/src/index.ts
@@ -30,6 +30,7 @@ export type { WebMessage, ServerValidationError } from './types'
export * from './mfa/login'
export * from './mfa/pki'
export * from './mfa/config'
+export * from './mfa/fido'
//Social exports
export * from './social'
diff --git a/lib/vnlib.browser/src/mfa/config.ts b/lib/vnlib.browser/src/mfa/config.ts
index d4bce35..0343ccb 100644
--- a/lib/vnlib.browser/src/mfa/config.ts
+++ b/lib/vnlib.browser/src/mfa/config.ts
@@ -1,4 +1,4 @@
-// Copyright (c) 2023 Vaughn Nugent
+// Copyright (c) 2024 Vaughn Nugent
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
@@ -22,6 +22,7 @@ import { type MaybeRef } from "vue";
import { useAxiosInternal } from "../axios"
import type { MfaMethod } from "./login"
import type { WebMessage } from '../types'
+import type { AxiosRequestConfig } from "axios";
export type UserArg = object;
@@ -56,9 +57,9 @@ export interface MfaApi{
* @param mfaEndpoint The server mfa endpoint relative to the base url
* @returns An object containing the mfa api
*/
-export const useMfaConfig = (mfaEndpoint: MaybeRef<string>): MfaApi =>{
+export const useMfaConfig = (mfaEndpoint: MaybeRef<string>, axiosConfig?: MaybeRef<AxiosRequestConfig | undefined | null>): MfaApi =>{
- const axios = useAxiosInternal(null)
+ const axios = useAxiosInternal(axiosConfig)
const getMethods = async () => {
//Get the mfa methods
diff --git a/lib/vnlib.browser/src/mfa/fido.ts b/lib/vnlib.browser/src/mfa/fido.ts
new file mode 100644
index 0000000..5eaa166
--- /dev/null
+++ b/lib/vnlib.browser/src/mfa/fido.ts
@@ -0,0 +1,184 @@
+// Copyright (c) 2024 Vaughn Nugent
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of
+// this software and associated documentation files (the "Software"), to deal in
+// the Software without restriction, including without limitation the rights to
+// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+// the Software, and to permit persons to whom the Software is furnished to do so,
+// subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+import { get } from "@vueuse/core";
+import { type MaybeRef } from "vue";
+import { useAxiosInternal } from "../axios";
+import type { WebMessage } from "../types";
+import type { AxiosRequestConfig } from "axios";
+import type {
+ IMfaFlowContinuiation,
+ IMfaMessage,
+ IMfaTypeProcessor,
+ MfaSumissionHandler
+} from "./login";
+import { startRegistration } from "@simplewebauthn/browser";
+import type { RegistrationResponseJSON, PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/types";
+
+export type IFidoServerOptions = PublicKeyCredentialCreationOptionsJSON
+
+export interface IFidoRequestOptions{
+ readonly password: string;
+}
+
+export interface IFidoDevice{
+ readonly id: string;
+ readonly name: string;
+ readonly registered_at: number;
+}
+
+
+interface FidoRegistration{
+ readonly id: string;
+ readonly publicKey?: string;
+ readonly publicKeyAlgorithm: number;
+ readonly clientDataJSON: string;
+ readonly authenticatorData?: string;
+ readonly attestationObject?: string;
+}
+
+export interface IFidoApi {
+ /**
+ * Gets fido credential options from the server for a currently logged-in user
+ * @returns A promise that resolves to the server options for the FIDO API
+ */
+ beginRegistration: (options?: Partial<IFidoRequestOptions>) => Promise<PublicKeyCredentialCreationOptionsJSON>;
+
+ /**
+ * Creates a new credential for the currently logged-in user
+ * @param credential The credential to create
+ * @returns A promise that resolves to a web message
+ */
+ registerCredential: (credential: RegistrationResponseJSON, commonName: string) => Promise<WebMessage>;
+
+ /**
+ * Registers the default device for the currently logged-in user
+ * @returns A promise that resolves to a web message status of the operation
+ */
+ registerDefaultDevice: (commonName: string, options?: Partial<IFidoRequestOptions>) => Promise<WebMessage>;
+
+ /**
+ * Lists all devices for the currently logged-in user
+ * @returns A promise that resolves to a list of devices
+ */
+ listDevices: () => Promise<IFidoDevice[]>;
+
+ /**
+ * Disables a device for the currently logged-in user.
+ * May require a password to be passed in the options
+ * @param device The device descriptor to disable
+ * @param options The options to pass to the server
+ * @returns A promise that resolves to a web message status of the operation
+ */
+ disableDevice: (device: IFidoDevice, options?: Partial<IFidoRequestOptions>) => Promise<WebMessage>;
+
+ /**
+ * Disables all devices for the currently logged-in user.
+ * May require a password to be passed in the options
+ * @param options The options to pass to the server
+ * @returns A promise that resolves to a web message status of the operation
+ */
+ disableAllDevices: (options?: Partial<IFidoRequestOptions>) => Promise<WebMessage>;
+}
+
+/**
+ * Creates a fido api for configuration and management of fido client devices
+ * @param endpoint The fido server endpoint
+ * @param axiosConfig The optional axios configuration to use
+ * @returns An object containing the fido api
+ */
+export const useFidoApi = (endpoint: MaybeRef<string>, axiosConfig?: MaybeRef<AxiosRequestConfig | undefined | null>)
+ : IFidoApi =>{
+ const ep = () => get(endpoint);
+
+ const axios = useAxiosInternal(axiosConfig)
+
+ const beginRegistration = async (options?: Partial<IFidoRequestOptions>) : Promise<IFidoServerOptions> => {
+ const { data } = await axios.value.put<WebMessage<IFidoServerOptions>>(ep(), options);
+ return data.getResultOrThrow();
+ }
+
+ const registerCredential = async (registration: RegistrationResponseJSON, commonName: string): Promise<WebMessage> => {
+
+ const response: FidoRegistration = {
+ id: registration.id,
+ publicKey: registration.response.publicKey,
+ publicKeyAlgorithm: registration.response.publicKeyAlgorithm!,
+ clientDataJSON: registration.response.clientDataJSON,
+ authenticatorData: registration.response.authenticatorData,
+ attestationObject: registration.response.attestationObject
+ }
+
+ const { data } = await axios.value.post<WebMessage>(ep(), { response, commonName });
+ return data;
+ }
+
+ const registerDefaultDevice = async (commonName: string, options?: Partial<IFidoRequestOptions>): Promise<WebMessage> => {
+ //begin registration
+ const serverOptions = await beginRegistration(options);
+
+ const reg = await startRegistration(serverOptions);
+
+ return await registerCredential(reg, commonName);
+ }
+
+ const listDevices = async (): Promise<IFidoDevice[]> => {
+ const { data } = await axios.value.get<WebMessage<IFidoDevice[]>>(ep());
+ return data.getResultOrThrow();
+ }
+
+ const disableDevice = async (device: IFidoDevice, options?: Partial<IFidoRequestOptions>): Promise<WebMessage> => {
+ const { data } = await axios.value.post<WebMessage>(ep(), { delete: device, ...options });
+ return data;
+ }
+
+ const disableAllDevices = async (options?: Partial<IFidoRequestOptions>): Promise<WebMessage> => {
+ const { data } = await axios.value.post<WebMessage>(ep(), options);
+ return data;
+ }
+
+ return {
+ beginRegistration,
+ registerCredential,
+ registerDefaultDevice,
+ listDevices,
+ disableDevice,
+ disableAllDevices
+ }
+}
+
+/**
+ * Enables fido as a supported multi-factor authentication method
+ * @returns A mfa login processor for fido multi-factor
+ */
+export const fidoMfaProcessor = () : IMfaTypeProcessor => {
+
+ const processMfa = (payload: IMfaMessage, onSubmit: MfaSumissionHandler) : Promise<IMfaFlowContinuiation> => {
+
+ return Promise.resolve({
+ ...payload,
+ submit: onSubmit.submit,
+ })
+ }
+
+ return{
+ type: "fido",
+ processMfa
+ }
+} \ No newline at end of file
diff --git a/lib/vnlib.browser/src/mfa/login.ts b/lib/vnlib.browser/src/mfa/login.ts
index a2bf120..57465ef 100644
--- a/lib/vnlib.browser/src/mfa/login.ts
+++ b/lib/vnlib.browser/src/mfa/login.ts
@@ -1,4 +1,4 @@
-// Copyright (c) 2023 Vaughn Nugent
+// Copyright (c) 2024 Vaughn Nugent
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
@@ -29,9 +29,7 @@ import type { Axios } from "axios";
import type { ITokenResponse } from "../session";
import type { WebMessage } from "../types";
-export enum MfaMethod {
- TOTP = 'totp'
-}
+export type MfaMethod = 'totp' | 'fido' | 'pkotp';
export interface IMfaSubmission {
/**
@@ -100,7 +98,7 @@ export interface IMfaLoginManager {
const getMfaProcessor = (user: IUserInternal, axios:Ref<Axios>) =>{
//Store handlers by their mfa type
- const handlerMap = new Map<string, IMfaTypeProcessor>();
+ const handlerMap = new Map<MfaMethod, IMfaTypeProcessor>();
//Creates a submission handler for an mfa upgrade
const createSubHandler = (upgrade : string, finalize: (res: ITokenResponse) => Promise<void>) :MfaSumissionHandler => {
@@ -177,7 +175,7 @@ export const totpMfaProcessor = (): IMfaTypeProcessor => {
}
return {
- type: MfaMethod.TOTP,
+ type: 'totp',
processMfa
}
}
diff --git a/lib/vnlib.browser/src/session/internal.ts b/lib/vnlib.browser/src/session/internal.ts
index 4fba638..0d60a38 100644
--- a/lib/vnlib.browser/src/session/internal.ts
+++ b/lib/vnlib.browser/src/session/internal.ts
@@ -21,7 +21,7 @@ import { defaults, isEmpty, isNil, noop } from 'lodash-es';
import { computed, watch, type Ref } from "vue";
import { get, set, toRefs } from '@vueuse/core';
import { SignJWT } from 'jose'
-import crypto, { decryptAsync, getRandomHex } from "../webcrypto";
+import { getCryptoOrThrow, decryptAsync, getRandomHex } from "../webcrypto";
import { ArrayBuffToBase64, Base64ToUint8Array } from '../binhelpers'
import { debugLog } from "../util";
import type { CookieMonitor } from './cookies'
@@ -63,6 +63,8 @@ const createKeyStore = (storage: Ref<IKeyStorage>, keyAlg: Ref<AlgorithmIdentifi
}
const setCredentialAsync = async (keypair: CryptoKeyPair): Promise<void> => {
+ const crypto = getCryptoOrThrow();
+
// Store the private key
const newPrivRaw = await crypto.exportKey('pkcs8', keypair.privateKey);
const newPubRaw = await crypto.exportKey('spki', keypair.publicKey);
@@ -83,10 +85,11 @@ const createKeyStore = (storage: Ref<IKeyStorage>, keyAlg: Ref<AlgorithmIdentifi
return;
}
+ const crypto = getCryptoOrThrow();
+
// If not, generate a new key pair
const keypair = await crypto.generateKey(keyAlg.value, true, ['encrypt', 'decrypt']) as CryptoKeyPair;
-
- //Set credential
+
await setCredentialAsync(keypair);
debugLog("Generated new client keypair, none were found")
@@ -102,10 +105,11 @@ const createKeyStore = (storage: Ref<IKeyStorage>, keyAlg: Ref<AlgorithmIdentifi
// Convert the private key to a Uint8Array from its base64 string
const keyData = Base64ToUint8Array(priv.value || "")
+ const crypto = getCryptoOrThrow();
+
//import private key as pkcs8
const privKey = await crypto.importKey('pkcs8', keyData, keyAlg.value, false, ['decrypt'])
-
- // Decrypt the data and return it
+
return await decryptAsync(keyAlg.value, privKey, data, false) as ArrayBuffer
}
@@ -113,6 +117,8 @@ const createKeyStore = (storage: Ref<IKeyStorage>, keyAlg: Ref<AlgorithmIdentifi
// Decrypt the data
const decrypted = await decryptDataAsync(data)
+ const crypto = getCryptoOrThrow();
+
// Hash the decrypted data
const hashed = await crypto.digest({ name: 'SHA-256' }, decrypted)
diff --git a/lib/vnlib.browser/src/webcrypto.ts b/lib/vnlib.browser/src/webcrypto.ts
index d2c7640..1f796ea 100644
--- a/lib/vnlib.browser/src/webcrypto.ts
+++ b/lib/vnlib.browser/src/webcrypto.ts
@@ -20,7 +20,16 @@
import { isArrayBuffer, isPlainObject, isString } from 'lodash-es';
import { ArrayBuffToBase64, Base64ToUint8Array, ArrayToHexString } from './binhelpers';
-const crypto = window?.crypto?.subtle || {};
+export const isCryptoSupported = () : boolean => {
+ return !!(window.isSecureContext && window.crypto && window.crypto.subtle);
+}
+
+export const getCryptoOrThrow = () => {
+ if (!isCryptoSupported()) {
+ throw new Error('Your browser does not support the Web Cryptography API');
+ }
+ return window.crypto.subtle;
+}
/**
* Signs the dataBuffer using the specified key and hmac algorithm by its name eg. 'SHA-256'
@@ -29,9 +38,13 @@ const crypto = window?.crypto?.subtle || {};
* @param {String} alg The name of the hmac algorithm to use eg. 'SHA-256'
* @param {String} [toBase64 = false] The output format, the array buffer data, or true for base64 string
* @returns {Promise<ArrayBuffer | String>} The signature as an ArrayBuffer or a base64 string
+ * @throws An error if the browser does not support the Web Cryptography API
*/
export const hmacSignAsync = async (keyBuffer: ArrayBuffer | string, dataBuffer: ArrayBuffer | string, alg : string, toBase64 = false)
: Promise<ArrayBuffer | string> => {
+
+ const crypto = getCryptoOrThrow()
+
// Check key argument type
const rawKeyBuffer = isString(keyBuffer) ? Base64ToUint8Array(keyBuffer as string) : keyBuffer as ArrayBuffer;
@@ -47,6 +60,7 @@ export const hmacSignAsync = async (keyBuffer: ArrayBuffer | string, dataBuffer:
// Encode to base64 if needed
return toBase64 ? ArrayBuffToBase64(digest) : digest;
}
+
/**
* @function decryptAsync Decrypts syncrhonous or asyncrhonsous en encypted data
* asynchronously.
@@ -55,13 +69,17 @@ export const hmacSignAsync = async (keyBuffer: ArrayBuffer | string, dataBuffer:
* @param {Object} algorithm The algorithm object to use for decryption.
* @param {Boolean} toBase64 If true, the decrypted data will be returned as a base64 string.
* @returns {Promise} The decrypted data.
+ * @throws An error if the browser does not support the Web Cryptography API
*/
export const decryptAsync = async (
algorithm: AlgorithmIdentifier,
privKey: BufferSource | CryptoKey | JsonWebKey,
data: string | ArrayBuffer,
- toBase64 = false): Promise<string | ArrayBuffer> =>
+ toBase64 = false
+): Promise<string | ArrayBuffer> =>
{
+ const crypto = getCryptoOrThrow()
+
// Check data argument type and decode if needed
const dataBuffer = isString(data) ? Base64ToUint8Array(data as string) : data as ArrayBuffer;
@@ -84,14 +102,21 @@ export const decryptAsync = async (
return toBase64 ? ArrayBuffToBase64(decrypted) : decrypted
}
+/**
+ * Gets a random hex string of the specified size
+ * @param size The number of bytes to generate
+ * @returns A random hex string of the specified size
+ * @throws An error if the browser does not support the Web Cryptography API
+ */
export const getRandomHex = (size: number) : string => {
- // generate a new random secret and store it
+ if (!isCryptoSupported()) {
+ throw new Error('Your browser does not support the Web Cryptography API');
+ }
+
const randBuffer = new Uint8Array(size)
- // generate random id directly on the window.crypto object
+
window.crypto.getRandomValues(randBuffer)
- // Store the id in the session as hex
+
+ //Convert the random buffer to a hex string
return ArrayToHexString(randBuffer)
}
-
-//default export subtle crypto
-export default crypto; \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
index 219239e..31b4180 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/AccountsEntryPoint.cs
@@ -38,6 +38,9 @@ using VNLib.Plugins.Essentials.Accounts.SecurityProvider;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using VNLib.Plugins.Extensions.Loading.Routing;
+using VNLib.Plugins.Essentials.Accounts.MFA.Otp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
namespace VNLib.Plugins.Essentials.Accounts
{
@@ -49,6 +52,7 @@ namespace VNLib.Plugins.Essentials.Accounts
private bool SetupMode => HostArgs.HasArgument("--account-setup");
+ /// <inheritdoc/>
protected override void OnLoad()
{
//Add optional endpoint routing
@@ -84,6 +88,11 @@ namespace VNLib.Plugins.Essentials.Accounts
this.Route<PkiLoginEndpoint>();
}
+ if (this.HasConfigForType<FidoEndpoint>())
+ {
+ this.Route<FidoEndpoint>();
+ }
+
//Only export the account security service if the configuration element is defined
if (this.HasConfigForType<AccountSecProvider>())
{
@@ -257,7 +266,11 @@ Commands:
break;
}
- user.MFADisable();
+ //Disable all mfa methods
+ user.TotpDisable();
+ //user.OtpDisable();
+ user.FidoDisable();
+
await user.ReleaseAsync();
Log.Information("Successfully disabled MFA for {id}", username);
@@ -293,7 +306,7 @@ Commands:
}
//Update the totp secret and flush changes
- user.MFASetTOTPSecret(secret);
+ user.TotpSetSecret(secret);
await user.ReleaseAsync();
Log.Information("Successfully set TOTP secret for {id}", username);
@@ -328,7 +341,7 @@ Commands:
break;
}
- PkiAuthPublicKey? pubkey = JsonSerializer.Deserialize<PkiAuthPublicKey>(pubkeyJwk);
+ OtpAuthPublicKey? pubkey = JsonSerializer.Deserialize<OtpAuthPublicKey>(pubkeyJwk);
if (pubkey == null)
{
Log.Error("You public key is not a JSON object");
@@ -345,7 +358,7 @@ Commands:
//Add/update the public key and flush changes
- user.PKIAddPublicKey(pubkey);
+ user.OtpAddPublicKey(pubkey);
await user.ReleaseAsync();
Log.Information("Successfully set TOTP secret for {id}", username);
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs
new file mode 100644
index 0000000..779d8c9
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/FidoEndpoint.cs
@@ -0,0 +1,282 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoEndpoint.cs
+*
+* FidoEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+using System.Net;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+using FluentValidation;
+
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Hashing;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Plugins.Essentials.Endpoints;
+using VNLib.Plugins.Extensions.Loading;
+using VNLib.Plugins.Extensions.Loading.Users;
+using VNLib.Plugins.Extensions.Validation;
+using VNLib.Plugins.Essentials.Extensions;
+
+using VNLib.Plugins.Essentials.Accounts.MFA;
+using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.Endpoints
+{
+ /// <summary>
+ /// <para>
+ /// This enpdoint requires Fido to be enabled in the MFA configuration.
+ /// </para>
+ /// </summary>
+ [ConfigurationName("fido_endpoint")]
+ internal sealed class FidoEndpoint : ProtectedWebEndpoint
+ {
+ private static readonly FidoResponseValidator ResponseValidator = new();
+ private static readonly FidoClientDataJsonValidtor ClientDataValidator = new();
+
+ private readonly IUserManager _users;
+ private readonly FidoConfig _fidoConfig;
+ private readonly FidoPubkeyAlgorithm[] _supportedAlgs;
+
+ public FidoEndpoint(PluginBase plugin, IConfigScope config)
+ {
+ _users = plugin.GetOrCreateSingleton<UserManager>();
+ _fidoConfig = plugin.GetConfigElement<MFAConfig>().FIDOConfig
+ ?? throw new ConfigurationValidationException("Fido configuration was not set, but Fido endpoint was enabled");
+
+ InitPathAndLog(
+ path: config.GetRequiredProperty("path", p => p.GetString()!),
+ log: plugin.Log.CreateScope("Fido-Endpoint")
+ );
+
+ /*
+ * For now hard-code supported algorithms,
+ * ECDSA is easiest for the time being
+ */
+
+ _supportedAlgs =
+ [
+ new FidoPubkeyAlgorithm(algId: -7), //ES256
+ new FidoPubkeyAlgorithm(algId: -35), //ES384
+ new FidoPubkeyAlgorithm(algId: -36), //ES512
+ ];
+ }
+
+ protected override VfReturnType Get(HttpEntity entity)
+ {
+ return VirtualOk(entity);
+ }
+
+ protected override async ValueTask<VfReturnType> PutAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ using IUser? user = await _users.GetUserFromIDAsync(entity.Session.UserID, entity.EventCancellation);
+
+ if (webm.Assert(user != null, "User not found"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.NotFound);
+ }
+
+ //TODO: Store challenge in user session
+ string challenge = RandomHash.GetRandomBase64(16);
+
+ webm.Result = new FidoRegistrationMessage
+ {
+ AttestationType = _fidoConfig.AttestationType,
+ AuthSelection = _fidoConfig.FIDOAuthSelection,
+ RelyingParty = new FidoRelyingParty
+ {
+ Id = entity.Server.RequestUri.DnsSafeHost,
+ Name = _fidoConfig.SiteName
+ },
+ User = new FidoUserData
+ {
+ UserId = user.UserID,
+ UserName = user.EmailAddress,
+ DisplayName = user.EmailAddress,
+ },
+ Timeout = _fidoConfig.Timeout,
+ PubKeyCredParams = _supportedAlgs,
+ Base64Challenge = challenge,
+ };
+
+ webm.Success = true;
+
+ return VirtualOk(entity, webm);
+ }
+
+ protected override async ValueTask<VfReturnType> PostAsync(HttpEntity entity)
+ {
+ ValErrWebMessage webm = new();
+
+ using JsonDocument? doc = await entity.GetJsonFromFileAsync();
+
+ if(webm.Assert(doc != null, "Missing entity message"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if(doc.RootElement.TryGetProperty("response", out JsonElement deviceResponse))
+ {
+ //complete registation of new device
+ FidoAuthenticatorResponse? res = deviceResponse.Deserialize<FidoAuthenticatorResponse>();
+
+ if(webm.Assert(res != null, "Mising registation response object"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if(!ResponseValidator.Validate(res, webm))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
+ }
+
+ return await RegisterDeviceAsync(entity, res);
+ }
+
+ return VfReturnType.NotFound;
+ }
+
+ private async ValueTask<VfReturnType> RegisterDeviceAsync(
+ HttpEntity entity,
+ FidoAuthenticatorResponse response
+ )
+ {
+ ValErrWebMessage webm = new();
+
+ bool isAlgSupported = _supportedAlgs.Any(p => p.AlgId == response.CoseAlgorithmNumber);
+
+ if(webm.Assert(isAlgSupported, "Authenticator does not support the same algorithms as the server"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ FidoClientDataJson? clientData = FidoBase64Util.DeserialzeJson<FidoClientDataJson>(response.Base64ClientData!);
+
+ if(webm.Assert(clientData != null, "Client data json is not valid"))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
+ }
+
+ if(!ClientDataValidator.Validate(clientData, webm))
+ {
+ return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
+ }
+
+ return VirtualOk(entity);
+ }
+
+ }
+
+ internal sealed class FidoBase64Util
+ {
+
+ /// <summary>
+ /// Takes a base64url encoded JSON string and deserializes it into a
+ /// given object.
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="base64Url">The base64url encoded JSON string to decode</param>
+ /// <returns>The instance of the object if it could be decoded</returns>
+ /// <exception cref="JsonException"></exception>
+ public static T? DeserialzeJson<T>(string base64Url)
+ {
+ /*
+ * We just need to transform the base64 encoded chars back to
+ * utf8 bytes and then deserialize the object
+ *
+ * The length is assumed to be validated before deserialization
+ */
+
+ using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(base64Url.Length);
+
+ ERRNO count = VnEncoding.Base64UrlDecode(base64Url, buffer.Span, System.Text.Encoding.UTF8);
+
+ if (count < 1)
+ {
+ throw new JsonException("Failed to decode base64url");
+ }
+
+ return JsonSerializer.Deserialize<T>(buffer.AsSpan(0, count));
+ }
+ }
+
+ internal sealed class FidoResponseValidator : AbstractValidator<FidoAuthenticatorResponse>
+ {
+ public FidoResponseValidator()
+ {
+ RuleFor(c => c.DeviceId)
+ .NotEmpty()
+ .WithMessage("Fido 'device_id' must be provided")
+ .MaximumLength(256);
+
+ RuleFor(c => c.Base64PublicKey)
+ .NotEmpty()
+ .WithMessage("Fido 'public_key' must be provided");
+
+ RuleFor(c => c.CoseAlgorithmNumber)
+ .NotNull()
+ .WithMessage("Fido 'public_key_algorithm' number must be provided in a valid COSE algorithm number");
+
+ RuleFor(c => c.Base64ClientData)
+ .NotEmpty()
+ .WithMessage("Fido 'client_data' must be provided")
+ .MaximumLength(4096);
+
+ RuleFor(c => c.Base64AuthenticatorData)
+ .NotEmpty()
+ .WithMessage("Fido 'authenticator_data' must be provided")
+ .MaximumLength(4096);
+
+ RuleFor(c => c.Base64Attestation)
+ .NotEmpty()
+ .WithMessage("Fido 'attestation' must be provided")
+ .MaximumLength(4096);
+
+ }
+
+ }
+
+ internal sealed class FidoClientDataJsonValidtor : AbstractValidator<FidoClientDataJson>
+ {
+ public FidoClientDataJsonValidtor()
+ {
+ RuleFor(c => c.Base64Challenge)
+ .NotEmpty()
+ .WithMessage("Fido 'challenge' must be provided")
+ .MaximumLength(4096);
+
+ RuleFor(c => c.Origin)
+ .NotEmpty()
+ .WithMessage("Fido 'origin' must be provided")
+ .MaximumLength(1024);
+
+ RuleFor(c => c.Type)
+ .NotEmpty()
+ .WithMessage("Fido 'type' must be provided")
+ .MaximumLength(64);
+ }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
index 6e3653e..faad34b 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/LoginEndpoint.cs
@@ -27,6 +27,7 @@ using System.Net;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text.Json.Serialization;
@@ -44,6 +45,8 @@ using VNLib.Plugins.Essentials.Accounts.Validators;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
using static VNLib.Plugins.Essentials.Statics;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
/*
@@ -73,7 +76,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
private static readonly LoginMessageValidation LmValidator = new();
- private readonly MFAConfig MultiFactor;
+ private readonly MfaAuthManager MultiFactor;
private readonly IUserManager Users;
private readonly FailedLoginLockout _lockout;
@@ -84,10 +87,25 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
uint maxLogins = config["max_login_attempts"].GetUInt32();
InitPathAndLog(path, pbase.Log);
-
+
+ MFAConfig conf = pbase.GetConfigElement<MFAConfig>();
Users = pbase.GetOrCreateSingleton<UserManager>();
- MultiFactor = pbase.GetConfigElement<MFAConfig>();
_lockout = new(maxLogins, duration);
+
+
+ List<IMfaProcessor> proc = [];
+
+ if(conf.TOTPEnabled)
+ {
+ proc.Add(new TotpAuthProcessor(conf.TOTPConfig!));
+ }
+
+ if(conf.FIDOEnabled)
+ {
+ proc.Add(new FidoMfaProcessor(conf.FIDOConfig!));
+ }
+
+ MultiFactor = new(conf, [.. proc]);
}
protected override ERRNO PreProccess(HttpEntity entity)
@@ -104,14 +122,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VirtualClose(entity, HttpStatusCode.Conflict);
}
- //If mfa is enabled, allow processing via mfa
- if (MultiFactor.FIDOEnabled || MultiFactor.TOTPEnabled)
+ /*
+ * To continue an mfa upgrade, the client must send
+ * an mfa query argument to continue the upgrade process
+ */
+ if (MultiFactor.Armed)
{
if (entity.QueryArgs.ContainsKey("mfa"))
{
return await ProcessMfaAsync(entity);
}
}
+
return await ProccesLoginAsync(entity);
}
@@ -165,7 +187,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//If login return true, the response has been set and we should return
- if (LoginUser(entity, loginMessage, user, webm))
+ if (LoginOrMfaConnection(entity, loginMessage, user, webm))
{
goto Cleanup;
}
@@ -191,12 +213,11 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
//Validate password against store
ERRNO valResult = await Users.ValidatePasswordAsync(user, login.Password!, PassValidateFlags.None, cancellation);
-
- //Valid results are greater than 0;
+
return valResult == UserPassValResult.Success;
}
- private bool LoginUser(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm)
+ private bool LoginOrMfaConnection(HttpEntity entity, LoginMessage loginMessage, IUser user, MfaUpgradeWebm webm)
{
//Only allow active users
if (user.Status != UserStatus.Active)
@@ -211,22 +232,14 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
try
{
- //get the new upgrade jwt string
- MfaUpgradeMessage? message = user.MFAGetUpgradeIfEnabled(MultiFactor, loginMessage);
-
/*
- * Mfa is essentially indempodent, the session stores the last upgrade key, so
- * if this method is continually called, new mfa tokens will be generated.
+ * Determine if the user uses MFA to guard their account. If so
+ * force an MFA upgrade before allowing the user to login
*/
-
- //if message is null, mfa was not enabled or could not be prepared
- if (message.HasValue)
+ if (MultiFactor.HasMfaEnabled(user))
{
- //Store the base64 signature
- entity.Session.MfaUpgradeSecret(message.Value.SessionKey);
-
- //send challenge message to client
- webm.Result = message.Value.ClientJwt;
+ //the upgrade message
+ webm.Result = MultiFactor.GetChallengeMessage(entity, user, loginMessage);
webm.MultiFactorUpgrade = true;
}
else
@@ -278,31 +291,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
- //Recover upgrade jwt
- string? upgradeJwt = request.RootElement.GetPropString("upgrade");
-
- if (webm.Assert(upgradeJwt != null, "Missing required upgrade data"))
- {
- return VirtualClose(entity, webm, HttpStatusCode.UnprocessableEntity);
- }
-
- //Recover stored signature
- string? storedSig = entity.Session.MfaUpgradeSecret();
-
- if(webm.Assert(!string.IsNullOrWhiteSpace(storedSig), MFA_ERROR_MESSAGE))
- {
- return VirtualOk(entity, webm);
- }
-
- //Recover upgrade data from upgrade message
- MFAUpgrade? upgrade = MultiFactor!.RecoverUpgrade(upgradeJwt, storedSig);
+ MfaChallenge? upgrade = MultiFactor.GetChallengeData(entity, request);
+ /*
+ * Upgrade may be null if it is not valid, not correctly formatted,
+ * expired, and so on. We cannot leak information about the upgrade
+ * request to the client, so return a generic error message
+ */
if (webm.Assert(upgrade != null, MFA_ERROR_MESSAGE))
{
return VirtualOk(entity, webm);
}
- //recover user account
using IUser? user = await Users.GetUserFromUsernameAsync(upgrade.UserName!);
if (webm.Assert(user != null, MFA_ERROR_MESSAGE))
@@ -311,81 +311,55 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
bool locked = _lockout.CheckOrClear(user, entity.RequestedTimeUtc);
-
- //Make sure the account has not been locked out
+
if (webm.Assert(locked == false, LOCKED_ACCOUNT_MESSAGE))
{
//Locked, so clear stored signature
- entity.Session.MfaUpgradeSecret(null);
+ MultiFactor.InvalidateUpgrade(entity);
+ }
+ else if (MultiFactor.VerifyResponse(entity, upgrade, user, request))
+ {
+ /*
+ * ###################################################
+ *
+ * AUTHORIZATION ZONE
+ *
+ * Connection will be elevated to authorized
+ * this is a successful login!
+ *
+ * ####################################################
+ */
+
+ MultiFactor.InvalidateUpgrade(entity);
+
+ /*
+ * Time to authorize the user now. This will cause state changs
+ * to the client session, and user account. The user
+ * is now authorized to use the session.
+ */
+ entity.GenerateAuthorization(upgrade, user, webm);
+
+ webm.Result = new AccountData()
+ {
+ EmailAddress = user.EmailAddress,
+ };
+
+ webm.Success = true;
+
+ Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
}
else
{
- //process mfa login
- LoginMfa(entity, user, request, upgrade, webm);
+ webm.Result = "Please check your input and try again.";
}
- //Update user on clean process
+ //Flush any changes to the user store
await user.ReleaseAsync();
//Close rseponse
return VirtualOk(entity, webm);
}
- private void LoginMfa(HttpEntity entity, IUser user, JsonDocument request, MFAUpgrade upgrade, MfaUpgradeWebm webm)
- {
- //Recover the user's local time
- if(!request.RootElement.TryGetProperty("localtime", out JsonElement ltEl)
- && ltEl.TryGetDateTimeOffset(out DateTimeOffset localTime))
- {
- webm.Result = MFA_ERROR_MESSAGE;
- return;
- }
-
- //Check mode
- switch (upgrade.Type)
- {
- case MFAType.TOTP:
- {
- //get totp code from request
- uint code = request.RootElement.GetProperty("code").GetUInt32();
-
- //Verify totp code
- if (!MultiFactor.VerifyTOTP(user, code))
- {
- webm.Result = "Please check your code.";
-
- //Increment flc and update the user in the store
- _lockout.Increment(user, entity.RequestedTimeUtc);
- return;
- }
- //Valid, complete
- }
- break;
- default:
- webm.Result = MFA_ERROR_MESSAGE;
- return;
- }
-
- //SUCCESSFUL LOGIN
-
- //Wipe session signature
- entity.Session.MfaUpgradeSecret(null);
-
- //Elevate the login status of the session to reflect the user's status
- entity.GenerateAuthorization(upgrade, user, webm);
-
- //Send the Username (since they already have it)
- webm.Result = new AccountData()
- {
- EmailAddress = user.EmailAddress,
- };
-
- webm.Success = true;
-
- //Write to log
- Log.Verbose("Successful login for user {uid}...", user.UserID[..8]);
- }
-
private sealed class MfaUpgradeWebm : ValErrWebMessage
{
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
index 2e102a3..f31334c 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/MFAEndpoint.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2023 Vaughn Nugent
+* Copyright (c) 2024 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -40,9 +40,13 @@ using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Essentials.Endpoints;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
+using VNLib.Plugins.Essentials.Accounts.MFA.Otp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
+using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
+
[ConfigurationName("mfa_endpoint")]
internal sealed class MFAEndpoint : ProtectedWebEndpoint
{
@@ -50,12 +54,14 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
private const string CHECK_PASSWORD = "Please check your password";
private readonly IUserManager Users;
- private readonly MFAConfig? MultiFactor;
+ private readonly MFAConfig MultiFactor;
public MFAEndpoint(PluginBase pbase, IConfigScope config)
- {
- string? path = config["path"].GetString();
- InitPathAndLog(path, pbase.Log);
+ {
+ InitPathAndLog(
+ path: config.GetRequiredProperty("path", p => p.GetString()!),
+ log: pbase.Log.CreateScope("Mfa-Endpoint")
+ );
Users = pbase.GetOrCreateSingleton<UserManager>();
MultiFactor = pbase.GetConfigElement<MFAConfig>();
@@ -67,21 +73,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//Load the MFA entry for the user
using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
-
- //Set the TOTP flag if set
- if (user?.MFATotpEnabled() == true)
+
+ if (user?.TotpEnabled() == true)
{
enabledModes[0] = "totp";
}
-
- //TODO Set fido flag if enabled
- if (!string.IsNullOrWhiteSpace(""))
+
+ if (user?.FidoEnabled() == true)
{
enabledModes[1] = "fido";
}
-
- //PKI enabled
- if (user?.PKIEnabled() == true)
+
+ if (user?.OtpAuthEnabled() == true)
{
enabledModes[2] = "pki";
}
@@ -116,12 +119,6 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VirtualOk(entity, webm);
}
- //Make sure mfa is loaded
- if (webm.Assert(MultiFactor != null, "MFA is not enabled on this server"))
- {
- return VirtualOk(entity, webm);
- }
-
//Get the user entry
using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
@@ -183,18 +180,18 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
//get the request
using JsonDocument? request = await entity.GetJsonFromFileAsync();
- if (webm.Assert(request != null, "Invalid request."))
+ if (webm.Assert(request != null, "Invalid request"))
{
return VirtualClose(entity, webm, HttpStatusCode.BadRequest);
}
string? mfaType = request.RootElement.GetProperty("type").GetString();
-
- //get the user
+
using IUser? user = await Users.GetUserFromIDAsync(entity.Session.UserID);
- if (user == null)
+
+ if (webm.Assert(user != null, "User does not exist"))
{
- return VfReturnType.NotFound;
+ return VirtualClose(entity, webm, HttpStatusCode.NotFound);
}
/*
@@ -218,30 +215,35 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Check for totp disable
- if ("totp".Equals(mfaType, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals("totp", mfaType, StringComparison.OrdinalIgnoreCase))
{
- //Clear the TOTP secret to disable it
- user.MFASetTOTPSecret(null);
-
- //write changes
- await user.ReleaseAsync();
+ user.TotpDisable();
+
webm.Result = "Successfully disabled your TOTP authentication";
webm.Success = true;
}
- else if ("fido".Equals(mfaType, StringComparison.OrdinalIgnoreCase))
+ else if (string.Equals("fido", mfaType, StringComparison.OrdinalIgnoreCase))
{
- //Clear webauthn changes
-
- //write changes
- await user.ReleaseAsync();
+ user.FidoDisable();
+
webm.Result = "Successfully disabled your FIDO authentication";
webm.Success = true;
}
+ else if(string.Equals("pkotp", mfaType, StringComparison.OrdinalIgnoreCase))
+ {
+ user.OtpDisable();
+
+ webm.Result = "Successfully disabled your OTP authentication";
+ webm.Success = true;
+ }
else
{
webm.Result = "Invalid MFA type";
}
+ //write changes (will do nothing if no changes were made)
+ await user.ReleaseAsync();
+
//Must write response while password is in scope
return VirtualOk(entity, webm);
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
index 60c99e3..b274f5f 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PasswordResetEndpoint.cs
@@ -38,6 +38,7 @@ using VNLib.Plugins.Essentials.Accounts.MFA;
using VNLib.Plugins.Extensions.Validation;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
@@ -134,7 +135,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Check if totp is enabled
- if (mFAConfig.TOTPEnabled && user.MFATotpEnabled())
+ if (mFAConfig.TOTPEnabled && user.TotpEnabled())
{
//TOTP code is required
if (webm.Assert(pwReset.TotpCode.HasValue, "TOTP is enabled on this user account, you must enter your TOTP code."))
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
index b88dc11..bda5898 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Endpoints/PkiLoginEndpoint.cs
@@ -43,13 +43,14 @@ using VNLib.Plugins.Essentials.Users;
using VNLib.Plugins.Essentials.Endpoints;
using VNLib.Plugins.Essentials.Extensions;
using VNLib.Plugins.Extensions.Validation;
-using VNLib.Plugins.Essentials.Accounts.MFA;
using VNLib.Plugins.Essentials.Accounts.Validators;
using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Extensions.Loading.Users;
+using VNLib.Plugins.Essentials.Accounts.MFA.Otp;
namespace VNLib.Plugins.Essentials.Accounts.Endpoints
{
+
[ConfigurationName("pki_auth_endpoint")]
internal sealed class PkiLoginEndpoint : UnprotectedWebEndpoint
{
@@ -66,9 +67,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
private static IValidator<AuthenticationInfo> AuthValidator { get; } = AuthenticationInfo.GetValidator();
/// <summary>
- /// A validator used to validate <see cref="PkiAuthPublicKey"/> instances
+ /// A validator used to validate <see cref="OtpAuthPublicKey"/> instances
/// </summary>
- public static IValidator<PkiAuthPublicKey> UserJwkValidator { get; } = GetKeyValidator();
+ public static IValidator<OtpAuthPublicKey> UserJwkValidator { get; } = GetKeyValidator();
private readonly JwtEndpointConfig _config;
private readonly IUserManager _users;
@@ -176,7 +177,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Now we can verify the signed message against the stored key
- if (webm.Assert(user.PKIVerifyUserJWT(jwt, authInfo.KeyId!) == true, INVALID_MESSAGE))
+ if (webm.Assert(user.OtpVerifyUserJWT(jwt, authInfo.KeyId!) == true, INVALID_MESSAGE))
{
//increment flc on invalid signature
_lockout.Increment(user, entity.RequestedTimeUtc);
@@ -254,7 +255,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Get the uesr's stored keys
- webm.Result = user.PkiGetAllPublicKeys();
+ webm.Result = user.OtpGetAllPublicKeys();
webm.Success = true;
return VirtualOk(entity, webm);
@@ -276,7 +277,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
ValErrWebMessage webm = new();
//Get the request body
- PkiAuthPublicKey? pubKey = await entity.GetJsonFromFileAsync<PkiAuthPublicKey>();
+ OtpAuthPublicKey? pubKey = await entity.GetJsonFromFileAsync<OtpAuthPublicKey>();
if(webm.Assert(pubKey != null, "The request message is not valid"))
{
@@ -304,6 +305,12 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
return VirtualOk(entity, webm);
}
+ //Make sure there is enough room to store another key
+ if(webm.Assert(user.OtpCanAddKey(), "Cannot add another public key to your account"))
+ {
+ return VirtualOk(entity, webm);
+ }
+
try
{
//Try to get the ECDA instance to confirm the key data could be recovered properly
@@ -323,7 +330,7 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
//Update user's key, or add it if it doesn't exist
- user.PKIAddPublicKey(pubKey);
+ user.OtpAddPublicKey(pubKey);
//publish changes
await user.ReleaseAsync();
@@ -368,13 +375,13 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
if(entity.QueryArgs.TryGetValue("id", out string? keyId))
{
//Remove only the specified key
- user.PKIRemovePublicKey(keyId);
+ user.OtpRemovePublicKey(keyId);
webm.Result = "You have successfully removed the key from your account";
}
else
{
//Delete all keys
- user.PKISetPublicKeys(null);
+ user.OtpSetPublicKeys(null);
webm.Result = "You have successfully disabled PKI login";
}
@@ -535,9 +542,9 @@ namespace VNLib.Plugins.Essentials.Accounts.Endpoints
}
}
- private static IValidator<PkiAuthPublicKey> GetKeyValidator()
+ private static IValidator<OtpAuthPublicKey> GetKeyValidator()
{
- InlineValidator<PkiAuthPublicKey> val = new();
+ InlineValidator<OtpAuthPublicKey> val = new();
val.RuleFor(a => a.KeyType)
.NotEmpty()
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json b/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json
index 8a345c5..173ed5b 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/Essentials.Accounts.json
@@ -42,6 +42,10 @@
"enable_key_update": true
},
+ "fido_endpoint": {
+ "path": "/account/fido"
+ },
+
//If mfa is defined, configures mfa enpoints and enables mfa logins
"mfa": {
"upgrade_expires_secs": 180,
@@ -59,14 +63,14 @@
"fido": {
"challenge_size": 64,
- "attestation": "none",
"timeout": 60000,
- "site_name": "vaughnnugent.com",
+ "attestation_type": "none",
+ "site_name": "localhost",
- "authenticatorSelection": {
+ "authenticator_selection": {
"authenticatorAttachment": "cross-platform",
"requireResidentKey": false,
- "userVerification": "required"
+ "userVerification": "preferred"
}
}
},
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs
new file mode 100644
index 0000000..e0160e1
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/CoseEncodings.cs
@@ -0,0 +1,62 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: CoseEncodings.cs
+*
+* CoseEncodings.cs is part of VNLib.Plugins.Essentials.Accounts which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA
+{
+ internal static class CoseEncodings
+ {
+ public static int GetCodeFromAlg(string algName)
+ {
+ return algName switch
+ {
+ "ES256" => -7,
+ "ES384" => -35,
+ "ES512" => -36,
+ _ => 0
+ };
+ }
+
+ public static string GetAlgFromCode(int code)
+ {
+ return code switch
+ {
+ -7 => "ES256",
+ -35 => "ES384",
+ -36 => "ES512",
+ _ => string.Empty
+ };
+ }
+
+ public static string GetCurveFromCode(int code)
+ {
+ return code switch
+ {
+ -7 => "P-256",
+ -35 => "P-384",
+ -36 => "P-521",
+ _ => string.Empty
+ };
+ }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs
new file mode 100644
index 0000000..aedb2a7
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatedRequest.cs
@@ -0,0 +1,50 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+
+ internal sealed class FidoAuthenticatedRequest
+ {
+ /// <summary>
+ /// Base64 encoded device ID
+ /// </summary>
+ [JsonPropertyName("id")]
+ public string Base64Id { get; set; } = string.Empty;
+
+ /// <summary>
+ /// The device attachment type
+ /// </summary>
+ [JsonPropertyName("authenticatorAttachment")]
+ public string? Attachment { get; set; }
+
+ /// <summary>
+ /// The device registration response data
+ /// </summary>
+ [JsonPropertyName("response")]
+ public FidoAuthenticatorResponse? Response { get; set; }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs
new file mode 100644
index 0000000..8f0ac7f
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorResponse.cs
@@ -0,0 +1,50 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ internal sealed class FidoAuthenticatorResponse
+ {
+ [JsonPropertyName("id")]
+ public string DeviceId { get; set; } = string.Empty;
+
+ [JsonPropertyName("publicKey")]
+ public string? Base64PublicKey { get; set; }
+
+ [JsonPropertyName("publicKeyAlgorithm")]
+ public int? CoseAlgorithmNumber { get; set; }
+
+ [JsonPropertyName("clientDataJSON")]
+ public string? Base64ClientData { get; set; }
+
+ [JsonPropertyName("authenticatorData")]
+ public string? Base64AuthenticatorData { get; set; }
+
+ [JsonPropertyName("attestationObject")]
+ public string? Base64Attestation { get; set; }
+
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs
index 301113c..8699537 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoAuthenticatorSelection.cs
@@ -28,8 +28,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
{
internal sealed class FidoAuthenticatorSelection
{
- [JsonPropertyName("requireResidentKey")]
- public bool RequireResidentKey { get; set; } = false;
+ [JsonPropertyName("residentKey")]
+ public string? RequireResidentKey { get; set; } = "discouraged";
[JsonPropertyName("authenticatorAttachment")]
public string? AuthenticatorAttachment { get; set; } = "cross-platform";
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs
new file mode 100644
index 0000000..e6b567d
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoClientDataJson.cs
@@ -0,0 +1,40 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ internal sealed class FidoClientDataJson
+ {
+ [JsonPropertyName("challenge")]
+ public string? Base64Challenge { get; set; }
+
+ [JsonPropertyName("origin")]
+ public string? Origin { get; set; }
+
+ [JsonPropertyName("type")]
+ public string? Type { get; set; }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs
new file mode 100644
index 0000000..3f3e930
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoConfig.cs
@@ -0,0 +1,85 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+using FluentValidation;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+
+ internal sealed class FidoConfig
+ {
+
+ [JsonPropertyName("challenge_size")]
+ public int ChallangeSize { get; set; }
+
+ [JsonPropertyName("timeout")]
+ public int Timeout { get; set; }
+
+ [JsonPropertyName("site_name")]
+ public string? SiteName { get; set; }
+
+ [JsonPropertyName("attestation_type")]
+ public string? AttestationType { get; set; }
+
+ [JsonPropertyName("authenticator_selection")]
+ public FidoAuthenticatorSelection? FIDOAuthSelection { get; set; }
+
+ [JsonPropertyName("transport")]
+ public string[] Transports { get; set; } = ["usb", "nfc", "ble"];
+
+ internal static IValidator<FidoConfig> GetValidator()
+ {
+ InlineValidator<FidoConfig> val = new();
+
+ val.RuleFor(c => c.ChallangeSize)
+ .InclusiveBetween(1, 4096)
+ .WithMessage("Fido 'challenge_size' must be between 1 and 4096 bytes");
+
+ val.RuleFor(c => c.Timeout)
+ .InclusiveBetween(1, int.MaxValue)
+ .WithMessage("Fido 'timeout' must be between 1 and 600 seconds");
+
+ val.RuleFor(c => c.SiteName)
+ .NotEmpty()
+ .WithMessage("Fido 'site_name' must be provided");
+
+ val.RuleFor(c => c.AttestationType)
+ .NotEmpty()
+ .WithMessage("Fido 'attestation_type' must be provided");
+
+ val.RuleFor(c => c.FIDOAuthSelection)
+ .NotNull()
+ .WithMessage("Fido 'authenticator_selection' must be provided");
+
+ val.RuleFor(c => c.Transports)
+ .NotEmpty()
+ .ForEach(p => p.NotEmpty())
+ .WithMessage("Fido 'transport' must be provided");
+
+ return val;
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs
new file mode 100644
index 0000000..2d7e01e
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoDeviceCredential.cs
@@ -0,0 +1,44 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoDeviceCredential.cs
+*
+* FidoDeviceCredential.cs is part of VNLib.Plugins.Essentials.Accounts which
+* is part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ public sealed class FidoDeviceCredential
+ {
+ [JsonPropertyName("n")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("id")]
+ public string Base64UrlId { get; set; }
+
+ [JsonPropertyName("pk")]
+ public string Base64PublicKey { get; set; }
+
+ [JsonPropertyName("alg")]
+ public int CoseAlgId { get; set; }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs
new file mode 100644
index 0000000..63aa46e
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoMfaProcessor.cs
@@ -0,0 +1,181 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: LoginEndpoint.cs
+*
+* LoginEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Hashing.IdentityUtility;
+
+using VNLib.Utils;
+using VNLib.Utils.Memory;
+using VNLib.Hashing;
+using VNLib.Utils.Extensions;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ internal sealed class FidoMfaProcessor(FidoConfig conf) : IMfaProcessor
+ {
+ const string JwtClaimKey = "fido";
+
+ ///<inheritdoc/>
+ public MFAType Type => MFAType.TOTP;
+
+ ///<inheritdoc/>
+ public void ExtendUpgradePayload(in JwtPayload message, IUser user)
+ {
+ FidoDeviceCredential[]? devices = user.FidoGetAllCredentials();
+
+ if(devices == null || devices.Length == 0)
+ {
+ return;
+ }
+
+ using UnsafeMemoryHandle<byte> challBuffer = MemoryUtil.UnsafeAlloc(conf.ChallangeSize, true);
+
+ RandomHash.GetRandomBytes(challBuffer.Span);
+
+ message.AddClaim(
+ claim: JwtClaimKey,
+ value: GetChallengeData(challBuffer.Span, devices)
+ );
+ }
+
+ ///<inheritdoc/>
+ public bool MethodEnabledForUser(IUser user) => user.FidoEnabled();
+
+ ///<inheritdoc/>
+ public bool VerifyResponse(MfaChallenge upgrade, IUser user, JsonDocument result)
+ {
+ FidoUpgradeResponse? fidoResponse = result.RootElement.GetProperty("fido")
+ .Deserialize<FidoUpgradeResponse>();
+
+ if (fidoResponse is null)
+ {
+ return false;
+ }
+
+
+
+ return false;
+ }
+
+ private ERRNO RecoverFidoChallenge(JsonDocument chalUpgrade, Span<byte> outBuffer)
+ {
+ /*
+ * When this function is called it must be assumed that the mfa token signature
+ * was verified so it doesn't need to be checked again.
+ *
+ * The only data we need to recover from the upgrade is the fido challenge data.
+ * to verify it's signature.
+ */
+
+ string? chalJwtData = chalUpgrade.RootElement.GetPropString("mfa");
+ if (string.IsNullOrWhiteSpace(chalJwtData))
+ {
+ return 0;
+ }
+
+ using JsonWebToken jwt = JsonWebToken.Parse(chalJwtData);
+
+ using JsonDocument chalDoc = jwt.GetPayload();
+
+ string challenge = chalDoc.RootElement.GetProperty(JwtClaimKey)
+ .GetProperty("challenge")
+ .GetString()!;
+
+ return VnEncoding.Base64UrlDecode(challenge, outBuffer);
+ }
+
+ private FidoDevUpgradeJson GetChallengeData(ReadOnlySpan<byte> challenge, FidoDeviceCredential[] devices)
+ {
+ return new FidoDevUpgradeJson
+ {
+ Base64UrlChallange = VnEncoding.ToBase64UrlSafeString(challenge, false),
+
+ Timeout = conf.Timeout,
+
+ Credentials = devices.Select(p => new CredentialInfoJson
+ {
+ Base64UrlId = p.Base64UrlId,
+ Transports = conf.Transports,
+ Type = "public-key"
+ }).ToArray(),
+ };
+ }
+
+ sealed class FidoDevUpgradeJson
+ {
+ [JsonPropertyName("challenge")]
+ public string Base64UrlChallange { get; set; } = string.Empty;
+
+ [JsonPropertyName("allowCredentials")]
+ public CredentialInfoJson[] Credentials { get; set; } = Array.Empty<CredentialInfoJson>();
+
+ [JsonPropertyName("timeout")]
+ public int Timeout { get; set; }
+ }
+
+ sealed class CredentialInfoJson
+ {
+ [JsonPropertyName("id")]
+ public string Base64UrlId { get; set; } = string.Empty;
+
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "public-key";
+
+ [JsonPropertyName("transports")]
+ public string[] Transports { get; set; } = Array.Empty<string>();
+ }
+ }
+
+ internal sealed class FidoUpgradeResponse
+ {
+ [JsonPropertyName("id")]
+ public string Base64UrlId { get; set; } = string.Empty;
+
+ [JsonPropertyName("authenticatorAttachment")]
+ public string? Attachment { get; set; }
+
+ [JsonPropertyName("response")]
+ public FidoAuthenticatorAssertionResponse? Response { get; set; }
+ }
+
+ internal sealed class FidoAuthenticatorAssertionResponse
+ {
+ [JsonPropertyName("authenticatorData")]
+ public string Base64UrlAuthData { get; set; } = string.Empty;
+
+ [JsonPropertyName("clientDataJSON")]
+ public string Base64UrlClientData { get; set; } = string.Empty;
+
+ [JsonPropertyName("signature")]
+ public string Base64UrlSignature { get; set; } = string.Empty;
+
+ [JsonPropertyName("userHandle")]
+ public string? Base64UrlUserHandle { get; set; }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs
index 0bdd563..8875e12 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoPubkeyAlgorithm.cs
@@ -26,10 +26,10 @@ using System.Text.Json.Serialization;
namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
{
- internal sealed class FidoPubkeyAlgorithm
+ internal sealed class FidoPubkeyAlgorithm(int algId)
{
[JsonPropertyName("alg")]
- public int AlgId { get; set; }
+ public int AlgId { get; set; } = algId;
[JsonPropertyName("type")]
public string Type { get; set; } = "public-key";
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs
index 4dfa036..9df1461 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoRegistrationMessage.cs
@@ -42,7 +42,7 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
public FidoRelyingParty RelyingParty { get; set; } = new();
[JsonPropertyName("attestation")]
- public string AttestationType { get; set; } = "none";
+ public string? AttestationType { get; set; } = "none";
[JsonPropertyName("user")]
public FidoUserData User { get; set; } = new();
@@ -51,6 +51,6 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
public FidoPubkeyAlgorithm[]? PubKeyCredParams { get; set; }
[JsonPropertyName("authenticatorSelection")]
- public FidoAuthenticatorSelection AuthSelection { get; set; } = new();
+ public FidoAuthenticatorSelection? AuthSelection { get; set; } = new();
}
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs
new file mode 100644
index 0000000..8c605dc
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserCredential.cs
@@ -0,0 +1,43 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: FidoConfig.cs
+*
+* FidoConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ internal sealed class FidoUserCredential
+ {
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("tp")]
+ public string Algorithm { get; set; } = string.Empty;
+
+ [JsonPropertyName("pk")]
+ public string PublicKey { get; set; } = string.Empty;
+
+ [JsonPropertyName("alg")]
+ public int AlgorithmId { get; set; }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs
index aadef29..3c64c7d 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/FidoUserData.cs
@@ -22,23 +22,10 @@
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
-using System;
-using System.Buffers.Binary;
-using System.Formats.Cbor;
using System.Text.Json.Serialization;
-using VNLib.Hashing.IdentityUtility;
-
namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
{
- internal sealed class FidoAuthenticatorResponse
- {
- [JsonPropertyName("client_data")]
- public string? Base64ClientDataJson { get; set; }
-
- [JsonPropertyName("attestation_object")]
- public string? Base64AttestationObject { get; set; }
- }
internal sealed class FidoUserData
{
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs
new file mode 100644
index 0000000..f6bb748
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Fido/UserFidoMfaExtensions.cs
@@ -0,0 +1,136 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: UserFidoMfaExtensions.cs
+*
+* UserFidoMfaExtensions.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+
+using VNLib.Plugins.Essentials.Users;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Fido
+{
+ /// <summary>
+ /// Provides Fido/Webauthn authentication extension methods for users
+ /// </summary>
+ public static class UserFidoMfaExtensions
+ {
+ public const string FidoUserStoreKey = "mfa.fido";
+
+ public const int MaxEncodedSize = 1200; //Aribtrary size limit for the user account object
+ public const int AssumedKeySize = 320; //Based on a p384 key base64 encoded
+
+ /// <summary>
+ /// Gets a value that determines if the user has PKI enabled
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if the user has a PKI key stored in their user account</returns>
+ public static bool FidoEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[FidoUserStoreKey]);
+
+ /// <summary>
+ /// Disables all Fido authentication for the current user
+ /// </summary>
+ /// <param name="user"></param>
+ public static void FidoDisable(this IUser user) => user[FidoUserStoreKey] = null!;
+
+ /// <summary>
+ /// Attempts to determine if another fido key can be encoded and stored in the
+ /// user's account object. This assumes that the key is roughly 320 bytes
+ /// when encoded.
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if there is enough key space to store another key, false otherwise</returns>
+ public static bool FidoCanAddKey(this IUser user)
+ {
+ string rawData = user[FidoUserStoreKey];
+ if (string.IsNullOrWhiteSpace(rawData))
+ {
+ return true;
+ }
+
+ return rawData.Length + AssumedKeySize < MaxEncodedSize;
+ }
+
+ /// <summary>
+ /// Stores an array of public keys in the user's account object
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="creds">The array of device credentials to store for the user</param>
+ public static void FidoSetCredentials(this IUser user, FidoDeviceCredential[]? creds)
+ => UserEnocdedData.Encode(user, FidoUserStoreKey, creds);
+
+ /// <summary>
+ /// Gets all public keys stored in the user's account object
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>The array of device credentials if they exist</returns>
+ public static FidoDeviceCredential[]? FidoGetAllCredentials(this IUser user)
+ => UserEnocdedData.Decode<FidoDeviceCredential[]>(user, FidoUserStoreKey);
+
+ /// <summary>
+ /// Removes a single pki key by it's id
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="credId">The id of the credential to remove</param>
+ public static void FidoRemoveCredential(this IUser user, string credId)
+ {
+ FidoDeviceCredential[]? keys = user.FidoGetAllCredentials();
+ if (keys == null)
+ {
+ return;
+ }
+
+ //Remove the key and store a new array without it
+
+ FidoSetCredentials(
+ user: user,
+ creds: keys.Where(k => !string.Equals(credId, k.Base64UrlId, StringComparison.Ordinal)).ToArray()
+ );
+ }
+
+ /// <summary>
+ /// Adds a single pki key to the user's account object, or overwrites
+ /// and existing key with the same id
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="key">The key to add to the list of user-keys</param>
+ public static void FidoAddCredential(this IUser user, FidoDeviceCredential key)
+ {
+ FidoDeviceCredential[]? keys = user.FidoGetAllCredentials();
+
+ if (keys == null)
+ {
+ //Add a single key if none exist
+ keys = [key];
+ }
+ else
+ {
+ //remove the key if it already exists, then append the new key
+ keys = keys.Where(k => !string.Equals(key.Base64UrlId, k.Base64UrlId, StringComparison.Ordinal))
+ .Append(key)
+ .ToArray();
+ }
+
+ user.FidoSetCredentials(keys);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs
new file mode 100644
index 0000000..95679c7
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/IMfaProcessor.cs
@@ -0,0 +1,43 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: IMfaProcessor.cs
+*
+* IMfaProcessor.cs is part of VNLib.Plugins.Essentials.Accounts which is
+* part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json;
+
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Plugins.Essentials.Users;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA
+{
+ internal interface IMfaProcessor
+ {
+ MFAType Type { get; }
+
+ bool MethodEnabledForUser(IUser user);
+
+ void ExtendUpgradePayload(in JwtPayload message, IUser user);
+
+ bool VerifyResponse(MfaChallenge upgrade, IUser user, JsonDocument result);
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs
index 9dfd183..e44006a 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAConfig.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2023 Vaughn Nugent
+* Copyright (c) 2024 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -27,9 +27,9 @@ using System.Text.Json.Serialization;
using FluentValidation;
-using VNLib.Hashing;
-using VNLib.Plugins.Extensions.Loading;
using VNLib.Plugins.Essentials.Accounts.MFA.Fido;
+using VNLib.Plugins.Essentials.Accounts.MFA.Totp;
+using VNLib.Plugins.Extensions.Loading;
namespace VNLib.Plugins.Essentials.Accounts.MFA
{
@@ -53,6 +53,14 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
.GreaterThanOrEqualTo(8)
.WithMessage("You must configure a signing key size of 8 bytes or larger");
+ val.RuleFor(c => c.FIDOConfig)
+ .SetValidator(FidoConfig.GetValidator()!)
+ .When(c => c.FIDOConfig != null);
+
+ val.RuleFor(c => c.TOTPConfig)
+ .SetValidator(TOTPConfig.GetValidator()!)
+ .When(c => c.TOTPConfig != null);
+
return val;
}
@@ -61,18 +69,9 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
[JsonPropertyName("totp")]
public TOTPConfig? TOTPConfig { get; set; }
- [JsonIgnore]
- public bool TOTPEnabled => TOTPConfig?.IssuerName != null;
-
[JsonPropertyName("fido")]
public FidoConfig? FIDOConfig { get; set; }
- [JsonIgnore]
- public bool FIDOEnabled => FIDOConfig?.FIDOSiteName != null;
-
- [JsonIgnore]
- public TimeSpan UpgradeValidFor { get; private set; } = TimeSpan.FromSeconds(120);
-
[JsonPropertyName("upgrade_expires_secs")]
public int UpgradeExpSeconds
{
@@ -82,113 +81,20 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
[JsonPropertyName("nonce_size")]
public int NonceLenBytes { get; set; } = 16;
+
[JsonPropertyName("upgrade_size")]
public int UpgradeKeyBytes { get; set; } = 32;
-
-
- public void Validate()
- {
- //Validate the current confige before child configs
- _validator.ValidateAndThrow(this);
-
- TOTPConfig?.Validate();
- FIDOConfig?.Validate();
- }
- }
-
- internal class TOTPConfig : IOnConfigValidation
- {
- private static IValidator<TOTPConfig> GetValidator()
- {
- InlineValidator<TOTPConfig> val = new();
-
- val.RuleFor(c => c.IssuerName)
- .NotEmpty();
-
- val.RuleFor(c => c.PeriodSec)
- .InclusiveBetween(1, 600);
-
- val.RuleFor(c => c.TOTPAlg)
- .Must(a => a != HashAlg.None)
- .WithMessage("TOTP Algorithim name must not be NONE");
-
- val.RuleFor(c => c.TOTPDigits)
- .GreaterThan(1)
- .WithMessage("You should have more than 1 digit for a totp code");
-
- //We dont neet to check window steps, the user may want to configure 0 or more
- val.RuleFor(c => c.TOTPTimeWindowSteps);
-
- val.RuleFor(c => c.TOTPSecretBytes)
- .GreaterThan(8)
- .WithMessage("You should configure a larger TOTP secret size for better security");
-
- return val;
- }
[JsonIgnore]
- private static IValidator<TOTPConfig> _validator { get; } = GetValidator();
+ public bool TOTPEnabled => TOTPConfig?.Enabled == true;
- [JsonPropertyName("issuer")]
- public string? IssuerName { get; set; }
-
- [JsonPropertyName("period_sec")]
- public int PeriodSec
- {
- get => (int)TOTPPeriod.TotalSeconds;
- set => TOTPPeriod = TimeSpan.FromSeconds(value);
- }
[JsonIgnore]
- public TimeSpan TOTPPeriod { get; set; } = TimeSpan.FromSeconds(30);
-
+ public bool FIDOEnabled => FIDOConfig != null;
- [JsonPropertyName("algorithm")]
- public string AlgName
- {
- get => TOTPAlg.ToString();
- set => TOTPAlg = Enum.Parse<HashAlg>(value.ToUpper(null));
- }
[JsonIgnore]
- public HashAlg TOTPAlg { get; set; } = HashAlg.SHA1;
-
- [JsonPropertyName("digits")]
- public int TOTPDigits { get; set; } = 6;
-
- [JsonPropertyName("secret_size")]
- public int TOTPSecretBytes { get; set; } = 32;
-
- [JsonPropertyName("window_size")]
- public int TOTPTimeWindowSteps { get; set; } = 1;
-
- public void Validate()
- {
- //Validate the current instance on the
- _validator.ValidateAndThrow(this);
- }
- }
-
- internal class FidoConfig : IOnConfigValidation
- {
- private static IValidator<FidoConfig> GetValidator()
- {
- InlineValidator<FidoConfig> val = new();
-
-
- return val;
- }
-
- private static IValidator<FidoConfig> _validator { get; } = GetValidator();
+ public TimeSpan UpgradeValidFor { get; private set; } = TimeSpan.FromSeconds(120);
-
- public int FIDOChallangeSize { get; }
- public int FIDOTimeout { get; }
- public string? FIDOSiteName { get; }
- public string? FIDOAttestationType { get; }
- public FidoAuthenticatorSelection? FIDOAuthSelection { get; }
- public void Validate()
- {
- _validator.ValidateAndThrow(this);
- }
+ public void Validate() => _validator.ValidateAndThrow(this);
}
}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs
new file mode 100644
index 0000000..dc52c59
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaAuthManager.cs
@@ -0,0 +1,248 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: MfaAuthManager.cs
+*
+* MfaAuthManager.cs is part of VNLib.Plugins.Essentials.Accounts which is
+* part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Collections.Generic;
+
+using FluentValidation;
+
+using VNLib.Utils;
+using VNLib.Hashing;
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Utils.Memory;
+using VNLib.Utils.Extensions;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Plugins.Essentials.Extensions;
+using VNLib.Plugins.Essentials.Sessions;
+using VNLib.Plugins.Extensions.Loading;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA
+{
+
+ internal sealed class MfaAuthManager(MFAConfig config, IMfaProcessor[] processors)
+ {
+
+ public const string SESSION_SIG_KEY = "mfa.sig";
+ private const HashAlg SigAlg = HashAlg.SHA256;
+ private static readonly byte[] UpgradeHeader = CompileJwtHeader();
+
+ public bool Armed => processors.Length > 0;
+
+ /// <summary>
+ /// Determines if the user has any MFA methods enabled and
+ /// should continue with an MFA upgrade
+ /// </summary>
+ /// <param name="user">The user to upgrade the mfa request on</param>
+ /// <returns>True if the user has any MFA methods enabled</returns>
+ public bool HasMfaEnabled(IUser user)
+ {
+ return processors.Any(p => p.MethodEnabledForUser(user));
+ }
+
+ /// <summary>
+ /// Gets the upgrade message to send back to the client to
+ /// continue the MFA upgrade process
+ /// </summary>
+ /// <param name="entity">The connection to upgrade</param>
+ /// <param name="user">The user wishing to upgrade MFA methods</param>
+ /// <param name="login">The login message containing required client authentication data</param>
+ /// <returns>The encoded upgrade message to send to the client</returns>
+ public string GetChallengeMessage(HttpEntity entity, IUser user, LoginMessage login)
+ {
+ string clientJwt = string.Empty, secret = string.Empty;
+
+ /*
+ * Upgrade tells the client what methods are suppoted by
+ * the server specific to a user. The client may choose
+ * to use any of the methods.
+ */
+ MfaChallenge upgrade = new()
+ {
+ //Set totp upgrade type
+ Types = GetEnbaledTypesForUser(user),
+
+ //Store login message details
+ UserName = login.UserName,
+ ClientId = login.ClientId,
+ PublicKey = login.ClientPublicKey,
+ ClientLocalLanguage = login.LocalLanguage,
+ };
+
+ GetUpgradeMessage(upgrade, ref clientJwt, ref secret);
+
+ //Store the upgrade message in the session
+ SetUpgradeSecret(in entity.Session, secret);
+
+ return clientJwt;
+ }
+
+ /// <summary>
+ /// Recovers and validates a previously signed challenge message from the client
+ /// </summary>
+ /// <param name="entity">The entity requesting the completation</param>
+ /// <param name="result">The client's result of an mfa upgrade operation</param>
+ /// <returns>The </returns>
+ public MfaChallenge? GetChallengeData(HttpEntity entity, JsonDocument result)
+ {
+ //Recover upgrade jwt
+ string? upgradeJwt = result.RootElement.GetPropString("upgrade");
+ string? storedSecret = GetUpgradeSecret(in entity.Session);
+
+ if (string.IsNullOrEmpty(upgradeJwt) || string.IsNullOrEmpty(storedSecret))
+ {
+ return null;
+ }
+
+ //Recover upgrade data from upgrade message
+ return RecoverChallange(entity.RequestedTimeUtc, upgradeJwt, storedSecret);
+ }
+
+ /// <summary>
+ /// Verifies the response from the client to the MFA upgrade request
+ /// and determines if the upgrade was successful
+ /// </summary>
+ /// <param name="entity"></param>
+ /// <param name="upgrade">The validated upgrade message returned by the client</param>
+ /// <param name="user">The user account to validate against</param>
+ /// <param name="result">The client's result message from the upgrade challenge</param>
+ /// <returns>True if the client successfully validated</returns>
+ public bool VerifyResponse(HttpEntity entity, MfaChallenge upgrade, IUser user, JsonDocument result)
+ {
+ string? desiredMfaType = entity.QueryArgs.GetValueOrDefault("mfa");
+
+ if (!Enum.TryParse(desiredMfaType, true, out MFAType desiredType))
+ {
+ return false;
+ }
+
+ //See if upgrade allows the desired type
+ if (!upgrade.Types.Contains(desiredType))
+ {
+ return false;
+ }
+
+ //Get the processor for the desired type
+ IMfaProcessor? processor = processors.FirstOrDefault(p => p.Type == desiredType);
+
+ if (processor == null)
+ {
+ return false;
+ }
+
+ //Verify the response using the desired processor
+ return processor.VerifyResponse(upgrade, user, result);
+ }
+
+ public void InvalidateUpgrade(HttpEntity entity)
+ {
+ SetUpgradeSecret(in entity.Session, null);
+ }
+
+ private MFAType[] GetEnbaledTypesForUser(IUser user)
+ {
+ return processors.Where(p => p.MethodEnabledForUser(user))
+ .Select(static p => p.Type)
+ .ToArray();
+ }
+
+ private static void SetUpgradeSecret(ref readonly SessionInfo session, string? base32Signature)
+ => session[SESSION_SIG_KEY] = base32Signature!;
+
+ private static string? GetUpgradeSecret(ref readonly SessionInfo session)
+ => session[SESSION_SIG_KEY];
+
+ private MfaChallenge? RecoverChallange(DateTimeOffset now, string upgradeJwtString, string base32Secret)
+ {
+ using JsonWebToken jwt = JsonWebToken.Parse(upgradeJwtString);
+
+ byte[] secret = VnEncoding.FromBase32String(base32Secret)!;
+
+ try
+ {
+ if (!jwt.Verify(secret, SigAlg))
+ {
+ return null;
+ }
+ }
+ finally
+ {
+ //Erase secret
+ MemoryUtil.InitializeBlock(secret);
+ }
+
+ using JsonDocument doc = jwt.GetPayload();
+
+ //Recover issued at time
+ long iatMs = doc.RootElement.GetProperty("iat").GetInt64();
+ DateTimeOffset iat = DateTimeOffset.FromUnixTimeMilliseconds(iatMs);
+
+ if (iat.Add(config.UpgradeValidFor) < now)
+ {
+ //expired
+ return null;
+ }
+
+ //Recover the upgrade message
+ return doc.RootElement.GetProperty("upgrade").Deserialize<MfaChallenge>();
+ }
+
+ private void GetUpgradeMessage(MfaChallenge upgrade, ref string clientMessage, ref string secret)
+ {
+ //Add some random entropy to the upgrade message, to help prevent forgery
+ string entropy = RandomHash.GetRandomBase32(config.NonceLenBytes);
+ byte[] sigKey = RandomHash.GetRandomBytes(config.UpgradeKeyBytes);
+
+ using JsonWebToken upgradeJwt = new();
+
+ upgradeJwt.WriteHeader(UpgradeHeader);
+
+ string[] mfaTypes = upgrade.Types.Select(static t => t.ToString().ToLower(null)).ToArray();
+
+ upgradeJwt.InitPayloadClaim()
+ .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
+ .AddClaim("upgrade", upgrade)
+ .AddClaim("capabilities", mfaTypes)
+ .AddClaim("expires", config.UpgradeValidFor.TotalSeconds)
+ .AddClaim("a", entropy)
+ .CommitClaims();
+
+ upgradeJwt.Sign(sigKey, SigAlg);
+
+ clientMessage = upgradeJwt.Compile();
+ secret = VnEncoding.ToBase32String(sigKey);
+ }
+
+ private static byte[] CompileJwtHeader()
+ {
+ Dictionary<string, string> header = new()
+ {
+ { "alg","HS256" },
+ { "typ", "JWT" }
+ };
+ return JsonSerializer.SerializeToUtf8Bytes(header);
+ }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaChallenge.cs
index e69088a..e06b78c 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MFAUpgrade.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/MfaChallenge.cs
@@ -1,5 +1,5 @@
/*
-* Copyright (c) 2023 Vaughn Nugent
+* Copyright (c) 2024 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
@@ -26,7 +26,7 @@ using System.Text.Json.Serialization;
namespace VNLib.Plugins.Essentials.Accounts.MFA
{
- internal class MFAUpgrade : IClientSecInfo
+ internal class MfaChallenge : IClientSecInfo
{
/// <summary>
/// The login's client id specifier
@@ -41,8 +41,8 @@ namespace VNLib.Plugins.Essentials.Accounts.MFA
/// <summary>
/// The <see cref="MFAType"/> of the upgrade request
/// </summary>
- [JsonPropertyName("type")]
- public MFAType Type { get; set; }
+ [JsonPropertyName("types")]
+ public MFAType[] Types { get; set; }
/// <summary>
/// The a base64 encoded string of the user's
/// public key
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/OtpAuthPublicKey.cs
index a941852..2fd507d 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/PkiAuthPublicKey.cs
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/OtpAuthPublicKey.cs
@@ -1,11 +1,11 @@
/*
-* Copyright (c) 2023 Vaughn Nugent
+* Copyright (c) 2024 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Essentials.Accounts
-* File: PkiAuthPublicKey.cs
+* File: OtpAuthPublicKey.cs
*
-* PkiAuthPublicKey.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* OtpAuthPublicKey.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
* VNLib collection of libraries and utilities.
*
* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
@@ -26,12 +26,12 @@ using System.Text.Json.Serialization;
using VNLib.Hashing.IdentityUtility;
-namespace VNLib.Plugins.Essentials.Accounts.MFA
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Otp
{
/// <summary>
/// A json serializable JWK format public key for PKI authentication
/// </summary>
- public record class PkiAuthPublicKey : IJsonWebKey
+ public record class OtpAuthPublicKey : IJsonWebKey
{
[JsonPropertyName("kid")]
public string? KeyId { get; set; }
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs
new file mode 100644
index 0000000..97b1807
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Otp/UserOtpMfaExtensions.cs
@@ -0,0 +1,167 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: UserPkiMfaExtensions.cs
+*
+* UserPkiMfaExtensions.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Buffers;
+
+using VNLib.Utils.IO;
+using VNLib.Utils.Extensions;
+using VNLib.Hashing.IdentityUtility;
+using VNLib.Plugins.Essentials.Users;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Otp
+{
+
+ /// <summary>
+ /// Provides user extension methods for PKI specific MFA operations
+ /// </summary>
+ public static class UserOtpMfaExtensions
+ {
+ /// <summary>
+ /// The key used to store the user's encoded Otp public
+ /// keys in their account object
+ /// </summary>
+ public const string OtpUserStoreKey = "mfa.pki";
+
+ public const int MaxEncodedSize = 1200; //Aribtrary size limit for the user account object
+ public const int AssumedKeySize = 320; //Based on a p384 key base64 encoded
+
+ /// <summary>
+ /// Gets a value that determines if the user has PKI enabled
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if the user has a PKI key stored in their user account</returns>
+ public static bool OtpAuthEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[OtpUserStoreKey]);
+
+ /// <summary>
+ /// Disables PKI authentication for the current user
+ /// </summary>
+ /// <param name="user"></param>
+ public static void OtpDisable(this IUser user) => user[OtpUserStoreKey] = null!;
+
+ /// <summary>
+ /// Attempts to determine if another key can be encoded and stored in the
+ /// user's account object. This assumes that the key is roughly 320 bytes
+ /// when encoded.
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if there is enough key space to store another key, false otherwise</returns>
+ public static bool OtpCanAddKey(this IUser user)
+ {
+ string rawData = user[OtpUserStoreKey];
+ if (string.IsNullOrWhiteSpace(rawData))
+ {
+ return true;
+ }
+
+ return rawData.Length + AssumedKeySize < MaxEncodedSize;
+ }
+
+ /// <summary>
+ /// Verifies a PKI login JWT against the user's stored login key data
+ /// </summary>
+ /// <param name="user">The user requesting a login</param>
+ /// <param name="jwt">The login jwt to verify</param>
+ /// <param name="keyId">The id of the key that generated the request, it must match the id of the stored key</param>
+ /// <returns>True if the user has PKI enabled, the key was recovered, the key id matches, and the JWT signature is verified</returns>
+ public static bool OtpVerifyUserJWT(this IUser user, JsonWebToken jwt, string keyId)
+ {
+ /*
+ * Since multiple keys can be stored, we need to recover the key that matches the desired key id
+ */
+ OtpAuthPublicKey? pub = user.OtpGetAllPublicKeys()?.FirstOrDefault(p => string.Equals(keyId, p.KeyId, StringComparison.Ordinal));
+
+ if (pub == null)
+ {
+ return false;
+ }
+
+ //verify the jwt
+ return jwt.VerifyFromJwk(pub);
+ }
+
+ /// <summary>
+ /// Stores an array of public keys in the user's account object
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="authKeys">The array of jwk format keys to store for the user</param>
+ public static void OtpSetPublicKeys(this IUser user, OtpAuthPublicKey[]? authKeys)
+ => UserEnocdedData.Encode(user, OtpUserStoreKey, authKeys);
+
+ /// <summary>
+ /// Gets all public keys stored in the user's account object
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>The array of public keys if the exist</returns>
+ public static OtpAuthPublicKey[]? OtpGetAllPublicKeys(this IUser user)
+ => UserEnocdedData.Decode<OtpAuthPublicKey[]>(user, OtpUserStoreKey);
+
+ /// <summary>
+ /// Removes a single pki key by it's id
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="keyId">The id of the key to remove</param>
+ public static void OtpRemovePublicKey(this IUser user, string keyId)
+ {
+ OtpAuthPublicKey[]? keys = user.OtpGetAllPublicKeys();
+ if (keys == null)
+ {
+ return;
+ }
+
+ //Remove the key and store a new array without it
+
+ user.OtpSetPublicKeys(
+ authKeys: keys.Where(k => !string.Equals(keyId, k.KeyId, StringComparison.Ordinal)).ToArray()
+ );
+ }
+
+ /// <summary>
+ /// Adds a single pki key to the user's account object, or overwrites
+ /// and existing key with the same id
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="key">The key to add to the list of user-keys</param>
+ public static void OtpAddPublicKey(this IUser user, OtpAuthPublicKey key)
+ {
+ OtpAuthPublicKey[]? keys = user.OtpGetAllPublicKeys();
+
+ if (keys == null)
+ {
+ //Add a single key if none exist
+ keys = [key];
+ }
+ else
+ {
+ //remove the key if it already exists, then append the new key
+ keys = keys.Where(k => !string.Equals(key.KeyId, k.KeyId, StringComparison.Ordinal))
+ .Append(key)
+ .ToArray();
+ }
+
+ user.OtpSetPublicKeys(keys);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs
new file mode 100644
index 0000000..eba54fe
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TOTPConfig.cs
@@ -0,0 +1,100 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: TOTPConfig.cs
+*
+* TOTPConfig.cs is part of VNLib.Plugins.Essentials.Accounts which is part
+* of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Text.Json.Serialization;
+
+using FluentValidation;
+
+using VNLib.Hashing;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Totp
+{
+ internal sealed class TOTPConfig
+ {
+ [JsonPropertyName("issuer")]
+ public string? IssuerName { get; set; }
+
+ [JsonPropertyName("period_sec")]
+ public int PeriodSec
+ {
+ get => (int)TOTPPeriod.TotalSeconds;
+ set => TOTPPeriod = TimeSpan.FromSeconds(value);
+ }
+
+ [JsonPropertyName("algorithm")]
+ public string AlgName
+ {
+ get => TOTPAlg.ToString();
+ set => TOTPAlg = Enum.Parse<HashAlg>(value.ToUpper(null));
+ }
+
+ [JsonPropertyName("digits")]
+ public int TOTPDigits { get; set; } = 6;
+
+ [JsonPropertyName("secret_size")]
+ public int TOTPSecretBytes { get; set; } = 32;
+
+ [JsonPropertyName("window_size")]
+ public int TOTPTimeWindowSteps { get; set; } = 1;
+
+ [JsonIgnore]
+ public bool Enabled => IssuerName != null;
+
+ [JsonIgnore]
+ public HashAlg TOTPAlg { get; set; } = HashAlg.SHA1;
+
+ [JsonIgnore]
+ public TimeSpan TOTPPeriod { get; set; } = TimeSpan.FromSeconds(30);
+
+ internal static IValidator<TOTPConfig> GetValidator()
+ {
+ InlineValidator<TOTPConfig> val = new();
+
+ val.RuleFor(c => c.IssuerName)
+ .NotEmpty();
+
+ val.RuleFor(c => c.PeriodSec)
+ .InclusiveBetween(1, 600);
+
+ val.RuleFor(c => c.TOTPAlg)
+ .Must(a => a != HashAlg.None)
+ .WithMessage("TOTP Algorithim name must not be NONE");
+
+ val.RuleFor(c => c.TOTPDigits)
+ .GreaterThan(1)
+ .WithMessage("You should have more than 1 digit for a totp code");
+
+ //We dont neet to check window steps, the user may want to configure 0 or more
+ val.RuleFor(c => c.TOTPTimeWindowSteps);
+
+ val.RuleFor(c => c.TOTPSecretBytes)
+ .GreaterThan(8)
+ .WithMessage("You should configure a larger TOTP secret size for better security");
+
+ return val;
+ }
+
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs
new file mode 100644
index 0000000..393a745
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/TotpAuthProcessor.cs
@@ -0,0 +1,186 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: LoginEndpoint.cs
+*
+* LoginEndpoint.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Text.Json;
+using System.Diagnostics;
+
+using VNLib.Utils;
+using VNLib.Hashing;
+using VNLib.Utils.Memory;
+using VNLib.Plugins.Essentials.Users;
+using VNLib.Hashing.IdentityUtility;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Totp
+{
+ internal sealed class TotpAuthProcessor(TOTPConfig config) : IMfaProcessor
+ {
+ ///<inheritdoc/>
+ public MFAType Type => MFAType.TOTP;
+
+ ///<inheritdoc/>
+ public bool MethodEnabledForUser(IUser user) => user.TotpEnabled();
+
+ ///<inheritdoc/>
+ public bool VerifyResponse(MfaChallenge upgrade, IUser user, JsonDocument result)
+ {
+ if (!result.RootElement.TryGetProperty("code", out JsonElement codeEl)
+ || codeEl.ValueKind != JsonValueKind.Number)
+ {
+ return false;
+ }
+
+ return VerifyTOTP(user, codeEl.GetUInt32());
+ }
+
+ /// <summary>
+ /// Verfies the supplied TOTP code against the current user's totp codes
+ /// This method should not be used for verifying TOTP codes for authentication
+ /// </summary>
+ /// <param name="user">The user account to verify the TOTP code against</param>
+ /// <param name="code">The code to verify</param>
+ /// <returns>True if the user has TOTP configured and code matches against its TOTP secret entry, false otherwise</returns>
+ /// <exception cref="FormatException"></exception>
+ /// <exception cref="OutOfMemoryException"></exception>
+ internal bool VerifyTOTP(IUser user, uint code)
+ {
+ //Get the base32 TOTP secret for the user and make sure its actually set
+ string base32Secret = user.TotpGetSecret();
+
+ if (string.IsNullOrWhiteSpace(base32Secret))
+ {
+ return false;
+ }
+
+ int length = base32Secret.Length;
+ bool isValid;
+
+ if (length > 256)
+ {
+ using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(base32Secret.Length, true);
+
+ ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer.Span);
+
+ //Verify the TOTP using the decrypted secret
+ isValid = count && VerifyTotpCode(code, buffer.AsSpan(0, count));
+
+ MemoryUtil.InitializeBlock(
+ ref buffer.GetReference(),
+ buffer.IntLength
+ );
+ }
+ else
+ {
+ Span<byte> buffer = stackalloc byte[base32Secret.Length];
+
+ ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer);
+
+ //Verify the TOTP using the decrypted secret
+ isValid = count && VerifyTotpCode(code, buffer[..(int)count]);
+
+ MemoryUtil.InitializeBlock(buffer);
+ }
+
+ return isValid;
+ }
+
+ private bool VerifyTotpCode(uint totpCode, ReadOnlySpan<byte> userSecret)
+ {
+ /*
+ * A basic attempt at a constant time TOTP verification, run
+ * the calculation a fixed number of times, regardless of the resutls
+ */
+ bool codeMatches = false;
+
+ //cache current time
+ DateTimeOffset currentUtc = DateTimeOffset.UtcNow;
+
+ //Start the current window with the minimum window
+ int currenStep = -config.TOTPTimeWindowSteps;
+
+ Span<byte> stepBuffer = stackalloc byte[sizeof(long)];
+ Span<byte> hashBuffer = stackalloc byte[(int)config.TOTPAlg];
+
+ //Run the loop at least once to allow a 0 step tight window
+ do
+ {
+ //Calculate the window by multiplying the window by the current step, then add it to the current time offset to produce a new window
+ DateTimeOffset window = currentUtc.Add(config.TOTPPeriod.Multiply(currenStep));
+
+ //calculate the time step
+ long timeStep = (long)Math.Floor(window.ToUnixTimeSeconds() / config.TOTPPeriod.TotalSeconds);
+
+ //try to compute the hash, must always be storable in the buffer
+ bool writeResult = BitConverter.TryWriteBytes(stepBuffer, timeStep);
+ Debug.Assert(writeResult, "Failed to format the time step buffer because the buffer size was not large enough");
+
+ //If platform is little endian, reverse the byte order
+ if (BitConverter.IsLittleEndian)
+ {
+ stepBuffer.Reverse();
+ }
+
+ ERRNO result = ManagedHash.ComputeHmac(userSecret, stepBuffer, hashBuffer, config.TOTPAlg);
+
+ if (result < 1)
+ {
+ throw new InternalBufferTooSmallException("Failed to compute TOTP time step hash because the buffer was too small");
+ }
+
+ codeMatches |= totpCode == CalcTOTPCode(config.TOTPDigits, hashBuffer[..(int)result]);
+
+ currenStep++;
+
+ } while (currenStep <= config.TOTPTimeWindowSteps);
+
+ return codeMatches;
+ }
+
+ private static uint CalcTOTPCode(int digits, ReadOnlySpan<byte> hash)
+ {
+ //Calculate the offset, RFC defines, the lower 4 bits of the last byte in the hash output
+ byte offset = (byte)(hash[^1] & 0x0Fu);
+
+ uint TOTPCode;
+ if (BitConverter.IsLittleEndian)
+ {
+ //Store the code components
+ TOTPCode = (hash[offset] & 0x7Fu) << 24 | (hash[offset + 1] & 0xFFu) << 16 | (hash[offset + 2] & 0xFFu) << 8 | hash[offset + 3] & 0xFFu;
+ }
+ else
+ {
+ //Store the code components (In reverse order for big-endian machines)
+ TOTPCode = (hash[offset + 3] & 0x7Fu) << 24 | (hash[offset + 2] & 0xFFu) << 16 | (hash[offset + 1] & 0xFFu) << 8 | hash[offset] & 0xFFu;
+ }
+ //calculate the modulus value
+ TOTPCode %= (uint)Math.Pow(10, digits);
+ return TOTPCode;
+ }
+
+ public void ExtendUpgradePayload(in JwtPayload message, IUser user)
+ { }
+ }
+} \ No newline at end of file
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs
new file mode 100644
index 0000000..c500a7e
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/Totp/UserTotpMfaExtensions.cs
@@ -0,0 +1,84 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: UserTotpMfaExtensions.cs
+*
+* UserTotpMfaExtensions.cs is part of VNLib.Plugins.Essentials.Accounts which is
+* part of the larger VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+
+using VNLib.Hashing;
+using VNLib.Utils;
+using VNLib.Plugins.Essentials.Users;
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA.Totp
+{
+ public static class UserTotpMfaExtensions
+ {
+ public const string TOTP_KEY_ENTRY = "mfa.totp";
+
+ /// <summary>
+ /// Recovers the base32 encoded TOTP secret for the current user
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>The base32 encoded TOTP secret, or an emtpy string (user spec) if not set</returns>
+ public static string TotpGetSecret(this IUser user) => user[TOTP_KEY_ENTRY];
+
+ /// <summary>
+ /// Stores or removes the current user's TOTP secret, stored in base32 format
+ /// </summary>
+ /// <param name="user"></param>
+ /// <param name="secret">The base32 encoded TOTP secret</param>
+ public static void TotpSetSecret(this IUser user, string? secret) => user[TOTP_KEY_ENTRY] = secret!;
+
+ /// <summary>
+ /// Determines if the user account has TOTP enabled
+ /// </summary>
+ /// <param name="user"></param>
+ /// <returns>True if the user has totp enabled, false otherwise</returns>
+ public static bool TotpEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]);
+
+ /// <summary>
+ /// Disables TOTP for the current user
+ /// </summary>
+ /// <param name="user"></param>
+ public static void TotpDisable(this IUser user) => user[TOTP_KEY_ENTRY] = null!;
+
+ /// <summary>
+ /// Generates/overwrites the current user's TOTP secret entry and returns a
+ /// byte array of the generated secret bytes
+ /// </summary>
+ /// <param name="config">The system MFA configuration</param>
+ /// <returns>The raw secret that was encrypted and stored in the user's object</returns>
+ /// <exception cref="OutOfMemoryException"></exception>
+ internal static byte[] MFAGenreateTOTPSecret(this IUser user, MFAConfig config)
+ {
+ _ = config.TOTPConfig ?? throw new NotSupportedException("The loaded configuration does not support TOTP");
+ //Generate a random key
+ byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPConfig.TOTPSecretBytes);
+ //Store secret in user storage
+ user.TotpSetSecret(VnEncoding.ToBase32String(newSecret, false));
+ //return the raw secret bytes
+ return newSecret;
+ }
+
+
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs
new file mode 100644
index 0000000..8711fb9
--- /dev/null
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserEnocdedData.cs
@@ -0,0 +1,99 @@
+/*
+* Copyright (c) 2024 Vaughn Nugent
+*
+* Library: VNLib
+* Package: VNLib.Plugins.Essentials.Accounts
+* File: MfaEncodedData.cs
+*
+* MfaEncodedData.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
+* VNLib collection of libraries and utilities.
+*
+* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Text.Json;
+
+using VNLib.Utils;
+using VNLib.Utils.IO;
+using VNLib.Utils.Memory;
+
+
+namespace VNLib.Plugins.Essentials.Accounts.MFA
+{
+ internal static class UserEnocdedData
+ {
+ /// <summary>
+ /// Recovers encoded items from the user's account object
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="store">The data store to read encoded data from</param>
+ /// <param name="index">The property index in the user fields to recover the objects from</param>
+ /// <returns>The encoded properties from the desired user index</returns>
+ public static T? Decode<T>(IIndexable<string, string> store, string index) where T : class
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentException.ThrowIfNullOrWhiteSpace(index);
+
+ string? encodedData = store[index];
+
+ if (string.IsNullOrWhiteSpace(encodedData))
+ {
+ return null;
+ }
+
+ //Output buffer will always be smaller than actual input data due to base64 encoding
+ using UnsafeMemoryHandle<byte> binBuffer = MemoryUtil.UnsafeAllocNearestPage(encodedData.Length, true);
+
+ ERRNO bytes = VnEncoding.Base64UrlDecode(encodedData, binBuffer.Span);
+
+ if (!bytes)
+ {
+ return null;
+ }
+
+ //Deserialize the objects directly from binary data
+ return JsonSerializer.Deserialize<T>(
+ utf8Json: binBuffer.AsSpan(0, bytes),
+ options: Statics.SR_OPTIONS
+ );
+ }
+
+ /// <summary>
+ /// Writes a set of items to the user's account object, encoded in base64
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="store"></param>
+ /// <param name="index">The store index to write the encoded string data to</param>
+ /// <param name="instance">The object instance to encode and store</param>
+ public static void Encode<T>(IIndexable<string, string> store, string index, T? instance) where T : class
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentException.ThrowIfNullOrWhiteSpace(index);
+
+ if (instance == null)
+ {
+ store[index] = null!;
+ return;
+ }
+
+ //Use a memory stream to serialize the items safely
+ using VnMemoryStream ms = new(MemoryUtil.Shared, 1024, false);
+
+ JsonSerializer.Serialize(ms, instance, Statics.SR_OPTIONS);
+
+ store[index] = VnEncoding.ToBase64UrlSafeString(ms.AsSpan(), false);
+ }
+ }
+}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs b/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
deleted file mode 100644
index 9bf04c8..0000000
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/MFA/UserMFAExtensions.cs
+++ /dev/null
@@ -1,490 +0,0 @@
-/*
-* Copyright (c) 2023 Vaughn Nugent
-*
-* Library: VNLib
-* Package: VNLib.Plugins.Essentials.Accounts
-* File: UserMFAExtensions.cs
-*
-* UserMFAExtensions.cs is part of VNLib.Plugins.Essentials.Accounts which is part of the larger
-* VNLib collection of libraries and utilities.
-*
-* VNLib.Plugins.Essentials.Accounts is free software: you can redistribute it and/or modify
-* it under the terms of the GNU Affero General Public License as
-* published by the Free Software Foundation, either version 3 of the
-* License, or (at your option) any later version.
-*
-* VNLib.Plugins.Essentials.Accounts is distributed in the hope that it will be useful,
-* but WITHOUT ANY WARRANTY; without even the implied warranty of
-* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-* GNU Affero General Public License for more details.
-*
-* You should have received a copy of the GNU Affero General Public License
-* along with this program. If not, see https://www.gnu.org/licenses/.
-*/
-
-using System;
-using System.Linq;
-using System.Buffers;
-using System.Text.Json;
-using System.Diagnostics;
-using System.Collections.Generic;
-
-using VNLib.Hashing;
-using VNLib.Utils;
-using VNLib.Utils.Memory;
-using VNLib.Utils.Extensions;
-using VNLib.Hashing.IdentityUtility;
-using VNLib.Plugins.Essentials.Users;
-using VNLib.Plugins.Essentials.Sessions;
-
-namespace VNLib.Plugins.Essentials.Accounts.MFA
-{
-
- public static class UserMFAExtensions
- {
- public const string WEBAUTHN_KEY_ENTRY = "mfa.fido";
- public const string TOTP_KEY_ENTRY = "mfa.totp";
- public const string SESSION_SIG_KEY = "mfa.sig";
- public const string USER_PKI_ENTRY = "mfa.pki";
-
- /// <summary>
- /// Determines if the user account has an
- /// </summary>
- /// <param name="user"></param>
- /// <returns>True if any form of MFA is enabled for the user account</returns>
- public static bool MFAEnabled(this IUser user)
- {
- return !(string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]) && string.IsNullOrWhiteSpace(user[WEBAUTHN_KEY_ENTRY]));
- }
-
- /// <summary>
- /// Disables all forms of MFA for the current user
- /// </summary>
- /// <param name="user"></param>
- public static void MFADisable(this IUser user)
- {
- user[TOTP_KEY_ENTRY] = null!;
- user[WEBAUTHN_KEY_ENTRY] = null!;
- }
-
- #region totp
-
- /// <summary>
- /// Recovers the base32 encoded TOTP secret for the current user
- /// </summary>
- /// <param name="user"></param>
- /// <returns>The base32 encoded TOTP secret, or an emtpy string (user spec) if not set</returns>
- public static string MFAGetTOTPSecret(this IUser user) => user[TOTP_KEY_ENTRY];
-
- /// <summary>
- /// Stores or removes the current user's TOTP secret, stored in base32 format
- /// </summary>
- /// <param name="user"></param>
- /// <param name="secret">The base32 encoded TOTP secret</param>
- public static void MFASetTOTPSecret(this IUser user, string? secret) => user[TOTP_KEY_ENTRY] = secret!;
-
- /// <summary>
- /// Determines if the user account has TOTP enabled
- /// </summary>
- /// <param name="user"></param>
- /// <returns>True if the user has totp enabled, false otherwise</returns>
- public static bool MFATotpEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[TOTP_KEY_ENTRY]);
-
- /// <summary>
- /// Generates/overwrites the current user's TOTP secret entry and returns a
- /// byte array of the generated secret bytes
- /// </summary>
- /// <param name="entry">The <see cref="MFAEntry"/> to modify the TOTP configuration of</param>
- /// <returns>The raw secret that was encrypted and stored in the <see cref="MFAEntry"/>, to send to the client</returns>
- /// <exception cref="OutOfMemoryException"></exception>
- internal static byte[] MFAGenreateTOTPSecret(this IUser user, MFAConfig config)
- {
- _ = config.TOTPConfig ?? throw new NotSupportedException("The loaded configuration does not support TOTP");
- //Generate a random key
- byte[] newSecret = RandomHash.GetRandomBytes(config.TOTPConfig.TOTPSecretBytes);
- //Store secret in user storage
- user.MFASetTOTPSecret(VnEncoding.ToBase32String(newSecret, false));
- //return the raw secret bytes
- return newSecret;
- }
-
- /// <summary>
- /// Verfies the supplied TOTP code against the current user's totp codes
- /// This method should not be used for verifying TOTP codes for authentication
- /// </summary>
- /// <param name="user">The user account to verify the TOTP code against</param>
- /// <param name="code">The code to verify</param>
- /// <param name="config">A readonly referrence to the MFA configuration structure</param>
- /// <returns>True if the user has TOTP configured and code matches against its TOTP secret entry, false otherwise</returns>
- /// <exception cref="FormatException"></exception>
- /// <exception cref="OutOfMemoryException"></exception>
- internal static bool VerifyTOTP(this MFAConfig config, IUser user, uint code)
- {
- //Get the base32 TOTP secret for the user and make sure its actually set
- string base32Secret = user.MFAGetTOTPSecret();
- if (!config.TOTPEnabled || string.IsNullOrWhiteSpace(base32Secret))
- {
- return false;
- }
-
- int length = base32Secret.Length;
- bool isValid;
-
- if (length > 256)
- {
- //Alloc buffer with zero o
- using UnsafeMemoryHandle<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(base32Secret.Length, true);
-
- ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer.Span);
- //Verify the TOTP using the decrypted secret
- isValid = count && VerifyTOTP(code, buffer.AsSpan(0, count), config.TOTPConfig);
- //Zero out the buffer
- MemoryUtil.InitializeBlock(ref buffer.GetReference(), buffer.IntLength);
- }
- else
- {
- //stack alloc buffer
- Span<byte> buffer = stackalloc byte[base32Secret.Length];
-
- ERRNO count = VnEncoding.TryFromBase32Chars(base32Secret, buffer);
- //Verify the TOTP using the decrypted secret
- isValid = count && VerifyTOTP(code, buffer[..(int)count], config.TOTPConfig);
- //Zero out the buffer
- MemoryUtil.InitializeBlock(buffer);
- }
-
- return isValid;
- }
-
- private static bool VerifyTOTP(uint totpCode, ReadOnlySpan<byte> userSecret, TOTPConfig config)
- {
- //A basic attempt at a constant time TOTP verification, run the calculation a fixed number of times, regardless of the resutls
- bool codeMatches = false;
-
- //cache current time
- DateTimeOffset currentUtc = DateTimeOffset.UtcNow;
- //Start the current window with the minimum window
- int currenStep = -config.TOTPTimeWindowSteps;
- Span<byte> stepBuffer = stackalloc byte[sizeof(long)];
- Span<byte> hashBuffer = stackalloc byte[(int)config.TOTPAlg];
- //Run the loop at least once to allow a 0 step tight window
- do
- {
- //Calculate the window by multiplying the window by the current step, then add it to the current time offset to produce a new window
- DateTimeOffset window = currentUtc.Add(config.TOTPPeriod.Multiply(currenStep));
- //calculate the time step
- long timeStep = (long)Math.Floor(window.ToUnixTimeSeconds() / config.TOTPPeriod.TotalSeconds);
- //try to compute the hash, must always be storable in the buffer
- bool writeResult = BitConverter.TryWriteBytes(stepBuffer, timeStep);
- Debug.Assert(writeResult, "Failed to format the time step buffer because the buffer size was not large enough");
- //If platform is little endian, reverse the byte order
- if (BitConverter.IsLittleEndian)
- {
- stepBuffer.Reverse();
- }
- ERRNO result = ManagedHash.ComputeHmac(userSecret, stepBuffer, hashBuffer, config.TOTPAlg);
- //try to compute the hash of the time step
- if (result < 1)
- {
- throw new InternalBufferTooSmallException("Failed to compute TOTP time step hash because the buffer was too small");
- }
- //Hash bytes
- ReadOnlySpan<byte> hash = hashBuffer[..(int)result];
- //compute the TOTP code and compare it to the supplied, then store the result
- codeMatches |= (totpCode == CalcTOTPCode(hash, config));
- //next step
- currenStep++;
- } while (currenStep <= config.TOTPTimeWindowSteps);
-
- return codeMatches;
- }
-
- private static uint CalcTOTPCode(ReadOnlySpan<byte> hash, TOTPConfig config)
- {
- //Calculate the offset, RFC defines, the lower 4 bits of the last byte in the hash output
- byte offset = (byte)(hash[^1] & 0x0Fu);
-
- uint TOTPCode;
- if (BitConverter.IsLittleEndian)
- {
- //Store the code components
- TOTPCode = (hash[offset] & 0x7Fu) << 24 | (hash[offset + 1] & 0xFFu) << 16 | (hash[offset + 2] & 0xFFu) << 8 | hash[offset + 3] & 0xFFu;
- }
- else
- {
- //Store the code components (In reverse order for big-endian machines)
- TOTPCode = (hash[offset + 3] & 0x7Fu) << 24 | (hash[offset + 2] & 0xFFu) << 16 | (hash[offset + 1] & 0xFFu) << 8 | hash[offset] & 0xFFu;
- }
- //calculate the modulus value
- TOTPCode %= (uint)Math.Pow(10, config.TOTPDigits);
- return TOTPCode;
- }
-
- #endregion
-
- #region PKI
-
- /// <summary>
- /// Gets a value that determines if the user has PKI enabled
- /// </summary>
- /// <param name="user"></param>
- /// <returns>True if the user has a PKI key stored in their user account</returns>
- public static bool PKIEnabled(this IUser user) => !string.IsNullOrWhiteSpace(user[USER_PKI_ENTRY]);
-
- /// <summary>
- /// Verifies a PKI login JWT against the user's stored login key data
- /// </summary>
- /// <param name="user">The user requesting a login</param>
- /// <param name="jwt">The login jwt to verify</param>
- /// <param name="keyId">The id of the key that generated the request, it must match the id of the stored key</param>
- /// <returns>True if the user has PKI enabled, the key was recovered, the key id matches, and the JWT signature is verified</returns>
- public static bool PKIVerifyUserJWT(this IUser user, JsonWebToken jwt, string keyId)
- {
- /*
- * Since multiple keys can be stored, we need to recover the key that matches the desired key id
- */
- PkiAuthPublicKey? pub = PkiGetAllPublicKeys(user)?.FirstOrDefault(p => keyId.Equals(p.KeyId, StringComparison.Ordinal));
-
- if(pub == null)
- {
- return false;
- }
-
- //verify the jwt
- return jwt.VerifyFromJwk(pub);
- }
-
- /// <summary>
- /// Stores an array of public keys in the user's account object
- /// </summary>
- /// <param name="user"></param>
- /// <param name="authKeys">The array of jwk format keys to store for the user</param>
- public static void PKISetPublicKeys(this IUser user, PkiAuthPublicKey[]? authKeys)
- {
- if(authKeys == null || authKeys.Length == 0)
- {
- user[USER_PKI_ENTRY] = null!;
- return;
- }
-
- //Serialize the key data
- byte[] keyData = JsonSerializer.SerializeToUtf8Bytes(authKeys, Statics.SR_OPTIONS);
-
- //convert to base64 string before writing user data
- user[USER_PKI_ENTRY] = VnEncoding.ToBase64UrlSafeString(keyData, false);
- }
-
- /// <summary>
- /// Gets all public keys stored in the user's account object
- /// </summary>
- /// <param name="user"></param>
- /// <returns>The array of public keys if the exist</returns>
- public static PkiAuthPublicKey[]? PkiGetAllPublicKeys(this IUser user)
- {
- string? keyData = user[USER_PKI_ENTRY];
-
- if(string.IsNullOrEmpty(keyData))
- {
- return null;
- }
-
- //Alloc bin buffer for base64 conversion
- using UnsafeMemoryHandle<byte> binBuffer = MemoryUtil.UnsafeAllocNearestPage(keyData.Length, true);
-
- //Recover base64 bytes from key data
- ERRNO bytes = VnEncoding.Base64UrlDecode(keyData, binBuffer.Span);
- if (!bytes)
- {
- return null;
- }
-
- //Deserialize the the key array
- return JsonSerializer.Deserialize<PkiAuthPublicKey[]>(binBuffer.AsSpan(0, bytes), Statics.SR_OPTIONS);
- }
-
- /// <summary>
- /// Removes a single pki key by it's id
- /// </summary>
- /// <param name="user"></param>
- /// <param name="keyId">The id of the key to remove</param>
- public static void PKIRemovePublicKey(this IUser user, string keyId)
- {
- //get all keys
- PkiAuthPublicKey[]? keys = PkiGetAllPublicKeys(user);
- if(keys == null)
- {
- return;
- }
-
- //remove the key
- keys = keys.Where(k => !keyId.Equals(k.KeyId, StringComparison.Ordinal)).ToArray();
-
- //store the new key array
- PKISetPublicKeys(user, keys);
- }
-
- /// <summary>
- /// Adds a single pki key to the user's account object, or overwrites
- /// and existing key with the same id
- /// </summary>
- /// <param name="user"></param>
- /// <param name="key">The key to add to the list of user-keys</param>
- public static void PKIAddPublicKey(this IUser user, PkiAuthPublicKey key)
- {
- //get all keys
- PkiAuthPublicKey[]? keys = PkiGetAllPublicKeys(user);
-
- if (keys == null)
- {
- keys = new PkiAuthPublicKey[] { key };
- }
- else
- {
- //remove the key if it already exists, then append the new key
- keys = keys.Where(k => !key.KeyId.Equals(k.KeyId, StringComparison.Ordinal))
- .Append(key)
- .ToArray();
- }
-
- //store the new key array
- PKISetPublicKeys(user, keys);
- }
-
- #endregion
-
- #region webauthn
-
- #endregion
-
- private static HashAlg SigingAlg { get; } = HashAlg.SHA256;
-
- private static ReadOnlyMemory<byte> UpgradeHeader { get; } = CompileJwtHeader();
-
- private static byte[] CompileJwtHeader()
- {
- Dictionary<string, string> header = new()
- {
- { "alg","HS256" },
- { "typ", "JWT" }
- };
- return JsonSerializer.SerializeToUtf8Bytes(header);
- }
-
- /// <summary>
- /// Recovers a signed MFA upgrade JWT and verifies its authenticity, and confrims its not expired,
- /// then recovers the upgrade mssage
- /// </summary>
- /// <param name="config"></param>
- /// <param name="upgradeJwtString">The signed JWT upgrade message</param>
- /// <param name="base32Secret">The stored base64 encoded signature from the session that requested an upgrade</param>
- /// <returns>True if the upgrade was verified, not expired, and was recovered from the signed message, false otherwise</returns>
- internal static MFAUpgrade? RecoverUpgrade(this MFAConfig config, string upgradeJwtString, string base32Secret)
- {
- //Parse jwt
- using JsonWebToken jwt = JsonWebToken.Parse(upgradeJwtString);
-
- //Recover the secret key
- byte[] secret = VnEncoding.FromBase32String(base32Secret)!;
- try
- {
- //Verify the signature
- if (!jwt.Verify(secret, SigingAlg))
- {
- return null;
- }
- }
- finally
- {
- //Erase secret
- MemoryUtil.InitializeBlock(secret.AsSpan());
- }
- //Valid
-
- //get request body
- using JsonDocument doc = jwt.GetPayload();
-
- //Recover issued at time
- DateTimeOffset iat = DateTimeOffset.FromUnixTimeMilliseconds(doc.RootElement.GetProperty("iat").GetInt64());
-
- //Verify its not timed out
- if (iat.Add(config.UpgradeValidFor) < DateTimeOffset.UtcNow)
- {
- //expired
- return null;
- }
-
- //Recover the upgrade message
- return doc.RootElement.GetProperty("upgrade").Deserialize<MFAUpgrade>();
- }
-
-
- /// <summary>
- /// Generates an upgrade for the requested user, using the highest prirotiy method
- /// </summary>
- /// <param name="login">The message from the user requesting the login</param>
- /// <returns>A signed upgrade message the client will pass back to the server after the MFA verification</returns>
- /// <exception cref="InvalidOperationException"></exception>
- internal static MfaUpgradeMessage? MFAGetUpgradeIfEnabled(this IUser user, MFAConfig? conf, LoginMessage login)
- {
- //Webauthn config
-
-
- //Search for totp secret entry
- string base32Secret = user.MFAGetTOTPSecret();
-
- //Check totp entry
- if (!string.IsNullOrWhiteSpace(base32Secret))
- {
-
- //setup the upgrade
- MFAUpgrade upgrade = new()
- {
- //Set totp upgrade type
- Type = MFAType.TOTP,
- //Store login message details
- UserName = login.UserName,
- ClientId = login.ClientId,
- PublicKey = login.ClientPublicKey,
- ClientLocalLanguage = login.LocalLanguage,
- };
-
- //Init jwt for upgrade
- return GetUpgradeMessage(upgrade, conf);
- }
- return null;
- }
-
- private static MfaUpgradeMessage GetUpgradeMessage(MFAUpgrade upgrade, MFAConfig config)
- {
- //Add some random entropy to the upgrade message, to help prevent forgery
- string entropy = RandomHash.GetRandomBase32(config.NonceLenBytes);
- //Init jwt
- using JsonWebToken upgradeJwt = new();
- //Add header
- upgradeJwt.WriteHeader(UpgradeHeader.Span);
- //Write claims
- upgradeJwt.InitPayloadClaim()
- .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
- .AddClaim("upgrade", upgrade)
- .AddClaim("type", upgrade.Type.ToString().ToLower(null))
- .AddClaim("expires", config.UpgradeValidFor.TotalSeconds)
- .AddClaim("a", entropy)
- .CommitClaims();
-
- //Generate a new random secret
- byte[] secret = RandomHash.GetRandomBytes(config.UpgradeKeyBytes);
-
- //sign jwt
- upgradeJwt.Sign(secret, SigingAlg);
-
- //compile and return jwt upgrade
- return new(upgradeJwt.Compile(), VnEncoding.ToBase32String(secret));
- }
-
- internal static void MfaUpgradeSecret(this in SessionInfo session, string? base32Signature) => session[SESSION_SIG_KEY] = base32Signature!;
-
- internal static string? MfaUpgradeSecret(this in SessionInfo session) => session[SESSION_SIG_KEY];
- }
-
- readonly record struct MfaUpgradeMessage(string ClientJwt, string SessionKey);
-}
diff --git a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj
index 8e37899..99867b5 100644
--- a/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj
+++ b/plugins/VNLib.Plugins.Essentials.Accounts/src/VNLib.Plugins.Essentials.Accounts.csproj
@@ -50,6 +50,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
+ <PackageReference Include="System.Formats.Cbor" Version="8.0.0" />
</ItemGroup>
<ItemGroup>