From 19ac52aecfbd2792e82c3c40bfb8ffabc346ead0 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Fri, 23 Oct 2026 03:24:39 +0100 Subject: [PATCH] fix(common): derive Hash.otp secrets from the app key Hash.otp used one hardcoded TOTP secret for every label, so a code issued for one account was valid for all of them and anyone could compute it from the published package. Each label now gets its own secret, an HMAC of the label under APP_KEY. --- packages/common/src/utils/hash.ts | 21 +++++++++++-- packages/common/tests/hash.test.ts | 47 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/common/tests/hash.test.ts 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') + }) +})