Skip to content

Commit b16cafd

Browse files
authored
feat(*): add withPrivateKey method for scoped private key access (#426)
* feat(*): add `withPrivateKey` method for scoped private key access Introduce `withPrivateKey` method across supported wallets, enabling scoped access to private keys during asynchronous operations. Added `canUsePrivateKey` property to indicate support for this method. Ensured all key material is securely cleaned after usage. * chore(*): prettier fixes
1 parent 1fb2b50 commit b16cafd

13 files changed

Lines changed: 625 additions & 8 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# Using Private Keys Safely
2+
3+
## The `withPrivateKey` Callback Pattern
4+
5+
The `withPrivateKey` method provides scoped access to a wallet's raw private key through a callback. The key material is guaranteed to be zeroed from memory when the callback completes, whether it succeeds or throws.
6+
7+
This is useful for operations beyond standard transaction signing, such as custom cryptographic operations, cross-chain signing, authentication challenges, or deriving secondary keys.
8+
9+
{% hint style="warning" %}
10+
**Supported Wallets**
11+
12+
Only wallets that manage raw key material support `withPrivateKey`. Currently this includes:
13+
14+
* **Web3Auth** -- key is fetched fresh from the Web3Auth provider for each call
15+
* **Mnemonic** -- a copy of the in-memory key is provided (testnet only)
16+
17+
All other wallets (Pera, Defly, Lute, WalletConnect, etc.) use external signing and will throw `"Method not supported: withPrivateKey"`. Always check `canUsePrivateKey` before calling.
18+
{% endhint %}
19+
20+
### Basic Usage
21+
22+
{% tabs %}
23+
{% tab title="React" %}
24+
```tsx
25+
import { useWallet } from '@txnlab/use-wallet-react'
26+
27+
function KeyOperation() {
28+
const { activeWallet, withPrivateKey } = useWallet()
29+
30+
const handleOperation = async () => {
31+
if (!activeWallet?.canUsePrivateKey) {
32+
console.error('This wallet does not support private key access')
33+
return
34+
}
35+
36+
const result = await withPrivateKey(async (secretKey) => {
37+
// secretKey is a 64-byte Uint8Array (Algorand format: seed + public key)
38+
// Perform your operation here
39+
return doSomethingWith(secretKey)
40+
})
41+
// secretKey is zeroed at this point -- guaranteed
42+
}
43+
44+
return <button onClick={handleOperation}>Run Operation</button>
45+
}
46+
```
47+
{% endtab %}
48+
49+
{% tab title="Vue" %}
50+
```typescript
51+
<script setup lang="ts">
52+
import { useWallet } from '@txnlab/use-wallet-vue'
53+
54+
const { activeWallet, withPrivateKey } = useWallet()
55+
56+
const handleOperation = async () => {
57+
if (!activeWallet.value?.canUsePrivateKey) {
58+
console.error('This wallet does not support private key access')
59+
return
60+
}
61+
62+
const result = await withPrivateKey(async (secretKey) => {
63+
return doSomethingWith(secretKey)
64+
})
65+
}
66+
</script>
67+
```
68+
{% endtab %}
69+
70+
{% tab title="Solid" %}
71+
```tsx
72+
import { useWallet } from '@txnlab/use-wallet-solid'
73+
74+
function KeyOperation() {
75+
const { activeWallet, withPrivateKey } = useWallet()
76+
77+
const handleOperation = async () => {
78+
if (!activeWallet()?.canUsePrivateKey) {
79+
console.error('This wallet does not support private key access')
80+
return
81+
}
82+
83+
const result = await withPrivateKey(async (secretKey) => {
84+
return doSomethingWith(secretKey)
85+
})
86+
}
87+
88+
return <button onClick={handleOperation}>Run Operation</button>
89+
}
90+
```
91+
{% endtab %}
92+
93+
{% tab title="Svelte" %}
94+
```typescript
95+
<script lang="ts">
96+
import { useWallet } from '@txnlab/use-wallet-svelte'
97+
98+
const { activeWallet, withPrivateKey } = useWallet()
99+
100+
const handleOperation = async () => {
101+
if (!activeWallet()?.canUsePrivateKey) {
102+
console.error('This wallet does not support private key access')
103+
return
104+
}
105+
106+
const result = await withPrivateKey(async (secretKey) => {
107+
return doSomethingWith(secretKey)
108+
})
109+
}
110+
</script>
111+
112+
<button onclick={handleOperation}>Run Operation</button>
113+
```
114+
{% endtab %}
115+
{% endtabs %}
116+
117+
### How It Works
118+
119+
The `withPrivateKey` method follows a "loan" pattern:
120+
121+
1. The wallet obtains the raw key material (for Web3Auth, this means a fresh fetch from the provider)
122+
2. A **copy** of the 64-byte Algorand secret key is created for the callback
123+
3. Your callback receives the copy and can perform any async operation with it
124+
4. When the callback returns (or throws), the copy is overwritten with random bytes then zeroed using `zeroMemory()`
125+
5. The original key material and any intermediate containers are also cleared
126+
127+
The key is a standard 64-byte Algorand secret key (32-byte ed25519 seed concatenated with 32-byte public key), compatible with `algosdk` operations.
128+
129+
### Security Guarantees
130+
131+
The library provides several layers of defense:
132+
133+
* **Scoped access** -- The key only exists within the callback closure. There is no method that returns a key directly.
134+
* **Guaranteed cleanup** -- `try/finally` blocks ensure the key buffer is zeroed even if your callback throws an exception.
135+
* **Copy isolation** -- Your callback receives an independent copy. The wallet's internal key state is never affected by what you do with the buffer.
136+
* **Fresh fetch** -- Web3Auth fetches the key from the provider on every call. Keys are never cached between operations.
137+
* **Anti-optimization zeroing** -- `zeroMemory()` writes random data before zeroing, preventing compiler optimizations from eliding the clear.
138+
139+
{% hint style="info" %}
140+
**JavaScript Memory Limitations**
141+
142+
JavaScript does not offer guaranteed immediate memory clearing due to garbage collection. The `zeroMemory()` function provides defense-in-depth by overwriting the buffer contents immediately, but copies of the data could theoretically persist in GC-managed memory until collected. This is an inherent limitation of the JavaScript runtime, not a flaw in the implementation.
143+
{% endhint %}
144+
145+
## Best Practices
146+
147+
### 1. Keep callbacks short and focused
148+
149+
Do the minimum amount of work needed with the key. The longer the callback runs, the longer the key material lives in memory.
150+
151+
```typescript
152+
// Good -- focused operation
153+
await withPrivateKey(async (secretKey) => {
154+
return signChallenge(secretKey, challenge)
155+
})
156+
157+
// Avoid -- unnecessarily long key exposure
158+
await withPrivateKey(async (secretKey) => {
159+
const challenge = await fetchChallengeFromServer() // network round trip
160+
const signature = signChallenge(secretKey, challenge)
161+
await submitSignatureToServer(signature) // another round trip
162+
return signature
163+
})
164+
```
165+
166+
Prefer fetching inputs before the callback and submitting results after:
167+
168+
```typescript
169+
// Better -- key exposure limited to the signing operation
170+
const challenge = await fetchChallengeFromServer()
171+
172+
const signature = await withPrivateKey(async (secretKey) => {
173+
return signChallenge(secretKey, challenge)
174+
})
175+
176+
await submitSignatureToServer(signature)
177+
```
178+
179+
### 2. Never copy the key out of the callback
180+
181+
The entire point of the callback pattern is scoped access. Copying the key defeats the automatic cleanup.
182+
183+
```typescript
184+
// NEVER do this
185+
let stolenKey: Uint8Array
186+
await withPrivateKey(async (secretKey) => {
187+
stolenKey = new Uint8Array(secretKey) // defeats the purpose
188+
})
189+
// stolenKey still contains the key material -- it won't be zeroed
190+
```
191+
192+
If you need key material for a deferred operation, restructure your code so the operation happens inside the callback.
193+
194+
### 3. Check `canUsePrivateKey` before calling
195+
196+
Not all wallets support private key access. Always guard the call:
197+
198+
```typescript
199+
if (!activeWallet?.canUsePrivateKey) {
200+
// Fall back to signTransactions() or show a user message
201+
return
202+
}
203+
```
204+
205+
### 4. Handle errors gracefully
206+
207+
The callback's errors propagate through `withPrivateKey`. The key is still zeroed on error, but you should handle the exception:
208+
209+
```typescript
210+
try {
211+
await withPrivateKey(async (secretKey) => {
212+
return riskyOperation(secretKey)
213+
})
214+
} catch (error) {
215+
// Key is already zeroed -- safe to log
216+
console.error('Operation failed:', error)
217+
}
218+
```
219+
220+
### 5. Never log or serialize the key
221+
222+
Avoid any operation that converts the key to a persistent or inspectable form:
223+
224+
```typescript
225+
await withPrivateKey(async (secretKey) => {
226+
// NEVER do any of these
227+
console.log(secretKey)
228+
JSON.stringify(Array.from(secretKey))
229+
localStorage.setItem('key', btoa(String.fromCharCode(...secretKey)))
230+
sendToAnalytics(secretKey)
231+
})
232+
```
233+
234+
## Desktop App Considerations
235+
236+
If you are building a desktop app with Electron, Tauri, or a similar framework, be aware that desktop environments introduce additional security concerns around process isolation, memory dumps, DevTools access, and IPC boundaries. These topics are beyond the scope of this guide -- consult the [Electron Security](https://www.electronjs.org/docs/latest/tutorial/security) or [Tauri Security](https://tauri.app/security/) documentation for framework-specific guidance.
237+
238+
## Example: Signing a Custom Challenge
239+
240+
A common use case is signing an authentication challenge from a backend server:
241+
242+
```typescript
243+
import { useWallet } from '@txnlab/use-wallet-react'
244+
import nacl from 'tweetnacl'
245+
246+
function AuthChallenge() {
247+
const { activeWallet, withPrivateKey } = useWallet()
248+
249+
const authenticate = async () => {
250+
if (!activeWallet?.canUsePrivateKey) {
251+
throw new Error('Wallet does not support private key access')
252+
}
253+
254+
// Fetch challenge before accessing the key
255+
const { challenge, sessionId } = await fetch('/api/auth/challenge').then(r => r.json())
256+
const challengeBytes = new Uint8Array(
257+
atob(challenge).split('').map(c => c.charCodeAt(0))
258+
)
259+
260+
// Key exposure limited to signing
261+
const signature = await withPrivateKey(async (secretKey) => {
262+
return nacl.sign.detached(challengeBytes, secretKey)
263+
})
264+
265+
// Submit after key is cleared
266+
const response = await fetch('/api/auth/verify', {
267+
method: 'POST',
268+
headers: { 'Content-Type': 'application/json' },
269+
body: JSON.stringify({
270+
sessionId,
271+
signature: btoa(String.fromCharCode(...signature))
272+
})
273+
})
274+
275+
if (!response.ok) {
276+
throw new Error('Authentication failed')
277+
}
278+
}
279+
280+
return <button onClick={authenticate}>Authenticate</button>
281+
}
282+
```
283+
284+
This example demonstrates the recommended pattern: fetch inputs first, use `withPrivateKey` only for the cryptographic operation, and submit results after the key has been cleared.

packages/use-wallet-react/src/__tests__/index.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,8 @@ describe('useWallet', () => {
441441
disconnect: expect.any(Function),
442442
setActive: expect.any(Function),
443443
setActiveAccount: expect.any(Function),
444-
canSignData: false
444+
canSignData: false,
445+
canUsePrivateKey: false
445446
},
446447
{
447448
id: mockMagicAuth.id,
@@ -455,7 +456,8 @@ describe('useWallet', () => {
455456
disconnect: expect.any(Function),
456457
setActive: expect.any(Function),
457458
setActiveAccount: expect.any(Function),
458-
canSignData: false
459+
canSignData: false,
460+
canUsePrivateKey: false
459461
}
460462
]
461463
mockWalletManager._clients = new Map<WalletId, BaseWallet>([

packages/use-wallet-react/src/index.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { useStore } from '@tanstack/react-store'
22
import {
3+
type AlgodConfig,
4+
type BaseWallet,
35
NetworkId,
46
SignDataResponse,
57
SignMetadata,
6-
WalletId,
7-
WalletManager,
8-
type AlgodConfig,
9-
type BaseWallet,
108
type WalletAccount,
9+
WalletId,
1110
type WalletKey,
11+
WalletManager,
1212
type WalletMetadata
1313
} from '@txnlab/use-wallet'
1414
import algosdk from 'algosdk'
@@ -133,6 +133,7 @@ export interface Wallet {
133133
isConnected: boolean
134134
isActive: boolean
135135
canSignData: boolean
136+
canUsePrivateKey: boolean
136137
connect: (args?: Record<string, any>) => Promise<WalletAccount[]>
137138
disconnect: () => Promise<void>
138139
setActive: () => void
@@ -166,6 +167,7 @@ export const useWallet = () => {
166167
isConnected: !!walletState,
167168
isActive: wallet.walletKey === activeWalletId,
168169
canSignData: wallet.canSignData ?? false,
170+
canUsePrivateKey: wallet.canUsePrivateKey ?? false,
169171
connect: (args) => wallet.connect(args),
170172
disconnect: () => wallet.disconnect(),
171173
setActive: () => wallet.setActive(),
@@ -216,6 +218,13 @@ export const useWallet = () => {
216218
return activeBaseWallet.signData(data, metadata)
217219
}
218220

221+
const withPrivateKey = <T,>(callback: (secretKey: Uint8Array) => Promise<T>): Promise<T> => {
222+
if (!activeBaseWallet) {
223+
throw new Error('No active wallet')
224+
}
225+
return activeBaseWallet.withPrivateKey(callback)
226+
}
227+
219228
return {
220229
wallets,
221230
isReady,
@@ -227,6 +236,7 @@ export const useWallet = () => {
227236
activeAccount,
228237
activeAddress,
229238
signData,
239+
withPrivateKey,
230240
signTransactions,
231241
transactionSigner
232242
}

packages/use-wallet-solid/src/index.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,14 @@ export const useWallet = () => {
171171
return wallet.signData(data, metadata)
172172
}
173173

174+
const withPrivateKey = <T,>(callback: (secretKey: Uint8Array) => Promise<T>): Promise<T> => {
175+
const wallet = activeWallet()
176+
if (!wallet) {
177+
throw new Error('No active wallet')
178+
}
179+
return wallet.withPrivateKey(callback)
180+
}
181+
174182
return {
175183
wallets: manager().wallets,
176184
isReady,
@@ -185,6 +193,7 @@ export const useWallet = () => {
185193
isWalletActive,
186194
isWalletConnected,
187195
signData,
196+
withPrivateKey,
188197
signTransactions,
189198
transactionSigner,
190199
walletStore

0 commit comments

Comments
 (0)