Skip to content

Commit cf812ef

Browse files
authored
feat(ironfish): Add unlock to wallet (#5281)
* feat(ironfish): Add unlock to wallet * feat(ironfish): Relock if unlock throws an error
1 parent c0cf9af commit cf812ef

File tree

4 files changed

+300
-13
lines changed

4 files changed

+300
-13
lines changed

ironfish/src/wallet/__fixtures__/wallet.test.ts.fixture

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7198,5 +7198,185 @@
71987198
"sequence": 1
71997199
}
72007200
}
7201+
],
7202+
"Wallet unlock does nothing if the wallet is decrypted": [
7203+
{
7204+
"value": {
7205+
"encrypted": false,
7206+
"version": 4,
7207+
"id": "334e8487-aa79-45de-89ab-b5dd5fd0244d",
7208+
"name": "A",
7209+
"spendingKey": "cf4dde47c8e4b50b1b51c92a2959fbe4bd525053dd900538ff6b97221e048572",
7210+
"viewKey": "f76cd2fde34f98c0f3b327ff93eaa872004aab787c4eb115a36faeb83c0db244723d34416ef53d669e8bd42e3b8a9b023e067a4a2cb8a37b942233682fe94842",
7211+
"incomingViewKey": "c4be4f5641a692f71a39335cbcb6f0f6040dd28a6d7be48cb5e985c630933e06",
7212+
"outgoingViewKey": "ecb5401f16b185bfa92afaa4486c3c3aa568e3484afa5827fd93915c9ac30ab4",
7213+
"publicAddress": "6b257f729fd7e7724d3a14f35b3fb087dddf2b81e2575a24f8c061e051e3392e",
7214+
"createdAt": {
7215+
"hash": {
7216+
"type": "Buffer",
7217+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7218+
},
7219+
"sequence": 1
7220+
},
7221+
"scanningEnabled": true,
7222+
"proofAuthorizingKey": "4d96678f22835d12fde65e43dfd032debb821bd3e19223b8e363cc455a0b7e0c"
7223+
},
7224+
"head": {
7225+
"hash": {
7226+
"type": "Buffer",
7227+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7228+
},
7229+
"sequence": 1
7230+
}
7231+
},
7232+
{
7233+
"value": {
7234+
"encrypted": false,
7235+
"version": 4,
7236+
"id": "c76feb88-27cf-4f1e-b0c4-14b2fdf481f6",
7237+
"name": "B",
7238+
"spendingKey": "dcc22de1010f28f8b467c9461f79a7f35ef4d74f89840148eb2b31e2bc7cdbc2",
7239+
"viewKey": "e908e1709c9e658bfc470b0ec29cb07a27e3d251391049ed5d0425642fb9dd6f2b9441d3d543292dfe5c0749d2fd319879ec54fa252c264966b6aa4302891eef",
7240+
"incomingViewKey": "3cc8da1c2a9cb66aaa5cf6645f58c2bb68c7324b61c6c9e7920d67f00ebfbf00",
7241+
"outgoingViewKey": "222dbc4f3b70bd6a544216b0c2eab8005664b9cae1a55791f2273415df6737ac",
7242+
"publicAddress": "5a53b85abd7a26f85735c113e3d49051f2b8ad3757c05c83eaa64a34c76e7e20",
7243+
"createdAt": {
7244+
"hash": {
7245+
"type": "Buffer",
7246+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7247+
},
7248+
"sequence": 1
7249+
},
7250+
"scanningEnabled": true,
7251+
"proofAuthorizingKey": "e9674e3b1c1cfc895f4c40f4fca18a62557cead575a59f0a5148ed9b7e533007"
7252+
},
7253+
"head": {
7254+
"hash": {
7255+
"type": "Buffer",
7256+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7257+
},
7258+
"sequence": 1
7259+
}
7260+
}
7261+
],
7262+
"Wallet unlock does not unlock the wallet with an invalid passphrase": [
7263+
{
7264+
"value": {
7265+
"encrypted": false,
7266+
"version": 4,
7267+
"id": "4db9e167-a9ae-44de-8f7f-4483e84decf9",
7268+
"name": "A",
7269+
"spendingKey": "d4633b8108f2480a7f23e3038c4b40f5db53dfceedaec1e73d50324f82898ab4",
7270+
"viewKey": "26226d3aa338516e3591ed701fc4c5e0b311c58e2204b870678d11e69939c9d67c29ccc06b1c502ead09fd703853a848c0c9bec130e93bdbf9598c216b0aeeaf",
7271+
"incomingViewKey": "6f21d7fa508f24bb3487dbdba4e26f96a1c670d9fa18ba237ac9f36c5617d700",
7272+
"outgoingViewKey": "758af14295174291a8685fbdca6c9f8c21bc270c2be5bcf0fa57290980bba1db",
7273+
"publicAddress": "1d1ed34db0d1892bc146dfaffad521666aa6ecfcc8a03ccbbe41e46e11c639b3",
7274+
"createdAt": {
7275+
"hash": {
7276+
"type": "Buffer",
7277+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7278+
},
7279+
"sequence": 1
7280+
},
7281+
"scanningEnabled": true,
7282+
"proofAuthorizingKey": "aa19d0c4ea71c7d31e3efbe4b0872b6f7781e68eb28dc063a00741cf91f29d04"
7283+
},
7284+
"head": {
7285+
"hash": {
7286+
"type": "Buffer",
7287+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7288+
},
7289+
"sequence": 1
7290+
}
7291+
},
7292+
{
7293+
"value": {
7294+
"encrypted": false,
7295+
"version": 4,
7296+
"id": "9162afb0-8a0f-4e98-af83-835a5e16b258",
7297+
"name": "B",
7298+
"spendingKey": "828175233697311072b278977e76e92bd49898c4a8397e8e031b4b81839e4137",
7299+
"viewKey": "4f5d82e80111257fb0b6bbd337e12366de1edce52cb773755a93920f9ef7896bff18f5394234095d3ecb11d509dbcc58335b41ab3ec60818f26bd75f2354bd15",
7300+
"incomingViewKey": "ecac98b848e00e9619b47cbe47cfe1dc20c2cb932fe2f1fac5bbd0c7c747fa03",
7301+
"outgoingViewKey": "64f2e79345ceeac1f66f1b3e02e306afe32a15df94e50b7566d69f7982e51467",
7302+
"publicAddress": "6d528f8071d9992f33838144f2a8659e3dc244b93061b3f8375dc20ca487f656",
7303+
"createdAt": {
7304+
"hash": {
7305+
"type": "Buffer",
7306+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7307+
},
7308+
"sequence": 1
7309+
},
7310+
"scanningEnabled": true,
7311+
"proofAuthorizingKey": "9b9ceed7867b16ff969a5d96f4b585c5b3a138d0bdab0c0c0133864780d96e05"
7312+
},
7313+
"head": {
7314+
"hash": {
7315+
"type": "Buffer",
7316+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7317+
},
7318+
"sequence": 1
7319+
}
7320+
}
7321+
],
7322+
"Wallet unlock saves decrypted accounts to memory with a valid passphrase": [
7323+
{
7324+
"value": {
7325+
"encrypted": false,
7326+
"version": 4,
7327+
"id": "384fe5b9-3160-4664-aed2-ac4d006b704f",
7328+
"name": "A",
7329+
"spendingKey": "f15eda5e36aa16bc6089fa3af4559d73586f46e98000450542d8b9be1c0140fc",
7330+
"viewKey": "2a81a45c51c715b8e65f1f460efa1378819f244f982ccbd6d9af642a5f9abfb27eb45969f2d44eda21b47b114c5dc232ecf10ef339154294ba5a49cb84c25ef3",
7331+
"incomingViewKey": "780c27a6fe9008e627b118c90d435bad1494380ca22fd780faa09df060e2d204",
7332+
"outgoingViewKey": "56d91dd686547a1108ea92570ad74b5aa9913be43bafe9d7de2a85fabd1ac9e6",
7333+
"publicAddress": "4946e7af9b10183cde7fe74747652af25fb04092558abac7d201a683d251b5e9",
7334+
"createdAt": {
7335+
"hash": {
7336+
"type": "Buffer",
7337+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7338+
},
7339+
"sequence": 1
7340+
},
7341+
"scanningEnabled": true,
7342+
"proofAuthorizingKey": "a572f7aac3975180f4324907111b1f49c1870308ba2e3914f5d1646cad2ee500"
7343+
},
7344+
"head": {
7345+
"hash": {
7346+
"type": "Buffer",
7347+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7348+
},
7349+
"sequence": 1
7350+
}
7351+
},
7352+
{
7353+
"value": {
7354+
"encrypted": false,
7355+
"version": 4,
7356+
"id": "113965fb-4d82-4775-a8e0-ab53dd486aae",
7357+
"name": "B",
7358+
"spendingKey": "de48a3a36c8f24aac3fb9c9fc068e0313b49ef51996b06a15d5c05fb58d49093",
7359+
"viewKey": "4f2d6835e2e7c89c634e9fef094974a106e0ffde0b7379f68f130646cef857df641be33dcae1aa687353a6d10265ea4df6ce9b7d1272617c92270b84581f8d44",
7360+
"incomingViewKey": "aba10ce211826f14de8a4436b1e3c8b7a78006bc8748bfa807e4c9e4e7af8006",
7361+
"outgoingViewKey": "13dae0f5e3b229a8d47c489844d9ad9c3f55c500cf7e4fd7acdb5c4d1f55a911",
7362+
"publicAddress": "57b793afa5f829d9fa84f12540116d31bfc6bd23508719e1b38e31f7a6721c38",
7363+
"createdAt": {
7364+
"hash": {
7365+
"type": "Buffer",
7366+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7367+
},
7368+
"sequence": 1
7369+
},
7370+
"scanningEnabled": true,
7371+
"proofAuthorizingKey": "1f4d67591e3fe5de62930c5cd066e3a08456dac92497eb354cb8c71f805ad608"
7372+
},
7373+
"head": {
7374+
"hash": {
7375+
"type": "Buffer",
7376+
"data": "base64:R5HXrp+X3xAO8VWOhHctagm0N2I4goP3XG8goyqIqoY="
7377+
},
7378+
"sequence": 1
7379+
}
7380+
}
72017381
]
72027382
}

