diff --git a/.eslintrc.js b/.eslintrc.js index 7c537f2d0..108081979 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,7 +29,7 @@ module.exports = { }, { files: ['*.spec.{,c,m}js'], - excludedFiles: '@vates/nbd-client/*', + excludedFiles: ['@vates/nbd-client', '@vates/otp'], rules: { 'n/no-unsupported-features/node-builtins': [ 'error', diff --git a/@vates/otp/.USAGE.md b/@vates/otp/.USAGE.md new file mode 100644 index 000000000..8f577634e --- /dev/null +++ b/@vates/otp/.USAGE.md @@ -0,0 +1,130 @@ +### Usual workflow + +> This section presents how this library should be used to implement a classic two factor authentification. + +#### Setup + +```js +import { generateSecret, generateTotp } from '@vates/otp' +import QrCode from 'qrcode' + +// Generates a secret that will be shared by both the service and the user: +const secret = generateSecret() + +// Stores the secret in the service: +await currentUser.saveOtpSecret(secret) + +// Generates an URI to present to the user +const uri = generateTotpUri({ secret }) + +// Generates the QR code from the URI to make it easily importable in Authy or Google Authenticator +const qr = await QrCode.toDataURL(uri) +``` + +#### Authentication + +```js +import { verifyTotp } from '@vates/otp' + +// Verifies a `token` entered by the user against a `secret` generated during setup. +if (await verifyTotp(token, { secret })) { + console.log('authenticated!') +} +``` + +### API + +#### Secret + +```js +import { generateSecret } from '@vates/otp' + +const secret = generateSecret() +// 'OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH' +``` + +#### HOTP + +> This is likely not what you want to use, see TOTP below instead. + +```js +import { generateHotp, generateHotpUri, verifyHotp } from '@vates/otp' + +// a sequence number, see HOTP specification +const counter = 0 + +// generate a token +// +// optional params: +// - digits +const token = await generateHotp({ counter, secret }) +// '239988' + +// verify a token +// +// optional params: +// - digits +const isValid = await verifyHotp(token, { counter, secret }) +// true + +// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator +// +// optional params: +// - digits +const uri = generateHotpUri({ counter, label: 'account name', issuer: 'my app', secret }) +// 'otpauth://hotp/my%20app:account%20name?counter=0&issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH' +``` + +Optional params and their default values: + +- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator + +#### TOTP + +```js +import { generateTotp, generateTotpUri, verifyTotp } from '@vates/otp' + +// generate a token +// +// optional params: +// - digits +// - period +// - timestamp +const token = await generateTotp({ secret }) +// '632869' + +// verify a token +// +// optional params: +// - digits +// - period +// - timestamp +// - window +const isValid = await verifyTotp(token, { secret }) +// true + +// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator +// +// optional params: +// - digits +// - period +const uri = generateTotpUri({ label: 'account name', issuer: 'my app', secret }) +// 'otpauth://totp/my%20app:account%20name?issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH' +``` + +Optional params and their default values: + +- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator +- `period = 30`: number of seconds a token is valid +- `timestamp = Date.now() / 1e3`: Unix timestamp, in seconds, when this token will be valid, default to now +- `window = 1`: number of periods before and after `timestamp` for which the token is considered valid + +#### Verification from URI + +```js +import { verifyFromUri } from '@vates/otp' + +// Verify the token using all the information contained in the URI +const isValid = await verifyFromUri(token, uri) +// true +``` diff --git a/@vates/otp/.npmignore b/@vates/otp/.npmignore new file mode 120000 index 000000000..008d1b9b9 --- /dev/null +++ b/@vates/otp/.npmignore @@ -0,0 +1 @@ +../../scripts/npmignore \ No newline at end of file diff --git a/@vates/otp/README.md b/@vates/otp/README.md new file mode 100644 index 000000000..19fa947c6 --- /dev/null +++ b/@vates/otp/README.md @@ -0,0 +1,163 @@ + + +# @vates/otp + +[![Package Version](https://badgen.net/npm/v/@vates/otp)](https://npmjs.org/package/@vates/otp) ![License](https://badgen.net/npm/license/@vates/otp) [![PackagePhobia](https://badgen.net/bundlephobia/minzip/@vates/otp)](https://bundlephobia.com/result?p=@vates/otp) [![Node compatibility](https://badgen.net/npm/node/@vates/otp)](https://npmjs.org/package/@vates/otp) + +> Minimal HTOP/TOTP implementation + +## Install + +Installation of the [npm package](https://npmjs.org/package/@vates/otp): + +``` +> npm install --save @vates/otp +``` + +## Usage + +### Usual workflow + +> This section presents how this library should be used to implement a classic two factor authentification. + +#### Setup + +```js +import { generateSecret, generateTotp } from '@vates/otp' +import QrCode from 'qrcode' + +// Generates a secret that will be shared by both the service and the user: +const secret = generateSecret() + +// Stores the secret in the service: +await currentUser.saveOtpSecret(secret) + +// Generates an URI to present to the user +const uri = generateTotpUri({ secret }) + +// Generates the QR code from the URI to make it easily importable in Authy or Google Authenticator +const qr = await QrCode.toDataURL(uri) +``` + +#### Authentication + +```js +import { verifyTotp } from '@vates/otp' + +// Verifies a `token` entered by the user against a `secret` generated during setup. +if (await verifyTotp(token, { secret })) { + console.log('authenticated!') +} +``` + +### API + +#### Secret + +```js +import { generateSecret } from '@vates/otp' + +const secret = generateSecret() +// 'OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH' +``` + +#### HOTP + +> This is likely not what you want to use, see TOTP below instead. + +```js +import { generateHotp, generateHotpUri, verifyHotp } from '@vates/otp' + +// a sequence number, see HOTP specification +const counter = 0 + +// generate a token +// +// optional params: +// - digits +const token = await generateHotp({ counter, secret }) +// '239988' + +// verify a token +// +// optional params: +// - digits +const isValid = await verifyHotp(token, { counter, secret }) +// true + +// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator +// +// optional params: +// - digits +const uri = generateHotpUri({ counter, label: 'account name', issuer: 'my app', secret }) +// 'otpauth://hotp/my%20app:account%20name?counter=0&issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH' +``` + +Optional params and their default values: + +- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator + +#### TOTP + +```js +import { generateTotp, generateTotpUri, verifyTotp } from '@vates/otp' + +// generate a token +// +// optional params: +// - digits +// - period +// - timestamp +const token = await generateTotp({ secret }) +// '632869' + +// verify a token +// +// optional params: +// - digits +// - period +// - timestamp +// - window +const isValid = await verifyTotp(token, { secret }) +// true + +// generate a URI than can be displayed as a QR code to be used with Authy or Google Authenticator +// +// optional params: +// - digits +// - period +const uri = generateTotpUri({ label: 'account name', issuer: 'my app', secret }) +// 'otpauth://totp/my%20app:account%20name?issuer=my%20app&secret=OJOKA65RY5FQQ2RYWVKD5Y3YG5CSHGYH' +``` + +Optional params and their default values: + +- `digits = 6`: length of the token, avoid using it because not compatible with Google Authenticator +- `period = 30`: number of seconds a token is valid +- `timestamp = Date.now() / 1e3`: Unix timestamp, in seconds, when this token will be valid, default to now +- `window = 1`: number of periods before and after `timestamp` for which the token is considered valid + +#### Verification from URI + +```js +import { verifyFromUri } from '@vates/otp' + +// Verify the token using all the information contained in the URI +const isValid = await verifyFromUri(token, uri) +// true +``` + +## Contributions + +Contributions are _very_ welcomed, either on the documentation or on +the code. + +You may: + +- report any [issue](https://github.com/vatesfr/xen-orchestra/issues) + you've encountered; +- fork and create a pull request. + +## License + +[ISC](https://spdx.org/licenses/ISC) © [Vates SAS](https://vates.fr) diff --git a/@vates/otp/index.mjs b/@vates/otp/index.mjs new file mode 100644 index 000000000..95a677f4d --- /dev/null +++ b/@vates/otp/index.mjs @@ -0,0 +1,111 @@ +import { base32 } from 'rfc4648' +import { webcrypto } from 'node:crypto' + +const { subtle } = webcrypto + +function assert(name, value) { + if (!value) { + throw new TypeError('invalid value for param ' + name) + } +} + +// https://github.com/google/google-authenticator/wiki/Key-Uri-Format +function generateUri(protocol, label, params) { + assert('label', typeof label === 'string') + assert('secret', typeof params.secret === 'string') + + let path = encodeURIComponent(label) + + const { issuer } = params + if (issuer !== undefined) { + path = encodeURIComponent(issuer) + ':' + path + } + + const query = Object.entries(params) + .filter(_ => _[1] !== undefined) + .map(([key, value]) => key + '=' + encodeURIComponent(value)) + .join('&') + + return `otpauth://${protocol}/${path}?${query}` +} + +export function generateSecret() { + // https://www.rfc-editor.org/rfc/rfc4226 recommends 160 bits (i.e. 20 bytes) + const data = new Uint8Array(20) + webcrypto.getRandomValues(data) + return base32.stringify(data, { pad: false }) +} + +const DIGITS = 6 + +// https://www.rfc-editor.org/rfc/rfc4226 +export async function generateHotp({ counter, digits = DIGITS, secret }) { + const data = new Uint8Array(8) + new DataView(data.buffer).setBigInt64(0, BigInt(counter), false) + + const key = await subtle.importKey( + 'raw', + base32.parse(secret, { loose: true }), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign', 'verify'] + ) + const digest = new DataView(await subtle.sign('HMAC', key, data)) + + const offset = digest.getUint8(digest.byteLength - 1) & 0xf + const p = digest.getUint32(offset) & 0x7f_ff_ff_ff + + return String(p % Math.pow(10, digits)).padStart(digits, '0') +} + +export function generateHotpUri({ counter, digits, issuer, label, secret }) { + assert('counter', typeof counter === 'number') + return generateUri('hotp', label, { counter, digits, issuer, secret }) +} + +export async function verifyHotp(token, opts) { + return token === (await generateHotp(opts)) +} + +function totpCounter(period = 30, timestamp = Math.floor(Date.now() / 1e3)) { + return Math.floor(timestamp / period) +} + +// https://www.rfc-editor.org/rfc/rfc6238.html +export async function generateTotp({ period, timestamp, ...opts }) { + opts.counter = totpCounter(period, timestamp) + return await generateHotp(opts) +} + +export function generateTotpUri({ digits, issuer, label, period, secret }) { + return generateUri('totp', label, { digits, issuer, period, secret }) +} + +export async function verifyTotp(token, { period, timestamp, window = 1, ...opts }) { + const counter = totpCounter(period, timestamp) + const end = counter + window + opts.counter = counter - window + while (opts.counter <= end) { + if (token === (await generateHotp(opts))) { + return true + } + opts.counter += 1 + } + return false +} + +export async function verifyFromUri(token, uri) { + const url = new URL(uri) + assert('protocol', url.protocol === 'otpauth:') + + const { host } = url + const opts = Object.fromEntries(url.searchParams.entries()) + if (host === 'hotp') { + return await verifyHotp(token, opts) + } + if (host === 'totp') { + return await verifyTotp(token, opts) + } + + assert('host', false) +} diff --git a/@vates/otp/index.spec.mjs b/@vates/otp/index.spec.mjs new file mode 100644 index 000000000..b17f681b6 --- /dev/null +++ b/@vates/otp/index.spec.mjs @@ -0,0 +1,113 @@ +import { strict as assert } from 'node:assert' +// eslint-disable-next-line n/no-unpublished-import +import { describe, it } from 'tap/mocha' + +import { + generateHotp, + generateHotpUri, + generateSecret, + generateTotp, + generateTotpUri, + verifyHotp, + verifyTotp, +} from './index.mjs' + +describe('generateSecret', function () { + it('generates a string of 32 chars', async function () { + const secret = generateSecret() + assert.equal(typeof secret, 'string') + assert.equal(secret.length, 32) + }) + + it('generates a different secret at each call', async function () { + assert.notEqual(generateSecret(), generateSecret()) + }) +}) + +describe('HOTP', function () { + it('generate and verify valid tokens', async function () { + for (const [token, opts] of Object.entries({ + 382752: { + counter: -3088, + secret: 'PJYFSZ3JNVXVQMZXOB2EQYJSKB2HE6TB', + }, + 163376: { + counter: 30598, + secret: 'GBUDQZ3UKZZGIMRLNVYXA33GMFMEGQKN', + }, + })) { + assert.equal(await generateHotp(opts), token) + assert(await verifyHotp(token, opts)) + } + }) + + describe('generateHotpUri', function () { + const opts = { + counter: 59732, + label: 'the label', + secret: 'OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX', + } + + Object.entries({ + 'without optional params': [ + opts, + 'otpauth://hotp/the%20label?counter=59732&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX', + ], + 'with issuer': [ + { ...opts, issuer: 'the issuer' }, + 'otpauth://hotp/the%20issuer:the%20label?counter=59732&issuer=the%20issuer&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX', + ], + 'with digits': [ + { ...opts, digits: 7 }, + 'otpauth://hotp/the%20label?counter=59732&digits=7&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX', + ], + }).forEach(([title, [opts, uri]]) => { + it(title, async function () { + assert.strictEqual(generateHotpUri(opts), uri) + }) + }) + }) +}) + +describe('TOTP', function () { + Object.entries({ + '033702': { + secret: 'PJYFSZ3JNVXVQMZXOB2EQYJSKB2HE6TB', + timestamp: 1665416296, + period: 30, + }, + 107250: { + secret: 'GBUDQZ3UKZZGIMRLNVYXA33GMFMEGQKN', + timestamp: 1665416674, + period: 60, + }, + }).forEach(([token, opts]) => { + it('works', async function () { + assert.equal(await generateTotp(opts), token) + assert(await verifyTotp(token, opts)) + }) + }) + + describe('generateHotpUri', function () { + const opts = { + label: 'the label', + secret: 'OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX', + } + + Object.entries({ + 'without optional params': [opts, 'otpauth://totp/the%20label?secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX'], + 'with issuer': [ + { ...opts, issuer: 'the issuer' }, + 'otpauth://totp/the%20issuer:the%20label?issuer=the%20issuer&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX', + ], + 'with digits': [ + { ...opts, digits: 7 }, + 'otpauth://totp/the%20label?digits=7&secret=OGK45BBZAIGNGELHZPXYKN4GUVWWO6YX', + ], + }).forEach(([title, [opts, uri]]) => { + it(title, async function () { + assert.strictEqual(generateTotpUri(opts), uri) + }) + }) + }) +}) diff --git a/@vates/otp/package.json b/@vates/otp/package.json new file mode 100644 index 000000000..d0c5cd4e4 --- /dev/null +++ b/@vates/otp/package.json @@ -0,0 +1,39 @@ +{ + "private": false, + "name": "@vates/otp", + "description": "Minimal HTOP/TOTP implementation", + "keywords": [ + "2fa", + "authenticator", + "hotp", + "otp", + "totp" + ], + "homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@vates/otp", + "bugs": "https://github.com/vatesfr/xen-orchestra/issues", + "main": "index.mjs", + "repository": { + "directory": "@vates/otp", + "type": "git", + "url": "https://github.com/vatesfr/xen-orchestra.git" + }, + "author": { + "name": "Vates SAS", + "url": "https://vates.fr" + }, + "license": "ISC", + "version": "0.0.0", + "engines": { + "node": ">=15" + }, + "dependencies": { + "rfc4648": "^1.5.2" + }, + "scripts": { + "postversion": "npm publish --access public", + "test": "tap" + }, + "devDependencies": { + "tap": "^16.3.0" + } +} diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index b03fe3f85..04a43385d 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -28,6 +28,7 @@ - @vates/nbd-client major +- @vates/otp major - @vates/read-chunk patch - @xen-orchestra/log minor - xo-remote-parser patch diff --git a/packages/xo-server/package.json b/packages/xo-server/package.json index 635a461c5..5c4a4dfa6 100644 --- a/packages/xo-server/package.json +++ b/packages/xo-server/package.json @@ -35,6 +35,7 @@ "@vates/decorate-with": "^2.0.0", "@vates/disposable": "^0.1.1", "@vates/event-listeners-manager": "^1.0.1", + "@vates/otp": "^0.0.0", "@vates/multi-key-map": "^0.1.0", "@vates/parse-duration": "^0.1.1", "@vates/predicates": "^1.0.0", @@ -102,7 +103,6 @@ "multiparty": "^4.2.2", "ndjson": "^2.0.0", "openpgp": "^5.0.0", - "otplib": "^11.0.0", "partial-stream": "0.0.0", "passport": "^0.6.0", "passport-local": "^1.0.0", diff --git a/packages/xo-server/src/index.mjs b/packages/xo-server/src/index.mjs index 67bb1a41a..e4b269566 100644 --- a/packages/xo-server/src/index.mjs +++ b/packages/xo-server/src/index.mjs @@ -1,11 +1,9 @@ import appConf from 'app-conf' import assert from 'assert' -import authenticator from 'otplib/authenticator.js' import blocked from 'blocked-at' import Bluebird from 'bluebird' import compression from 'compression' import createExpress from 'express' -import crypto from 'crypto' import forOwn from 'lodash/forOwn.js' import has from 'lodash/has.js' import helmet from 'helmet' @@ -28,6 +26,7 @@ import { createRequire } from 'module' import { genSelfSignedCert } from '@xen-orchestra/self-signed' import { parseDuration } from '@vates/parse-duration' import { URL } from 'url' +import { verifyTotp } from '@vates/otp' import { compile as compilePug } from 'pug' import { fromCallback, fromEvent } from 'promise-toolbox' @@ -62,11 +61,6 @@ const [APP_NAME, APP_VERSION] = (() => { // =================================================================== -// https://github.com/yeojz/otplib#using-specific-otp-implementations -authenticator.options = { crypto } - -// =================================================================== - configure([ { filter: process.env.DEBUG, @@ -203,14 +197,14 @@ async function setUpPassport(express, xo, { authentication: authCfg, http: { coo ) }) - express.post('/signin-otp', (req, res, next) => { + express.post('/signin-otp', async (req, res, next) => { const { user } = req.session if (user === undefined) { return res.redirect(303, '/signin') } - if (authenticator.check(req.body.otp, user.preferences.otp)) { + if (await verifyTotp(req.body.otp, { secret: user.preferences.otp })) { setToken(req, res, next) } else { req.flash('error', 'Invalid code') diff --git a/yarn.lock b/yarn.lock index ca29ed0b2..c55cdf4cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16240,6 +16240,11 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfc4648@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.5.2.tgz#cf5dac417dd83e7f4debf52e3797a723c1373383" + integrity sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg== + rfdc@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" @@ -17563,7 +17568,7 @@ tap-yaml@^1.0.0: dependencies: yaml "^1.5.0" -tap@^16.0.1, tap@^16.1.0, tap@^16.2.0: +tap@^16.0.1, tap@^16.1.0, tap@^16.2.0, tap@^16.3.0: version "16.3.0" resolved "https://registry.yarnpkg.com/tap/-/tap-16.3.0.tgz#8323fc66990951b52063a01dadffa0eaf3c55e96" integrity sha512-J9GffPUAbX6FnWbQ/jj7ktzd9nnDFP1fH44OzidqOmxUfZ1hPLMOvpS99LnDiP0H2mO8GY3kGN5XoY0xIKbNFA==