From 4e107751b1d19296f3c1838ddbe2bc9eea1b2d58 Mon Sep 17 00:00:00 2001 From: babkenmes Date: Tue, 31 Mar 2026 22:50:14 +0400 Subject: [PATCH 1/2] Harden secret derivation: non-extractable CryptoKey, secure wallet-sign, simplified SecureStorage - Move key derivation to use non-extractable CryptoKey throughout the auth and wallet-sign layers - Simplify SecureStorage to store CryptoKey directly via IndexedDB structured cloning - Update all blockchain wallet-sign implementations to use createProtectedKey - Add secret-derivation safety rules for Claude Code Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/rules/chain-integration.md | 23 ++++--- .claude/rules/secret-derivation.md | 63 +++++++++++++++++++ packages/auth/src/key-derivation.ts | 23 +++++++ packages/auth/src/passkey.ts | 14 ++--- packages/auth/src/registry.ts | 10 +-- .../aztec/src/login/wallet-sign.ts | 7 +-- .../blockchains/evm/src/login/wallet-sign.ts | 10 +-- .../blockchains/fuel/src/login/wallet-sign.ts | 8 +-- .../solana/src/login/wallet-sign.ts | 7 +-- .../starknet/src/login/wallet-sign.ts | 7 +-- .../blockchains/ton/src/login/wallet-sign.ts | 8 +-- packages/react/src/hooks/useCreateSwap.ts | 4 +- packages/react/src/hooks/usePasskeyLogin.ts | 4 +- packages/react/src/hooks/useRevealSecret.ts | 4 +- .../react/src/hooks/useSecretDerivation.ts | 14 ++--- packages/react/src/hooks/useWalletLogin.ts | 2 +- packages/react/src/internal/SecureStorage.ts | 58 +++++------------ .../src/internal/secretDerivationStore.ts | 18 +++--- .../providers/SecretDerivationProvider.tsx | 2 +- packages/sdk/src/secret.ts | 46 ++++++++++++-- 20 files changed, 208 insertions(+), 124 deletions(-) create mode 100644 .claude/rules/secret-derivation.md diff --git a/.claude/rules/chain-integration.md b/.claude/rules/chain-integration.md index 374e3adc..6e8f1e4d 100644 --- a/.claude/rules/chain-integration.md +++ b/.claude/rules/chain-integration.md @@ -390,26 +390,29 @@ The `{namespace}` is the chain identifier used in the registry (e.g., `'eip155'` Each chain needs a `login/wallet-sign.ts` that derives a deterministic login key: ```ts -import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/sdk' +import { createProtectedKey } from '@train-protocol/auth' export const deriveKeyFrom{Chain}Wallet = async ( /* chain-specific wallet/provider */ -): Promise => { +): Promise => { // 1. Sign a fixed message: "I am using TRAIN" // Use the chain's native signing mechanism - const signature = /* sign the message */ + const signature = /* sign the message — get as Uint8Array */ - // 2. Derive key material from signature - const inputMaterial = Buffer.from(/* signature bytes */) - const identitySalt = Buffer.from(IDENTITY_SALT, 'utf8') - return Buffer.from(deriveKeyMaterial(inputMaterial, identitySalt)) + // 2. Import signature as non-extractable CryptoKey + const key = await createProtectedKey(signature) + signature.fill(0) // zero raw bytes + return key } ``` Rules: - Always use `"I am using TRAIN"` as the message content -- Always use `IDENTITY_SALT` and `deriveKeyMaterial` from the base SDK +- Always use `createProtectedKey` from `@train-protocol/auth` to import — do NOT run intermediate HKDF in JS memory +- Return `CryptoKey` (non-extractable), never raw `Uint8Array` +- Zero signature bytes after import where practical - Define a minimal wallet/provider interface (don't import the full chain SDK for the type) +- See `.claude/rules/secret-derivation.md` for full security invariants --- @@ -441,8 +444,8 @@ import { ConsensusOptions, } from '@train-protocol/sdk' -// Key derivation -import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/sdk' +// Key derivation (for wallet-sign implementations) +import { createProtectedKey } from '@train-protocol/auth' ``` --- diff --git a/.claude/rules/secret-derivation.md b/.claude/rules/secret-derivation.md new file mode 100644 index 00000000..4a9adfae --- /dev/null +++ b/.claude/rules/secret-derivation.md @@ -0,0 +1,63 @@ +# Secret Derivation & Hashlock Rules + +Rules for working with the secret derivation system. Violations can lock user funds permanently. + +--- + +## 1. NEVER Change the Derivation Scheme + +The secret derivation chain determines the hashlock used to lock funds on-chain: + +``` +raw key bytes → createProtectedKey() → CryptoKey (non-extractable) +CryptoKey + nonce → deriveSecretFromCryptoKey() → secret → SHA256 → hashlock +``` + +**If the derivation output changes, existing on-chain locks become unredeemable.** + +Do NOT: +- Change the HKDF parameters (hash algorithm, info string, output length) in `deriveSecretFromCryptoKey` +- Change the salt derivation from the timelock nonce (the `timelockToSalt` logic) +- Add, remove, or reorder HKDF steps in the derivation chain +- Change `secretToHashlock` (SHA-256 of the secret) + +If a derivation scheme change is intentional (e.g. protocol upgrade), it must be: +1. Explicitly approved and documented +2. Versioned so old secrets can still be derived for existing locks +3. Coordinated with solver infrastructure + +## 2. Master Key Must Stay Non-Extractable + +The master key (`derivedKey` in `secretDerivationStore`) is a **non-extractable `CryptoKey`**. This is a security invariant. + +Do NOT: +- Store raw key bytes (`Uint8Array`) in the store, context, or any persistent storage +- Export or extract the CryptoKey to bytes (e.g. via `crypto.subtle.exportKey`) +- Pass `extractable: true` to `importKey` for master keys +- Log, serialize, or transmit the master key + +The only permitted operation on the master key is `crypto.subtle.deriveBits()`. + +## 3. Per-Swap Secrets Are Short-Lived + +The per-swap secret (output of `deriveSecretFromCryptoKey`) is a `Uint8Array` — it must be readable to compute the hashlock and send to the solver API. This is acceptable because: +- It's a single-use, per-swap value (not the master key) +- It's only in memory briefly during swap creation and secret reveal + +Do NOT persist per-swap secrets to storage or global state. + +## 4. CryptoKey Storage in IndexedDB + +`SecureStorage.storeCryptoKey()` stores the `CryptoKey` directly in IndexedDB via structured cloning. The browser preserves the non-extractable flag across sessions. + +Do NOT replace this with encrypt-then-store of raw bytes — that defeats the purpose. + +## 5. Wallet-Sign Implementations + +Each chain's `wallet-sign.ts` must: +1. Get signature bytes from the wallet +2. Call `createProtectedKey(signatureBytes)` to import as non-extractable CryptoKey +3. Zero the signature bytes after import (`signatureBytes.fill(0)`) where practical +4. Return the `CryptoKey` + +Do NOT run intermediate HKDF in JS memory before importing — the raw bytes go straight into Web Crypto. diff --git a/packages/auth/src/key-derivation.ts b/packages/auth/src/key-derivation.ts index 531b97c9..b42b8f2c 100644 --- a/packages/auth/src/key-derivation.ts +++ b/packages/auth/src/key-derivation.ts @@ -5,7 +5,30 @@ export const IDENTITY_SALT = 'train-identity-v1'; const HKDF_INFO = new TextEncoder().encode('train-signature-key-derivation'); const KEY_LENGTH = 32; +/** + * @deprecated Use `createProtectedKey` instead — it keeps key material out of JS memory. + */ export const deriveKeyMaterial = ( ikm: Uint8Array, salt: Uint8Array ): Uint8Array => hkdf(sha256, ikm, salt, HKDF_INFO, KEY_LENGTH); + +/** + * Import raw key bytes as a non-extractable HKDF CryptoKey. + * The raw bytes are copied into an ArrayBuffer for Web Crypto; + * the caller should zero the original Uint8Array after this call. + * The returned CryptoKey can only be used for HKDF deriveBits — it cannot be exported or read. + */ +export const createProtectedKey = async ( + rawKey: Uint8Array, +): Promise => { + const buf = new ArrayBuffer(rawKey.byteLength); + new Uint8Array(buf).set(rawKey); + return crypto.subtle.importKey( + 'raw', + buf, + { name: 'HKDF' }, + false, // non-extractable + ['deriveBits'], + ); +}; diff --git a/packages/auth/src/passkey.ts b/packages/auth/src/passkey.ts index 46445aa7..152b26e6 100644 --- a/packages/auth/src/passkey.ts +++ b/packages/auth/src/passkey.ts @@ -1,5 +1,5 @@ import { sha256 } from "@noble/hashes/sha2.js"; -import { deriveKeyMaterial, IDENTITY_SALT } from './key-derivation' +import { createProtectedKey, IDENTITY_SALT } from './key-derivation' import type { PasskeyCredentialStorage } from './storage' import { base64URLStringToBuffer, bufferToBase64URLString } from './utils' @@ -71,7 +71,7 @@ export const checkPrfSupport = async (): Promise => { export interface RegisterPasskeyResult { credentialId: string; - key?: Uint8Array; + key?: CryptoKey; } export const registerPasskey = async ( @@ -127,8 +127,8 @@ export const registerPasskey = async ( if (prfFirst) { const ikm = new Uint8Array(prfFirst); - const identitySalt = new TextEncoder().encode(IDENTITY_SALT); - const key = new Uint8Array(deriveKeyMaterial(ikm, identitySalt)); + const key = await createProtectedKey(ikm); + ikm.fill(0); return { credentialId, key }; } @@ -138,7 +138,7 @@ export const registerPasskey = async ( export const deriveKeyWithPasskey = async ( options?: { createIfMissing?: boolean }, storage?: PasskeyCredentialStorage -): Promise<{ key: Uint8Array; credentialId: string }> => { +): Promise<{ key: CryptoKey; credentialId: string }> => { const createIfMissing = options?.createIfMissing !== false; if (typeof window === 'undefined') throw new Error('Passkey auth must run in a browser'); @@ -172,8 +172,8 @@ export const deriveKeyWithPasskey = async ( if (!prfFirst) throw new Error('Passkey PRF extension not available in this browser/authenticator'); const ikm = new Uint8Array(prfFirst); - const identitySalt = new TextEncoder().encode(IDENTITY_SALT); - const key = new Uint8Array(deriveKeyMaterial(ikm, identitySalt)); + const key = await createProtectedKey(ikm); + ikm.fill(0); return { key, credentialId }; }; diff --git a/packages/auth/src/registry.ts b/packages/auth/src/registry.ts index 1274a438..e791b02d 100644 --- a/packages/auth/src/registry.ts +++ b/packages/auth/src/registry.ts @@ -8,14 +8,14 @@ type WalletSignConfigFor = N extends keyof WalletSignConfigMap ? WalletSignConfigMap[N] : Record -type WalletSignFactory = (config: any) => Promise +type WalletSignFactory = (config: any) => Promise export class TrainAuth { private walletSignRegistry = new Map() registerWalletSign( providerName: N, - factory: (config: WalletSignConfigFor) => Promise, + factory: (config: WalletSignConfigFor) => Promise, ): void { this.walletSignRegistry.set(providerName, factory) } @@ -23,7 +23,7 @@ export class TrainAuth { deriveKeyFromWallet( providerName: N, config: WalletSignConfigFor, - ): Promise { + ): Promise { const factory = this.walletSignRegistry.get(providerName) if (!factory) { throw new Error( @@ -44,7 +44,7 @@ export const defaultTrainAuth = new TrainAuth() export function registerWalletSign( providerName: N, - factory: (config: WalletSignConfigFor) => Promise, + factory: (config: WalletSignConfigFor) => Promise, ): void { return defaultTrainAuth.registerWalletSign(providerName, factory) } @@ -52,7 +52,7 @@ export function registerWalletSign( export function deriveKeyFromWallet( providerName: N, config: WalletSignConfigFor, -): Promise { +): Promise { return defaultTrainAuth.deriveKeyFromWallet(providerName, config) } diff --git a/packages/blockchains/aztec/src/login/wallet-sign.ts b/packages/blockchains/aztec/src/login/wallet-sign.ts index 32982d32..0cffd6cb 100644 --- a/packages/blockchains/aztec/src/login/wallet-sign.ts +++ b/packages/blockchains/aztec/src/login/wallet-sign.ts @@ -1,4 +1,4 @@ -import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth' +import { createProtectedKey } from '@train-protocol/auth' /** * Minimal interface for the Aztec wallet needed by the login flow. @@ -17,7 +17,7 @@ export interface AztecWalletLike { export const deriveKeyFromAztecWallet = async ( wallet: AztecWalletLike, address: string, -): Promise => { +): Promise => { if (!wallet) { throw new Error('Aztec wallet not connected') } @@ -42,6 +42,5 @@ export const deriveKeyFromAztecWallet = async ( // Serialize the witness into bytes for key derivation const witnessBuffer = authWitness.toBuffer() - const identitySalt = new TextEncoder().encode(IDENTITY_SALT) - return new Uint8Array(deriveKeyMaterial(witnessBuffer, identitySalt)) + return createProtectedKey(witnessBuffer) } diff --git a/packages/blockchains/evm/src/login/wallet-sign.ts b/packages/blockchains/evm/src/login/wallet-sign.ts index 39f2dce6..b4846032 100644 --- a/packages/blockchains/evm/src/login/wallet-sign.ts +++ b/packages/blockchains/evm/src/login/wallet-sign.ts @@ -1,4 +1,4 @@ -import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth'; +import { createProtectedKey } from '@train-protocol/auth'; export interface Eip1193Provider { request(args: { method: string; params: unknown[] }): Promise @@ -38,7 +38,7 @@ export const deriveKeyFromEvmSignature = async ( provider: Eip1193Provider, address: `0x${string}`, options?: { sandbox?: boolean; currentChainId?: number } -): Promise => { +): Promise => { const isSandbox = options?.sandbox ?? false; const signingChainId = isSandbox ? 11155111 : 1; const signingChainHex = isSandbox ? '0xAA36A7' : '0x1'; @@ -67,7 +67,7 @@ export const deriveKeyFromEvmSignature = async ( const signatureHex = signature.startsWith('0x') ? signature.slice(2) : signature; const inputMaterial = hexToUint8Array(signatureHex); - const identitySalt = new TextEncoder().encode(IDENTITY_SALT); - - return new Uint8Array(deriveKeyMaterial(inputMaterial, identitySalt)); + const key = await createProtectedKey(inputMaterial); + inputMaterial.fill(0); + return key; }; diff --git a/packages/blockchains/fuel/src/login/wallet-sign.ts b/packages/blockchains/fuel/src/login/wallet-sign.ts index bb73e82c..76a2bf65 100644 --- a/packages/blockchains/fuel/src/login/wallet-sign.ts +++ b/packages/blockchains/fuel/src/login/wallet-sign.ts @@ -1,4 +1,4 @@ -import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth' +import { createProtectedKey } from '@train-protocol/auth' /** * Minimal interface for a Fuel wallet needed by the login flow. @@ -16,7 +16,7 @@ export interface FuelWalletLike { */ export const deriveKeyFromFuelWallet = async ( wallet: FuelWalletLike, -): Promise => { +): Promise => { if (!wallet) { throw new Error('Fuel wallet not connected') } @@ -28,7 +28,5 @@ export const deriveKeyFromFuelWallet = async ( for (let i = 0; i < sigHex.length; i += 2) { inputMaterial[i / 2] = parseInt(sigHex.substring(i, i + 2), 16) } - const identitySalt = new TextEncoder().encode(IDENTITY_SALT) - - return new Uint8Array(deriveKeyMaterial(inputMaterial, identitySalt)) + return createProtectedKey(inputMaterial) } diff --git a/packages/blockchains/solana/src/login/wallet-sign.ts b/packages/blockchains/solana/src/login/wallet-sign.ts index 29b1cc41..9dd2dbd0 100644 --- a/packages/blockchains/solana/src/login/wallet-sign.ts +++ b/packages/blockchains/solana/src/login/wallet-sign.ts @@ -1,4 +1,4 @@ -import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth' +import { createProtectedKey } from '@train-protocol/auth' /** * Minimal interface for the Solana wallet needed by the login flow. @@ -15,7 +15,7 @@ export interface SolanaWalletLike { */ export const deriveKeyFromSolanaWallet = async ( wallet: SolanaWalletLike, -): Promise => { +): Promise => { if (!wallet?.signMessage) { throw new Error('Solana wallet does not support message signing') } @@ -23,6 +23,5 @@ export const deriveKeyFromSolanaWallet = async ( const message = new TextEncoder().encode('I am using TRAIN') const signature = await wallet.signMessage(message) - const identitySalt = new TextEncoder().encode(IDENTITY_SALT) - return new Uint8Array(deriveKeyMaterial(signature, identitySalt)) + return createProtectedKey(signature) } diff --git a/packages/blockchains/starknet/src/login/wallet-sign.ts b/packages/blockchains/starknet/src/login/wallet-sign.ts index 6a7f9169..b697d31b 100644 --- a/packages/blockchains/starknet/src/login/wallet-sign.ts +++ b/packages/blockchains/starknet/src/login/wallet-sign.ts @@ -1,4 +1,4 @@ -import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth' +import { createProtectedKey } from '@train-protocol/auth' /** * Minimal interface for a Starknet account needed by the login flow. @@ -18,7 +18,7 @@ export const deriveKeyFromStarknetWallet = async ( account: StarknetAccountLike, _address: string, options?: { chainId?: string }, -): Promise => { +): Promise => { if (!account) { throw new Error('Starknet wallet not connected') } @@ -58,6 +58,5 @@ export const deriveKeyFromStarknetWallet = async ( }) const inputMaterial = new Uint8Array(sigBytes) - const identitySalt = new TextEncoder().encode(IDENTITY_SALT) - return new Uint8Array(deriveKeyMaterial(inputMaterial, identitySalt)) + return createProtectedKey(inputMaterial) } diff --git a/packages/blockchains/ton/src/login/wallet-sign.ts b/packages/blockchains/ton/src/login/wallet-sign.ts index c88196c4..c48ae165 100644 --- a/packages/blockchains/ton/src/login/wallet-sign.ts +++ b/packages/blockchains/ton/src/login/wallet-sign.ts @@ -1,4 +1,4 @@ -import { deriveKeyMaterial, IDENTITY_SALT } from '@train-protocol/auth' +import { createProtectedKey } from '@train-protocol/auth' /** * Minimal interface for a TON wallet needed by the login flow. @@ -16,7 +16,7 @@ export interface TonWalletLike { */ export const deriveKeyFromTonWallet = async ( wallet: TonWalletLike, -): Promise => { +): Promise => { if (!wallet) { throw new Error('TON wallet not connected') } @@ -28,7 +28,5 @@ export const deriveKeyFromTonWallet = async ( for (let i = 0; i < sigHex.length; i += 2) { inputMaterial[i / 2] = parseInt(sigHex.substring(i, i + 2), 16) } - const identitySalt = new TextEncoder().encode(IDENTITY_SALT) - - return new Uint8Array(deriveKeyMaterial(inputMaterial, identitySalt)) + return createProtectedKey(inputMaterial) } diff --git a/packages/react/src/hooks/useCreateSwap.ts b/packages/react/src/hooks/useCreateSwap.ts index ee25952c..285d377a 100644 --- a/packages/react/src/hooks/useCreateSwap.ts +++ b/packages/react/src/hooks/useCreateSwap.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useRef } from 'react' import { - deriveSecretFromTimelock, + deriveSecretFromCryptoKey, secretToHashlock, bytesToHex, formatUnits, @@ -56,7 +56,7 @@ export function useCreateSwap(): UseCreateSwapResult { } const nonce = Date.now() - const secretBytes = deriveSecretFromTimelock(derivedKey, nonce) + const secretBytes = await deriveSecretFromCryptoKey(derivedKey, nonce) const secret = bytesToHex(Array.from(secretBytes)) const hashlock = secretToHashlock(secret) diff --git a/packages/react/src/hooks/usePasskeyLogin.ts b/packages/react/src/hooks/usePasskeyLogin.ts index 0aab5baf..4ceb3718 100644 --- a/packages/react/src/hooks/usePasskeyLogin.ts +++ b/packages/react/src/hooks/usePasskeyLogin.ts @@ -7,8 +7,8 @@ import { import type { PrfSupportResult, PasskeyCredentialStorage } from '@train-protocol/auth' export interface UsePasskeyLoginResult { - login: () => Promise<{ key: Uint8Array; credentialId: string }> - register: (displayName?: string) => Promise<{ credentialId: string; key?: Uint8Array }> + login: () => Promise<{ key: CryptoKey; credentialId: string }> + register: (displayName?: string) => Promise<{ credentialId: string; key?: CryptoKey }> isSupported: boolean | null prfDetails: PrfSupportResult | null checkSupport: () => Promise diff --git a/packages/react/src/hooks/useRevealSecret.ts b/packages/react/src/hooks/useRevealSecret.ts index d399a491..237a75f7 100644 --- a/packages/react/src/hooks/useRevealSecret.ts +++ b/packages/react/src/hooks/useRevealSecret.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useRef } from 'react' import { - deriveSecretFromTimelock, + deriveSecretFromCryptoKey, bytesToHex, } from '@train-protocol/sdk' import type { UserLockDetails } from '@train-protocol/sdk' @@ -121,7 +121,7 @@ export function useRevealSecret(hashlock: string | null | undefined): UseRevealS } try { - const secretBytes = deriveSecretFromTimelock(derivedKey, nonce) + const secretBytes = await deriveSecretFromCryptoKey(derivedKey, nonce) const secret = bytesToHex(Array.from(secretBytes)) await apiClient.revealSecret(solverId, swapConfig.hashlock, secret) diff --git a/packages/react/src/hooks/useSecretDerivation.ts b/packages/react/src/hooks/useSecretDerivation.ts index 0ecda7bd..450fd052 100644 --- a/packages/react/src/hooks/useSecretDerivation.ts +++ b/packages/react/src/hooks/useSecretDerivation.ts @@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react' import { useStore } from 'zustand' import { shallow } from 'zustand/shallow' import { - deriveSecretFromTimelock, + deriveSecretFromCryptoKey, secretToHashlock, bytesToHex, } from '@train-protocol/sdk' @@ -58,7 +58,7 @@ export interface UseSecretDerivationResult { loginWithWallet: (chainNamespace: string, config?: Record) => Promise logout: () => void - deriveSecret: (nonce?: number) => { secret: string; nonce: number; hashlock: string } | null + deriveSecret: (nonce?: number) => Promise<{ secret: string; nonce: number; hashlock: string } | null> // Passkey management registerPasskey: (displayName?: string) => Promise @@ -72,8 +72,8 @@ export interface UseSecretDerivationResult { /** @internal Full result including store and derivedKey — used by SecretDerivationProvider only */ export interface UseSecretDerivationInternalResult extends UseSecretDerivationResult { - /** @internal Raw key material — not exposed to consumers */ - derivedKey: Uint8Array | null + /** @internal Non-extractable CryptoKey — not exposed to consumers */ + derivedKey: CryptoKey | null _store: SecretDerivationStore } @@ -113,7 +113,7 @@ export function useSecretDerivation(options?: UseSecretDerivationOptions): UseSe store.getState().setDerivationStatus('signing') store.getState().setDerivationMessage('Confirm with passkey') try { - let key: Uint8Array + let key: CryptoKey let credentialId: string if (options?.forceCreate) { @@ -183,11 +183,11 @@ export function useSecretDerivation(options?: UseSecretDerivationOptions): UseSe store.getState().logout() }, [store]) - const deriveSecret = useCallback((nonce?: number) => { + const deriveSecret = useCallback(async (nonce?: number) => { const currentKey = store.getState().derivedKey if (!currentKey) return null const timestamp = nonce ?? Date.now() - const secretBytes = deriveSecretFromTimelock(currentKey, timestamp) + const secretBytes = await deriveSecretFromCryptoKey(currentKey, timestamp) const secret = bytesToHex(Array.from(secretBytes)) const hashlock = secretToHashlock(secret) return { secret, nonce: timestamp, hashlock } diff --git a/packages/react/src/hooks/useWalletLogin.ts b/packages/react/src/hooks/useWalletLogin.ts index 1cbc841a..f5f2eac0 100644 --- a/packages/react/src/hooks/useWalletLogin.ts +++ b/packages/react/src/hooks/useWalletLogin.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { deriveKeyFromWallet } from '@train-protocol/auth' export interface UseWalletLoginResult { - login: (providerName: string, config: Record) => Promise + login: (providerName: string, config: Record) => Promise } export function useWalletLogin(): UseWalletLoginResult { diff --git a/packages/react/src/internal/SecureStorage.ts b/packages/react/src/internal/SecureStorage.ts index 6cb3544e..a0ffc6fb 100644 --- a/packages/react/src/internal/SecureStorage.ts +++ b/packages/react/src/internal/SecureStorage.ts @@ -4,19 +4,6 @@ const KEYS_STORE = 'keys' const DATA_STORE = 'data' const WRAPPING_KEY_ID = 'wrapping-key' -/** AES-GCM encrypted payload stored in IndexedDB */ -interface EncryptedBlob { - iv: Uint8Array - ciphertext: ArrayBuffer -} - -/** Extract a proper ArrayBuffer from a Uint8Array (handles SharedArrayBuffer edge case). */ -function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { - const ab = new ArrayBuffer(bytes.byteLength) - new Uint8Array(ab).set(bytes) - return ab -} - /** * Secure IndexedDB storage with AES-GCM encryption via Web Crypto API. * @@ -41,40 +28,23 @@ export class SecureStorage { this.wrappingKey = await this.getOrCreateWrappingKey() } - /** Encrypt a Uint8Array and store it under the given key. */ - async encryptAndStore(key: string, data: Uint8Array): Promise { - if (!this.db || !this.wrappingKey) return - const iv = crypto.getRandomValues(new Uint8Array(12)) - const ciphertext = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - this.wrappingKey, - toArrayBuffer(data), - ) - - const blob: EncryptedBlob = { iv, ciphertext } - await this.put(DATA_STORE, key, blob) + /** + * Store a non-extractable CryptoKey directly in IndexedDB. + * IndexedDB supports structured cloning of CryptoKey objects, + * preserving the non-extractable flag across sessions. + */ + async storeCryptoKey(key: string, cryptoKey: CryptoKey): Promise { + if (!this.db) return + await this.put(DATA_STORE, key, cryptoKey) } - /** Load and decrypt a Uint8Array from the given key. Returns null if not found. */ - async loadAndDecrypt(key: string): Promise { - if (!this.db || !this.wrappingKey) return null - - const blob = await this.get(DATA_STORE, key) - if (!blob || !blob.iv || !blob.ciphertext) return null - - try { - const iv = blob.iv instanceof Uint8Array ? toArrayBuffer(blob.iv) : blob.iv - const plaintext = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, - this.wrappingKey, - blob.ciphertext, - ) - return new Uint8Array(plaintext) - } catch { - // Decryption failed (corrupt data or key mismatch) - return null - } + /** Load a CryptoKey from IndexedDB. Returns null if not found. */ + async loadCryptoKey(key: string): Promise { + if (!this.db) return null + const result = await this.get(DATA_STORE, key) + if (!result || !(result instanceof CryptoKey)) return null + return result } /** Store a JSON-serializable value (unencrypted). Use for non-sensitive data. */ diff --git a/packages/react/src/internal/secretDerivationStore.ts b/packages/react/src/internal/secretDerivationStore.ts index 13f99f0b..7ddb0b08 100644 --- a/packages/react/src/internal/secretDerivationStore.ts +++ b/packages/react/src/internal/secretDerivationStore.ts @@ -11,7 +11,7 @@ export interface LoginWalletInfo { chainId?: string | number } -const DERIVED_KEY_STORAGE_KEY = 'derived-key' +const DERIVED_CRYPTO_KEY_STORAGE_KEY = 'derived-crypto-key' const AUTH_META_STORAGE_KEY = 'auth-meta' interface AuthMeta { @@ -22,7 +22,7 @@ interface AuthMeta { export interface SecretDerivationStoreState { // Persisted state method: DerivationMethod | null - derivedKey: Uint8Array | null + derivedKey: CryptoKey | null loginWallet: LoginWalletInfo | null // Hydration state @@ -35,7 +35,7 @@ export interface SecretDerivationStoreState { credentialVersion: number // Actions - setLogin: (method: DerivationMethod, key: Uint8Array) => void + setLogin: (method: DerivationMethod, key: CryptoKey) => void setLoginWallet: (wallet: LoginWalletInfo | null) => void setDerivationStatus: (status: 'idle' | 'signing') => void setDerivationMessage: (message: string) => void @@ -70,7 +70,7 @@ export function createSecretDerivationStore(options?: CreateSecretDerivationStor setLogin: (method, key) => { set({ method, derivedKey: key }) if (secureStorage) { - secureStorage.encryptAndStore(DERIVED_KEY_STORAGE_KEY, key).catch(() => {}) + secureStorage.storeCryptoKey(DERIVED_CRYPTO_KEY_STORAGE_KEY, key).catch(() => {}) const meta: AuthMeta = { method, loginWallet: get().loginWallet } secureStorage.setJSON(AUTH_META_STORAGE_KEY, meta).catch(() => {}) } @@ -93,11 +93,9 @@ export function createSecretDerivationStore(options?: CreateSecretDerivationStor bumpCredentialVersion: () => set((s) => ({ credentialVersion: s.credentialVersion + 1 })), logout: () => { - // Null the store reference — do NOT zeroize the Uint8Array buffer. - // In-flight closures (e.g. useRevealSecret mid-reveal) may still hold - // a reference to the same buffer; mutating it would corrupt their key - // and could lock user funds. GC will reclaim the buffer once no - // references remain. + // Null the store reference. The CryptoKey is non-extractable — + // no raw bytes to zeroize. In-flight closures holding a reference + // can still derive secrets until GC reclaims the key. set({ method: null, derivedKey: null, loginWallet: null }) if (secureStorage) { secureStorage.clear().catch(() => {}) @@ -108,7 +106,7 @@ export function createSecretDerivationStore(options?: CreateSecretDerivationStor secureStorage = storage try { const [derivedKey, meta] = await Promise.all([ - storage.loadAndDecrypt(DERIVED_KEY_STORAGE_KEY), + storage.loadCryptoKey(DERIVED_CRYPTO_KEY_STORAGE_KEY), storage.getJSON(AUTH_META_STORAGE_KEY), ]) diff --git a/packages/react/src/providers/SecretDerivationProvider.tsx b/packages/react/src/providers/SecretDerivationProvider.tsx index af976d9d..f70b3ec0 100644 --- a/packages/react/src/providers/SecretDerivationProvider.tsx +++ b/packages/react/src/providers/SecretDerivationProvider.tsx @@ -147,7 +147,7 @@ export function SecretDerivationProvider({ ? hook.isReady : hydrated - // Exclude derivedKey and _store from the public context value + // Exclude derivedKey (CryptoKey) and _store from the public context value const { derivedKey: _dk, _store: _s, ...publicHook } = hook const value = useMemo(() => ({ diff --git a/packages/sdk/src/secret.ts b/packages/sdk/src/secret.ts index e5d50191..1846f537 100644 --- a/packages/sdk/src/secret.ts +++ b/packages/sdk/src/secret.ts @@ -7,16 +7,50 @@ const SECRET_INFO = new TextEncoder().encode('train-signature-key-derivation'); const normalizeHex = (value: string): string => value.length % 2 === 0 ? value : `0${value}`; +const timelockToSalt = (timelock: number): Uint8Array => { + const timelockHex = normalizeHex(timelock.toString(16)); + const salt = new Uint8Array(timelockHex.length / 2); + for (let i = 0; i < timelockHex.length; i += 2) { + salt[i / 2] = parseInt(timelockHex.substring(i, i + 2), 16); + } + return salt; +}; + +/** + * @deprecated Use `deriveSecretFromCryptoKey` with a CryptoKey instead. + * This variant accepts raw key bytes in JS memory. + */ export const deriveSecretFromTimelock = ( initialKey: Uint8Array, timelock: number ): Uint8Array => { - const timelockHex = normalizeHex(timelock.toString(16)); - const timelockSalt = new Uint8Array(timelockHex.length / 2); - for (let i = 0; i < timelockHex.length; i += 2) { - timelockSalt[i / 2] = parseInt(timelockHex.substring(i, i + 2), 16); - } - return new Uint8Array(hkdf(sha256, initialKey, timelockSalt, SECRET_INFO, 32)); + return new Uint8Array(hkdf(sha256, initialKey, timelockToSalt(timelock), SECRET_INFO, 32)); +}; + +/** + * Derive a per-swap secret from a non-extractable CryptoKey + timelock nonce. + * The master key never enters JS memory — only the derived per-swap secret is returned. + */ +export const deriveSecretFromCryptoKey = async ( + masterKey: CryptoKey, + timelock: number, +): Promise => { + const salt = timelockToSalt(timelock); + const saltBuf = new ArrayBuffer(salt.byteLength); + new Uint8Array(saltBuf).set(salt); + const infoBuf = new ArrayBuffer(SECRET_INFO.byteLength); + new Uint8Array(infoBuf).set(SECRET_INFO); + const bits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: saltBuf, + info: infoBuf, + }, + masterKey, + 256, // 32 bytes + ); + return new Uint8Array(bits); }; export const secretToHashlock = (secret: string): string => { From c30a13bde86a6887cd54e9f8f3d0bed5858b518e Mon Sep 17 00:00:00 2001 From: Aren Date: Wed, 8 Apr 2026 20:06:31 +0400 Subject: [PATCH 2/2] refactor: update return type of deriveKeyFromTronWallet to CryptoKey - Changed the return type of the deriveKeyFromTronWallet function from Uint8Array to CryptoKey for improved type safety and clarity. --- packages/blockchains/tron/src/login/wallet-sign.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockchains/tron/src/login/wallet-sign.ts b/packages/blockchains/tron/src/login/wallet-sign.ts index 6bd77972..f94786a9 100644 --- a/packages/blockchains/tron/src/login/wallet-sign.ts +++ b/packages/blockchains/tron/src/login/wallet-sign.ts @@ -16,7 +16,7 @@ export interface TronWalletLike { */ export const deriveKeyFromTronWallet = async ( wallet: TronWalletLike, -): Promise => { +): Promise => { if (!wallet) { throw new Error('Tron wallet not connected') }