diff --git a/packages/common/src/utils/hash.ts b/packages/common/src/utils/hash.ts index 76b900bd..980f8e26 100644 --- a/packages/common/src/utils/hash.ts +++ b/packages/common/src/utils/hash.ts @@ -1,7 +1,8 @@ import { Secret, TOTP } from 'otpauth' +import { appKey, env } from '../system' import { compare, genSalt, hash } from 'bcryptjs' -import { env } from '../system' +import { createHmac } from 'node:crypto' export class Hash { /** @@ -42,10 +43,26 @@ export class Hash { issuer: env('APP_NAME', 'Roseed'), algorithm: 'SHA1', period, // in seconds. - secret: 'US3WHSG7X5KAPV27VANWKQHF3SH3HULL', + secret: Hash.otpSecret(label), }) } + /** + * The OTP secret for a label, derived from the application key so codes + * differ per label and cannot be computed without the key. + * + * @param label + * @returns + */ + private static otpSecret(label: string): Secret { + const key = appKey() + if (!key) throw new Error('APP_KEY is required to generate one-time passwords. Run `ark key:generate`.') + + const digest = createHmac('sha256', key).update(`otp:${label}`).digest() + + return new Secret({ buffer: new Uint8Array(digest).buffer }) + } + static totp(secret: string, label: string, issuer: string = env('APP_NAME', 'Roseed'), period: number = 30) { return new TOTP({ issuer, diff --git a/packages/common/tests/hash.test.ts b/packages/common/tests/hash.test.ts new file mode 100644 index 00000000..fb39e98e --- /dev/null +++ b/packages/common/tests/hash.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { Hash } from '../src' +import { randomBytes } from 'node:crypto' + +describe('Hash.otp', () => { + let previous: string | undefined + + beforeEach(() => { + previous = process.env.APP_KEY + process.env.APP_KEY = randomBytes(32).toString('base64url') + }) + + afterEach(() => { + if (previous === undefined) { + delete process.env.APP_KEY + } else { + process.env.APP_KEY = previous + } + }) + + it('validates a code for the label it was generated for', () => { + const token = Hash.otp(6, 'alice@example.com:reset', 900).generate() + + expect(Hash.otp(6, 'alice@example.com:reset', 900).validate({ token })).not.toBeNull() + }) + + it('gives each label its own secret', () => { + const alice = Hash.otp(6, 'alice@example.com:reset', 900) + const bob = Hash.otp(6, 'bob@example.com:reset', 900) + + expect(alice.secret.base32).not.toBe(bob.secret.base32) + expect(bob.validate({ token: alice.generate() })).toBeNull() + }) + + it('derives the secret from the application key', () => { + const before = Hash.otp(6, 'alice@example.com:reset', 900).secret.base32 + + process.env.APP_KEY = randomBytes(32).toString('base64url') + + expect(Hash.otp(6, 'alice@example.com:reset', 900).secret.base32).not.toBe(before) + }) + + it('no longer uses the fixed secret', () => { + expect(Hash.otp().secret.base32).not.toBe('US3WHSG7X5KAPV27VANWKQHF3SH3HULL') + }) +})