From 36a7ff7ebc6628cf1dd091777ef7d3ada39b2a65 Mon Sep 17 00:00:00 2001 From: akordavid373 Date: Sat, 27 Jun 2026 13:11:02 +0100 Subject: [PATCH] fix(security): store salted hash of deviceId instead of plaintext UUID - Replace localStorage plaintext UUID storage with salted hash - Add per-session salt stored in sessionStorage - Add synchronous hash function for device identity - Update tests to verify hashed storage behavior Closes #448 --- .../__tests__/transactionSecurity.test.ts | 83 ++++++++++++++++++- src/utils/security/transactionSecurity.ts | 27 +++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/utils/security/__tests__/transactionSecurity.test.ts b/src/utils/security/__tests__/transactionSecurity.test.ts index da3014bb..ec162264 100644 --- a/src/utils/security/__tests__/transactionSecurity.test.ts +++ b/src/utils/security/__tests__/transactionSecurity.test.ts @@ -1,6 +1,10 @@ -import { decideStepUpSecurity, ethToWei } from '../transactionSecurity'; +import { decideStepUpSecurity, ethToWei, getSecurityDeviceId } from '../transactionSecurity'; describe('transactionSecurity helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('requires step-up for values at or above the configured threshold', () => { const decision = decideStepUpSecurity({ valueWei: ethToWei(3), @@ -39,5 +43,82 @@ describe('transactionSecurity helpers', () => { expect(decision.requiresStepUp).toBe(false); expect(decision.reason).toBeNull(); }); + + describe('getSecurityDeviceId', () => { + let mockLocalStorage: Record; + let mockSessionStorage: Record; + + beforeEach(() => { + mockLocalStorage = {}; + mockSessionStorage = {}; + jest.spyOn(globalThis, 'crypto', 'get').mockReturnValue({ + randomUUID: () => '550e8400-e29b-41d4-a716-446655440000', + subtle: {} as SubtleCrypto, + getRandomValues: (arr: Uint8Array) => arr, + } as unknown as Crypto); + jest.spyOn(globalThis, 'localStorage', 'get').mockReturnValue({ + getItem: (key: string) => mockLocalStorage[key] ?? null, + setItem: (key: string, value: string) => { mockLocalStorage[key] = value; }, + removeItem: (key: string) => { delete mockLocalStorage[key]; }, + clear: () => { mockLocalStorage = {}; }, + get length() { return Object.keys(mockLocalStorage).length; }, + key: (index: number) => Object.keys(mockLocalStorage)[index] ?? null, + } as Storage); + jest.spyOn(globalThis, 'sessionStorage', 'get').mockReturnValue({ + getItem: (key: string) => mockSessionStorage[key] ?? null, + setItem: (key: string, value: string) => { mockSessionStorage[key] = value; }, + removeItem: (key: string) => { delete mockSessionStorage[key]; }, + clear: () => { mockSessionStorage = {}; }, + get length() { return Object.keys(mockSessionStorage).length; }, + key: (index: number) => Object.keys(mockSessionStorage)[index] ?? null, + } as Storage); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns "server-device" when window is undefined', () => { + const windowSpy = jest.spyOn(globalThis, 'window', 'get').mockReturnValue(undefined as any); + expect(getSecurityDeviceId()).toBe('server-device'); + windowSpy.mockRestore(); + }); + + it('generates and stores a salted hash on first call', () => { + const result = getSecurityDeviceId(); + + expect(result).toBe('550e8400-e29b-41d4-a716-446655440000'); + expect(mockSessionStorage['propchain-session-salt']).toBeDefined(); + expect(mockLocalStorage['propchain-security-device-id-hash']).toBeDefined(); + expect(mockLocalStorage['propchain-security-device-id-hash']).not.toBe(result); + }); + + it('reuses existing hash from localStorage on subsequent calls', () => { + const existingHash = 'a1b2c3d4'; + mockLocalStorage['propchain-security-device-id-hash'] = existingHash; + + const result = getSecurityDeviceId(); + + expect(result).toBe(existingHash); + expect(mockSessionStorage['propchain-session-salt']).toBeUndefined(); + }); + + it('reuses session salt across calls within the same session', () => { + const sessionSalt = 'test-session-salt'; + mockSessionStorage['propchain-session-salt'] = sessionSalt; + + getSecurityDeviceId(); + + expect(mockSessionStorage['propchain-session-salt']).toBe(sessionSalt); + }); + + it('stores a hashed value, not the raw UUID', () => { + getSecurityDeviceId(); + + const storedValue = mockLocalStorage['propchain-security-device-id-hash']; + expect(storedValue).not.toBe('550e8400-e29b-41d4-a716-446655440000'); + expect(storedValue).toMatch(/^[0-9a-f]{8,}$/); + }); + }); }); diff --git a/src/utils/security/transactionSecurity.ts b/src/utils/security/transactionSecurity.ts index c5ce3d55..b18d1205 100644 --- a/src/utils/security/transactionSecurity.ts +++ b/src/utils/security/transactionSecurity.ts @@ -75,17 +75,40 @@ export function createTrustedDeviceId(): string { return `trusted_${crypto.randomUUID()}`; } +function simpleHash(input: string): string { + let hash = 0; + for (let i = 0; i < input.length; i++) { + const char = input.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash |= 0; + } + return Math.abs(hash).toString(16).padStart(8, '0'); +} + +const SESSION_SALT_KEY = 'propchain-session-salt'; + +function getSessionSalt(): string { + let salt = window.sessionStorage.getItem(SESSION_SALT_KEY); + if (!salt) { + salt = crypto.randomUUID(); + window.sessionStorage.setItem(SESSION_SALT_KEY, salt); + } + return salt; +} + export function getSecurityDeviceId(): string { if (typeof window === 'undefined') { return 'server-device'; } - const key = 'propchain-security-device-id'; + const key = 'propchain-security-device-id-hash'; const existing = window.localStorage.getItem(key); if (existing) return existing; const deviceId = crypto.randomUUID(); - window.localStorage.setItem(key, deviceId); + const salt = getSessionSalt(); + const hashedId = simpleHash(`${deviceId}:${salt}`); + window.localStorage.setItem(key, hashedId); return deviceId; }