ironfish/src/wallet/wallet.test.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2471,27 +2471,78 @@ describe('Wallet', () => {
24712471
await useAccountFixture(node.wallet, 'A')
24722472
await useAccountFixture(node.wallet, 'B')
24732473

2474-
// TODO(rohanjadvani)
2475-
// This is temporary for a unit test to keep PRs small.
2476-
// This will be refactored once unlock comes in a subsequent change.
2477-
// The goal is to mock an unlocked state by copying and setting
2478-
// decrypted accounts within the wallet.
2479-
const accountById = new Map(node.wallet.accountById.entries())
2480-
24812474
await node.wallet.encrypt(passphrase)
24822475
expect(node.wallet.accounts).toHaveLength(0)
24832476
expect(node.wallet.encryptedAccounts).toHaveLength(2)
24842477

2485-
// Mock unlock until the method is implemented
2486-
node.wallet.locked = false
2487-
for (const [k, v] of accountById.entries()) {
2488-
node.wallet.accountById.set(k, v)
2489-
}
2478+
await node.wallet.unlock(passphrase)
24902479
expect(node.wallet.accounts).toHaveLength(2)
24912480

24922481
await node.wallet.lock()
24932482
expect(node.wallet.accounts).toHaveLength(0)
24942483
expect(node.wallet.locked).toBe(true)
24952484
})
24962485
})
2486+
2487+
describe('unlock', () => {
2488+
it('does nothing if the wallet is decrypted', async () => {
2489+
const { node } = nodeTest
2490+
2491+
await useAccountFixture(node.wallet, 'A')
2492+
await useAccountFixture(node.wallet, 'B')
2493+
expect(node.wallet.accounts).toHaveLength(2)
2494+
expect(node.wallet.encryptedAccounts).toHaveLength(0)
2495+
2496+
await node.wallet.unlock('foobar')
2497+
expect(node.wallet.accounts).toHaveLength(2)
2498+
expect(node.wallet.encryptedAccounts).toHaveLength(0)
2499+
})
2500+
2501+
it('does not unlock the wallet with an invalid passphrase', async () => {
2502+
const { node } = nodeTest
2503+
const passphrase = 'foo'
2504+
const invalidPassphrase = 'bar'
2505+
2506+
await useAccountFixture(node.wallet, 'A')
2507+
await useAccountFixture(node.wallet, 'B')
2508+
2509+
await node.wallet.encrypt(passphrase)
2510+
expect(node.wallet.accounts).toHaveLength(0)
2511+
expect(node.wallet.encryptedAccounts).toHaveLength(2)
2512+
2513+
await expect(node.wallet.unlock(invalidPassphrase)).rejects.toThrow(
2514+
AccountDecryptionFailedError,
2515+
)
2516+
expect(node.wallet.accounts).toHaveLength(0)
2517+
expect(node.wallet.encryptedAccounts).toHaveLength(2)
2518+
expect(node.wallet.locked).toBe(true)
2519+
})
2520+
2521+
it('saves decrypted accounts to memory with a valid passphrase', async () => {
2522+
const { node } = nodeTest
2523+
const passphrase = 'foo'
2524+
2525+
await useAccountFixture(node.wallet, 'A')
2526+
await useAccountFixture(node.wallet, 'B')
2527+
2528+
await node.wallet.encrypt(passphrase)
2529+
expect(node.wallet.accounts).toHaveLength(0)
2530+
expect(node.wallet.encryptedAccounts).toHaveLength(2)
2531+
2532+
await node.wallet.unlock(passphrase)
2533+
expect(node.wallet.accounts).toHaveLength(2)
2534+
expect(node.wallet.encryptedAccounts).toHaveLength(2)
2535+
expect(node.wallet.locked).toBe(false)
2536+
2537+
for (const [id, account] of node.wallet.accountById.entries()) {
2538+
const encryptedAccount = node.wallet.encryptedAccountById.get(id)
2539+
Assert.isNotUndefined(encryptedAccount)
2540+
const decryptedAccount = encryptedAccount.decrypt(passphrase)
2541+
2542+
expect(account.serialize()).toMatchObject(decryptedAccount.serialize())
2543+
}
2544+
2545+
await node.wallet.lock()
2546+
})
2547+
})
24972548
})

