Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ fresh; none of it is retained in memory between calls.
### secureStorage is local-only

`secureStorage` never syncs or backs up anything - every value it writes
stays on the device that wrote it. Cross-device recovery, if an app wants
stays on the device that wrote it. Wallets created before the encryption key
became device-only keep the previous accessibility class until the key is
written again. Cross-device recovery, if an app wants
it, is a separate, explicit concern layered on top of this library's public
API, not something this library performs itself.

Expand Down
21 changes: 11 additions & 10 deletions src/storage/secureStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
* On-device only - this module never syncs or backs up anything anywhere. Thin wrapper
* around react-native-keychain:
* - namespaces entries per wallet identifier
* - marks the encrypted seed/entropy device-only, excluded from ever migrating to a new
* device via an encrypted backup restore (iOS-specific; a no-op on Android)
* - marks the encryption key and the encrypted seed/entropy device-only, excluded from ever
* migrating to a new device via an encrypted backup restore (iOS-specific; a no-op on Android)
* - guards against a hung keychain call with a timeout
* - all values are still encrypted at rest by the OS keychain/keystore
*
Expand All @@ -31,6 +31,7 @@ import * as Keychain from 'react-native-keychain'
import * as Crypto from 'expo-crypto'

import { log, logError } from '../utils/logger'
import { isReservedObjectKey } from '../utils/schemas'

export class SecureStorageError extends Error {
constructor(
Expand Down Expand Up @@ -115,6 +116,9 @@ function validateIdentifier(identifier: string): void {
'Identifier contains invalid characters. Allowed: alphanumeric, dots, dashes, underscores, plus signs, and email format'
)
}
if (isReservedObjectKey(trimmed)) {
throw new ValidationError('Identifier must not be a reserved object property name')
}
}

