]> git.codecow.com Git - libnemo.git/commitdiff
Create timer for vault locking. Create unit test.
authorChris Duncan <chris@zoso.dev>
Mon, 18 Aug 2025 19:15:32 +0000 (12:15 -0700)
committerChris Duncan <chris@zoso.dev>
Mon, 18 Aug 2025 19:15:32 +0000 (12:15 -0700)
src/lib/vault/timer.ts [new file with mode: 0644]
src/lib/vault/vault.ts
test/test.lock-unlock.mjs

diff --git a/src/lib/vault/timer.ts b/src/lib/vault/timer.ts
new file mode 100644 (file)
index 0000000..d788ad4
--- /dev/null
@@ -0,0 +1,32 @@
+//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
+//! SPDX-License-Identifier: GPL-3.0-or-later
+
+export class Timer {
+       #f: () => any
+       #elapsed: number = 0
+       #isPaused: boolean = false
+       #start: number
+       #timeout: number | NodeJS.Timeout
+
+       constructor (f: () => any, t: number) {
+               this.#f = f
+               this.#start = performance.now()
+               this.#timeout = setTimeout(f, t)
+       }
+
+       pause () {
+               if (!this.#isPaused) {
+                       clearTimeout(this.#timeout)
+                       this.#elapsed = performance.now() - this.#start
+                       this.#isPaused = true
+               }
+       }
+
+       resume () {
+               if (this.#isPaused) {
+                       this.#start = performance.now()
+                       this.#timeout = setTimeout(this.#f, this.#elapsed)
+                       this.#isPaused = false
+               }
+       }
+}
index d6b45b59126848c91fce1f55374a8204169d8b1a..fcf15917ccdf34997de7fcc9b9912d7cbb94e6bf 100644 (file)
@@ -6,13 +6,14 @@ import { Bip39, Bip44, Blake2b, NanoNaCl } from '#crypto'
 import { NamedData } from '#types'
 import { default as Constants, BIP44_COIN_NANO } from '../constants'
 import { default as Convert, utf8 } from '../convert'
+import { Timer } from './timer'
 
 /**
 * Cross-platform worker for managing wallet secrets.
 */
 export class Vault {
        static #locked: boolean = true
-       static #timeout: number | NodeJS.Timeout
+       static #timeout: Timer
        static #type?: 'BIP-44' | 'BLAKE2b'
        static #seed?: ArrayBuffer
        static #mnemonic?: ArrayBuffer
@@ -120,6 +121,7 @@ export class Vault {
                        }
                        return { ...record, seed: this.#seed.slice(), mnemonic: this.#mnemonic.slice() }
                } catch (err) {
+                       console.error(err)
                        throw new Error('Failed to create wallet', { cause: err })
                } finally {
                        this.lock()
@@ -145,14 +147,16 @@ export class Vault {
                        if (typeof index !== 'number') {
                                throw new Error('Invalid wallet account index')
                        }
-                       clearTimeout(this.#timeout)
+                       this.#timeout.pause()
                        const prv = this.#type === 'BIP-44'
                                ? await Bip44.ckd(this.#seed, BIP44_COIN_NANO, index)
                                : await this.#deriveBlake2bPrivateKey(this.#seed, index)
                        const pub = await NanoNaCl.convert(new Uint8Array(prv))
-                       this.#timeout = setTimeout(() => this.lock(), 300000)
+                       this.#timeout = new Timer(() => this.lock(), 300000)
                        return { index, publicKey: pub.buffer }
                } catch (err) {
+                       console.error(err)
+                       this.#timeout.resume()
                        throw new Error('Failed to derive account', { cause: err })
                }
        }
@@ -169,6 +173,7 @@ export class Vault {
                        }
                        return record
                } catch (err) {
+                       console.error(err)
                        throw new Error('Failed to load wallet', { cause: err })
                } finally {
                        this.lock()
@@ -176,7 +181,7 @@ export class Vault {
        }
 
        static lock (): NamedData<boolean> {
-               clearTimeout(this.#timeout)
+               this.#timeout?.pause()
                this.#mnemonic = undefined
                this.#seed = undefined
                this.#locked = true
@@ -201,14 +206,16 @@ export class Vault {
                        if (data == null) {
                                throw new Error('Data to sign not found')
                        }
-                       clearTimeout(this.#timeout)
+                       this.#timeout.pause()
                        const prv = this.#type === 'BIP-44'
                                ? await Bip44.ckd(this.#seed, BIP44_COIN_NANO, index)
                                : await this.#deriveBlake2bPrivateKey(this.#seed, index)
                        const sig = await NanoNaCl.detached(new Uint8Array(data), new Uint8Array(prv))
-                       this.#timeout = setTimeout(() => this.lock(), 300000)
+                       this.#timeout = new Timer(() => this.lock(), 300000)
                        return { signature: sig.buffer }
                } catch (err) {
+                       console.error(err)
+                       this.#timeout.resume()
                        throw new Error('Failed to sign message', { cause: err })
                }
        }
@@ -230,7 +237,7 @@ export class Vault {
                        if (encrypted == null) {
                                throw new TypeError('Wallet encrypted data is required')
                        }
-                       clearTimeout(this.#timeout)
+                       this.#timeout?.pause()
                        await this.#decryptWallet(type, key, iv, encrypted)
                        if (!(this.#seed instanceof ArrayBuffer)) {
                                throw new TypeError('Invalid seed')
@@ -239,10 +246,11 @@ export class Vault {
                                throw new TypeError('Invalid mnemonic')
                        }
                        this.#locked = false
-                       this.#timeout = setTimeout(() => this.lock(), 300000)
+                       this.#timeout = new Timer(() => this.lock(), 300000)
                        return { isUnlocked: !this.#locked }
                } catch (err) {
                        console.error(err)
+                       this.#timeout?.resume()
                        throw new Error('Failed to unlock wallet', { cause: err })
                }
        }
@@ -261,12 +269,13 @@ export class Vault {
                        if (key == null || keySalt == null) {
                                throw new TypeError('Wallet password is required')
                        }
-                       clearTimeout(this.#timeout)
+                       this.#timeout.pause()
                        const { iv, encrypted } = await this.#encryptWallet(key)
-                       this.#timeout = setTimeout(() => this.lock(), 300000)
+                       this.#timeout = new Timer(() => this.lock(), 300000)
                        return { iv, salt: keySalt, encrypted }
                } catch (err) {
                        console.error(err)
+                       this.#timeout.resume()
                        throw new Error('Failed to update wallet password', { cause: err })
                }
        }
@@ -310,6 +319,7 @@ export class Vault {
                        }
                        return { isVerified }
                } catch (err) {
+                       console.error(err)
                        throw new Error('Failed to export wallet', { cause: err })
                }
        }
@@ -591,5 +601,6 @@ export default `
        const Bip44 = ${Bip44}
        const Blake2b = ${Blake2b}
        const NanoNaCl = ${NanoNaCl}
+       const Timer = ${Timer}
        const Vault = ${Vault}
 `
index 3370ecce44580e8bad799f1ccc566ff0dcb3c413..6ea00772099c61298d15e1a044bfd5cd7e7add9c 100644 (file)
@@ -115,7 +115,7 @@ await Promise.all([
                        await assert.resolves(wallet.destroy())\r
                })\r
 \r
-               await test('fail to access a wallet after automatic lock', { skip: true }, async () => {\r
+               await test('wallet automatic lock resets after user activity', { skip: true }, async () => {\r
                        const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.MNEMONIC, NANO_TEST_VECTORS.PASSWORD)\r
                        await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
 \r
@@ -123,13 +123,34 @@ await Promise.all([
                        assert.ok(await wallet.verify(NANO_TEST_VECTORS.BIP39_SEED))\r
 \r
                        await new Promise(async (resolve) => {\r
-                               console.log('Waiting 5 minutes...')\r
+                               console.log('Waiting 3 minutes...')\r
                                setTimeout(async () => {\r
+                                       // should still be unlocked\r
+                                       const account = await wallet.account(0)\r
+                                       assert.equal(account.address, NANO_TEST_VECTORS.ADDRESS_0)\r
+                                       resolve(null)\r
+                               }, 180000)\r
+                       })\r
+\r
+                       await new Promise(async (resolve) => {\r
+                               console.log('Timer should be reset, waiting 3 minutes...')\r
+                               setTimeout(async () => {\r
+                                       // should still be unlocked from account() reset and not initial unlock\r
+                                       assert.ok(await wallet.verify(NANO_TEST_VECTORS.MNEMONIC))\r
+                                       assert.ok(await wallet.verify(NANO_TEST_VECTORS.BIP39_SEED))\r
+                                       resolve(null)\r
+                               }, 180000)\r
+                       })\r
+\r
+                       await new Promise(async (resolve) => {\r
+                               console.log('Timer should not be reset by verify, waiting 3 minutes...')\r
+                               setTimeout(async () => {\r
+                                       // should be locked from account() reset and not reset by verify()\r
                                        await assert.rejects(wallet.verify(NANO_TEST_VECTORS.MNEMONIC))\r
                                        await assert.rejects(wallet.verify(NANO_TEST_VECTORS.BIP39_SEED))\r
                                        await assert.resolves(wallet.destroy())\r
                                        resolve(null)\r
-                               }, 301000)\r
+                               }, 180000)\r
                        })\r
                })\r
        })\r