ironfish/src/wallet/wallet.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export type TransactionOutput = {
9090
assetId: Buffer
9191
}
9292

93+
export const DEFAULT_UNLOCK_TIMEOUT_MS = 5 * 60 * 1000
94+
9395
export class Wallet {
9496
readonly onAccountImported = new Event<[account: Account]>()
9597
readonly onAccountRemoved = new Event<[account: Account]>()
@@ -112,6 +114,7 @@ export class Wallet {
112114
protected isSyncingTransactionGossip = false
113115
locked: boolean
114116
protected eventLoopTimeout: SetTimeoutToken | null = null
117+
protected lockTimeout: SetTimeoutToken | null
115118
private readonly createTransactionMutex: Mutex
116119
private readonly eventLoopAbortController: AbortController
117120
private eventLoopPromise: Promise<void> | null = null
@@ -146,6 +149,7 @@ export class Wallet {
146149
this.nodeClient = nodeClient || null
147150
this.rebroadcastAfter = rebroadcastAfter ?? 10
148151
this.locked = false
152+
this.lockTimeout = null
149153
this.createTransactionMutex = new Mutex()
150154
this.eventLoopAbortController = new AbortController()
151155

@@ -271,6 +275,8 @@ export class Wallet {
271275
clearTimeout(this.eventLoopTimeout)
272276
}
273277

278+
this.stopUnlockTimeout()
279+
274280
await this.scanner.abort()
275281
this.eventLoopAbortController.abort()
276282
await this.eventLoopPromise
@@ -1813,10 +1819,60 @@ export class Wallet {
18131819
return
18141820
}
18151821

1822+
this.stopUnlockTimeout()
18161823
this.accountById.clear()
18171824
this.locked = true
18181825
} finally {
18191826
unlock()
18201827
}
18211828
}
1829+
1830+
async unlock(passphrase: string, timeout?: number, tx?: IDatabaseTransaction): Promise<void> {
1831+
const unlock = await this.createTransactionMutex.lock()
1832+
1833+
try {
1834+
const encrypted = await this.walletDb.accountsEncrypted(tx)
1835+
if (!encrypted) {
1836+
return
1837+
}
1838+
1839+
for (const [id, account] of this.encryptedAccountById.entries()) {
1840+
this.accountById.set(id, account.decrypt(passphrase))
1841+
}
1842+
1843+
this.startUnlockTimeout(timeout)
1844+
this.locked = false
1845+
} catch (e) {
1846+
this.logger.debug('Wallet unlock failed')
1847+
this.stopUnlockTimeout()
1848+
this.accountById.clear()
1849+
this.locked = true
1850+
1851+
throw e
1852+
} finally {
1853+
unlock()
1854+
}
1855+
}
1856+
1857+
private startUnlockTimeout(timeout?: number): void {
1858+
if (!timeout) {
1859+
timeout = DEFAULT_UNLOCK_TIMEOUT_MS
1860+
}
1861+
1862+
this.stopUnlockTimeout()
1863+
1864+
// Keep the wallet unlocked indefinitely
1865+
if (timeout === -1) {
1866+
return
1867+
}
1868+
1869+
this.lockTimeout = setTimeout(() => void this.lock(), timeout)
1870+
}
1871+
1872+
private stopUnlockTimeout(): void {
1873+
if (this.lockTimeout) {
1874+
clearTimeout(this.lockTimeout)
1875+
this.lockTimeout = null
1876+
}
1877+
}
18221878
}

ironfish/src/wallet/walletdb/accountValue.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { MultisigKeysEncoding } from './multisigKeys'
1111
export const VIEW_KEY_LENGTH = 64
1212
const VERSION_LENGTH = 2
1313

14-
export interface EncryptedAccountValue {
14+
export type EncryptedAccountValue = {
1515
encrypted: true
1616
data: Buffer
1717
}

0 commit comments

Comments
 (0)