function validateValue(value: string, fieldName: string): void {
Expand Down Expand Up @@ -228,17 +232,14 @@ export interface SecureStorage {
async function setSecureValue(
baseKey: BaseKey,
value: string,
identifier: string,
deviceOnly: boolean
identifier: string
): Promise<void> {
validateValue(value, 'value')
validateIdentifier(identifier)

try {
const storageKey = await deriveStorageKey(baseKey, identifier)
const accessible = deviceOnly
? Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY
: Keychain.ACCESSIBLE.WHEN_UNLOCKED
const accessible = Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY

const result = await withTimeout(
Keychain.setGenericPassword(baseKey, value, { service: storageKey, accessible }),
Expand Down Expand Up @@ -325,23 +326,23 @@ function throwIfFailed(failedServices: string[]): void {

const secureStorage: SecureStorage = {
async setEncryptionKey(key, identifier) {
return setSecureValue(ENCRYPTION_KEY, key, identifier, false)
return setSecureValue(ENCRYPTION_KEY, key, identifier)
},

async getEncryptionKey(identifier) {
return getSecureValue(ENCRYPTION_KEY, identifier)
},

async setEncryptedSeed(encryptedSeed, identifier) {
return setSecureValue(ENCRYPTED_SEED, encryptedSeed, identifier, true)
return setSecureValue(ENCRYPTED_SEED, encryptedSeed, identifier)
},

async getEncryptedSeed(identifier) {
return getSecureValue(ENCRYPTED_SEED, identifier)
},

async setEncryptedEntropy(encryptedEntropy, identifier) {
return setSecureValue(ENCRYPTED_ENTROPY, encryptedEntropy, identifier, true)
return setSecureValue(ENCRYPTED_ENTROPY, encryptedEntropy, identifier)
},

async getEncryptedEntropy(identifier) {
Expand Down
4 changes: 2 additions & 2 deletions src/store/walletStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
*
* ## Important Notes
*
* - Addresses: Stored in Zustand (derived/computed state, deterministic, no refetching needed)
* - Addresses: Stored in Zustand for the session only (derived from the worklet, never persisted)
* - Balances: Stored in Zustand (single source of truth), use TanStack Query via useBalance() hook for fetching
* - Zustand is the single source of truth for balances
* - TanStack Query reads from Zustand (initialData) and updates Zustand after fetch
Expand Down Expand Up @@ -164,7 +164,6 @@ export function createWalletStore(): WalletStoreInstance {
name: 'wallet-storage',
storage: createJSONStorage(() => defaultStorageAdapter),
partialize: (state: WalletState) => ({
addresses: state.addresses,
balances: state.balances,
balanceLoading: {},
lastBalanceUpdate: state.lastBalanceUpdate,
Expand All @@ -176,6 +175,7 @@ export function createWalletStore(): WalletStoreInstance {
return (state: WalletState | undefined) => {
if (state) {
log('🔄 Rehydrating wallet state - resetting loading states')
state.addresses = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to ensure this doesn't alter the behavior for consumer. Can you test against wdk-examples? What I expect is that all functions behave the same way, it's just a bit slower without the wallet store, but this is only addresses so fetching from the worklet shouldn't be too taxing

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested with wdk-examples. Same results after loading. The only difference is one getAddress call per network on first load, then it's cached, and data is undefined on the first render instead of coming from storage

state.walletLoading = {}
state.balanceLoading = {}
// Reset runtime-only state
Expand Down
7 changes: 6 additions & 1 deletion src/utils/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,17 @@ export const assetConfigSchema = z.object({
*/
export const accountIndexSchema = z.number().int().nonnegative()

export const isReservedObjectKey = (key: string): boolean =>
key in Object.prototype || key === 'prototype'

/**
* Network name schema
*/
export const networkNameSchema = z.string().regex(/^[a-zA-Z0-9_-]+$/, {
message: 'Network name must contain only alphanumeric characters, hyphens, and underscores',
}).min(1)
}).min(1).refine((name) => !isReservedObjectKey(name), {
message: 'Network name must not be a reserved object property name',
})

/**
* Balance string schema (valid number string)
Expand Down
27 changes: 19 additions & 8 deletions src/utils/storeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ import type { HRPC } from '../types'
import type { WalletState } from '../store/walletStore'
import { produce } from 'immer'
import { logWarn } from './logger'
import { isReservedObjectKey } from './schemas'

function assertStateKey(name: string, value: string): void {
if (isReservedObjectKey(value)) {
throw new Error(`${name} must not be a reserved object property name`)
}
}

/**
* Require that worklet is initialized and return HRPC instance
Expand Down Expand Up @@ -74,6 +81,7 @@ export async function requireInitialized(): Promise<HRPC> {
* @param tokenKey - Token key (address or 'native')
* @param balance - Balance value
* @returns The updated state
* @throws {Error} If walletId or network is a reserved object property name
*/
export function updateBalanceInState(
prev: WalletState,
Expand All @@ -83,6 +91,9 @@ export function updateBalanceInState(
tokenKey: string,
balance: string,
) {
assertStateKey('walletId', walletId)
assertStateKey('network', network)

return produce(prev, (state) => {
state.balances[walletId] ??= {}
state.balances[walletId][network] ??= {}
Expand All @@ -104,6 +115,7 @@ export function updateBalanceInState(
* @param accountIndex - Account index
* @param address - Address value
* @returns The updated state
* @throws {Error} If walletId or network is a reserved object property name
*/
export function updateAddressInState(
prev: WalletState,
Expand All @@ -112,6 +124,9 @@ export function updateAddressInState(
accountIndex: number,
address: string,
) {
assertStateKey('walletId', walletId)
assertStateKey('network', network)

return produce(prev, (state) => {
state.addresses[walletId] ??= {}
state.addresses[walletId][network] ??= {}
Expand All @@ -128,6 +143,7 @@ export function updateAddressInState(
*
* @param walletId - Optional wallet identifier parameter
* @returns Resolved wallet identifier (never null)
* @throws {Error} If the resolved identifier is a reserved object property name
*
* @example
* ```typescript
Expand All @@ -136,14 +152,9 @@ export function updateAddressInState(
* ```
*/
export function resolveWalletId(walletId?: string): string {
if (walletId) {
return walletId
}

const walletStore = getWalletStore()
const activeWalletId = walletStore.getState().activeWalletId

return activeWalletId || '__temporary__'
const resolved = walletId || getWalletStore().getState().activeWalletId || '__temporary__'
assertStateKey('walletId', resolved)
return resolved
}

/**
Expand Down
16 changes: 7 additions & 9 deletions tests/storage/secureStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,18 +220,16 @@ describe('secureStorage', () => {
})
})

describe('accessible policy', () => {
it('stores the encryption key without a device-only restriction (WHEN_UNLOCKED)', async () => {
await storage.setEncryptionKey('key-value', TEST_ID)

expect(Keychain.setGenericPassword).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED })
describe('identifier validation', () => {
it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject the reserved identifier %s', async (identifier) => {
await expect(storage.setEncryptionKey('key-value', identifier)).rejects.toThrow(
'Identifier must not be a reserved object property name'
)
})
})

it.each(['setEncryptedSeed', 'setEncryptedEntropy'] as const)(
describe('accessible policy', () => {
it.each(['setEncryptionKey', 'setEncryptedSeed', 'setEncryptedEntropy'] as const)(
'stores %s as device-only (WHEN_UNLOCKED_THIS_DEVICE_ONLY)',
async (method) => {
await storage[method]('value', TEST_ID)
Expand Down
38 changes: 37 additions & 1 deletion tests/store/walletStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { createJSONStorage } from 'zustand/middleware'
import {
createWalletStore,
getWalletStore,
Expand Down Expand Up @@ -253,6 +254,41 @@ describe('walletStore', () => {
})
})

describe('persist', () => {
it('should not persist addresses', () => {
const storeInstance = createWalletStore()
const persistOptions = (storeInstance as any).persist.getOptions()

const persisted = persistOptions.partialize({
...storeInstance.getState(),
addresses: { w1: { ethereum: { 0: '0xabc' } } },
balances: { w1: { ethereum: { 0: { asset: '100' } } } },
})

expect(persisted.addresses).toBeUndefined()
expect(persisted.balances).toEqual({ w1: { ethereum: { 0: { asset: '100' } } } })
})

it('should drop addresses from a persisted blob written by an older version', async () => {
const storeInstance = createWalletStore()
const legacyBlob = JSON.stringify({
state: {
addresses: { w1: { ethereum: { 0: '0xATTACKER' } } },
balances: { w1: { ethereum: { 0: { asset: '100' } } } },
},
version: 0,
})
const legacyStorage = { getItem: async () => legacyBlob, setItem: async () => {}, removeItem: async () => {} }
;(storeInstance as any).persist.setOptions({ storage: createJSONStorage(() => legacyStorage) })

await (storeInstance as any).persist.rehydrate()

expect(storeInstance.getState().addresses).toEqual({})
expect(storeInstance.getState().balances).toEqual({ w1: { ethereum: { 0: { asset: '100' } } } })
storeInstance.setState(mockInitState)
})
})

describe('onRehydrateStorage logic', () => {
it('should reset runtime-only states during rehydration', () => {
const mockState: WalletState = {
Expand Down Expand Up @@ -285,7 +321,7 @@ describe('walletStore', () => {
expect(stateBeforeRehydration.currentOperation).toEqual(null)

expect(stateBeforeRehydration.tempWalletId).toEqual('temp_w')
expect(stateBeforeRehydration.addresses).toEqual(mockState.addresses)
expect(stateBeforeRehydration.addresses).toEqual({})
expect(stateBeforeRehydration.balances).toEqual(mockState.balances)
expect(stateBeforeRehydration.lastBalanceUpdate).toEqual(mockState.lastBalanceUpdate)

Expand Down
11 changes: 11 additions & 0 deletions tests/utils/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,20 @@ import {
workletResponseSchema,
balanceResponseSchema,
accountMethodResponseSchema,
networkNameSchema,
} from '../../src/utils/schemas'

describe('schemas', () => {
describe('networkNameSchema', () => {
it.each(['ethereum', 'arbitrum-one', 'bsc_testnet', 'toStringify', 'constructorX'])('should accept %s', (name) => {
expect(networkNameSchema.parse(name)).toBe(name)
})

it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject the reserved key %s', (name) => {
expect(() => networkNameSchema.parse(name)).toThrow('Network name must not be a reserved object property name')
})
})

describe('workletResponseSchema', () => {
it('should validate valid worklet response', () => {
const valid = {
Expand Down
51 changes: 50 additions & 1 deletion tests/utils/storeHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
requireInitialized,
updateBalanceInState,
updateAddressInState,
resolveWalletId,
} from '../../src/utils/storeHelpers'
import { getWorkletStore } from '../../src/store/workletStore'

Expand Down Expand Up @@ -184,6 +185,21 @@ describe('storeHelpers', () => {
expect(result.balances?.['wallet-1']?.ethereum?.[0]?.native).toBe('50')
expect(result.balances?.['wallet-1']?.ethereum?.[0]?.['0x123']).toBe('1000')
})
it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s as walletId', (key) => {
const prev = { balances: {} } as any

expect(() => updateBalanceInState(prev, key, 'ethereum', 0, 'asset', '1')).toThrow(
'walletId must not be a reserved object property name'
)
})

it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s as network', (key) => {
const prev = { balances: {} } as any

expect(() => updateBalanceInState(prev, 'wallet-1', key, 0, 'asset', '1')).toThrow(
'network must not be a reserved object property name'
)
})
})

describe('updateAddressInState', () => {
Expand Down Expand Up @@ -286,5 +302,38 @@ describe('storeHelpers', () => {
'0x842d35Cc6634C0532925a3b844Bc9e7595f0bEb0'
)
})

it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s as walletId', (key) => {
const prev = { addresses: {} } as any

expect(() => updateAddressInState(prev, key, 'ethereum', 0, '0xabc')).toThrow(
'walletId must not be a reserved object property name'
)
})

it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s as network', (key) => {
const prev = { addresses: {} } as any

expect(() => updateAddressInState(prev, 'wallet-1', key, 0, '0xabc')).toThrow(
'network must not be a reserved object property name'
)
})

it('should leave Object.prototype untouched when __proto__ is rejected', () => {
const prev = { addresses: {} } as any

expect(() => updateAddressInState(prev, '__proto__', 'ethereum', 0, '0xabc')).toThrow()

expect(({} as any).ethereum).toBeUndefined()
})
})
})

describe('resolveWalletId', () => {
it('should return the given wallet id', () => {
expect(resolveWalletId('wallet-1')).toBe('wallet-1')
})

it.each(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty'])('should reject %s', (key) => {
expect(() => resolveWalletId(key)).toThrow('walletId must not be a reserved object property name')
})
})})
Loading