diff --git a/.gitignore b/.gitignore index b39afc6..228165a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ dist-extension .idea *.log .env -.air \ No newline at end of file +.air +docs/superpowers/ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ee8e5dc..010b7e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,14 @@ "dependencies": { "@nom/ui": "workspace:*", "@vueuse/core": "^14.2.0", + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", + "@zxcvbn-ts/language-en": "^3.0.2", "buffer": "^6.0.3", "lucide-vue-next": "^0.563.0", "vue": "^3.4.0", "vue-router": "^4.2.0", - "znn-typescript-sdk": "^1.0.3" + "znn-typescript-sdk": "^1.0.4" }, "devDependencies": { "@crxjs/vite-plugin": "^2.0.0-beta.23", @@ -2406,6 +2409,27 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@zxcvbn-ts/core": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-3.0.4.tgz", + "integrity": "sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==", + "license": "MIT", + "dependencies": { + "fastest-levenshtein": "1.0.16" + } + }, + "node_modules/@zxcvbn-ts/language-common": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-common/-/language-common-3.0.4.tgz", + "integrity": "sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw==", + "license": "MIT" + }, + "node_modules/@zxcvbn-ts/language-en": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-en/-/language-en-3.0.2.tgz", + "integrity": "sha512-Zp+zL+I6Un2Bj0tRXNs6VUBq3Djt+hwTwUz4dkt2qgsQz47U0/XthZ4ULrT/RxjwJRl5LwiaKOOZeOtmixHnjg==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3834,6 +3858,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -6758,9 +6791,9 @@ } }, "node_modules/znn-typescript-sdk": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/znn-typescript-sdk/-/znn-typescript-sdk-1.0.3.tgz", - "integrity": "sha512-jS90Tu+8ql3wYEzoobDKY/EeeTDnkSK6b77yafwJzP/2jThPHdvGfJRvFdPpfJQy+xfUtjVxB+ggjkSf0U1F0w==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/znn-typescript-sdk/-/znn-typescript-sdk-1.0.4.tgz", + "integrity": "sha512-a0S7MmCtMNgar/0Ag4ioKAXxTjCr4+Nj2UdtXdJOqo6jsp0vX/W/cUmO+0DbKOnccHWHu1jTEQ4yApPRrLVKtQ==", "license": "MIT", "dependencies": { "@noble/ed25519": "^2.2.3", diff --git a/package.json b/package.json index 7e0da40..2ba0fd1 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,14 @@ "dependencies": { "@nom/ui": "workspace:*", "@vueuse/core": "^14.2.0", + "@zxcvbn-ts/core": "^3.0.4", + "@zxcvbn-ts/language-common": "^3.0.4", + "@zxcvbn-ts/language-en": "^3.0.2", "buffer": "^6.0.3", "lucide-vue-next": "^0.563.0", "vue": "^3.4.0", "vue-router": "^4.2.0", - "znn-typescript-sdk": "^1.0.3" + "znn-typescript-sdk": "^1.0.4" }, "devDependencies": { "@crxjs/vite-plugin": "^2.0.0-beta.23", diff --git a/src/components/CreateWalletForm.vue b/src/components/CreateWalletForm.vue index e903430..c5e2a7a 100644 --- a/src/components/CreateWalletForm.vue +++ b/src/components/CreateWalletForm.vue @@ -1,7 +1,6 @@ + + + + + + + + {{ strength.label }} + + {{ strength.suggestions[0] }} + + + + diff --git a/src/config.ts b/src/config.ts index d59b6b6..696486a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,4 +49,34 @@ export const STAKE_DURATION_OPTIONS: StakeDurationOption[] = [ // --- Wallet / security --- /** Minimum password length for wallet encryption. */ -export const MIN_PASSWORD_LENGTH = 8 +export const MIN_PASSWORD_LENGTH = 12 + +/** + * Minimum acceptable password-strength score (0–4) to create/import a wallet. + * 2 = "Fair". See estimatePasswordStrength in core/password-strength.ts. + */ +export const MIN_PASSWORD_SCORE = 2 + +/** Human labels for the 0–4 password-strength score. */ +export const PASSWORD_STRENGTH_LABELS = [ + 'Very weak', + 'Weak', + 'Fair', + 'Good', + 'Strong' +] as const + +/** + * Strong Argon2id KDF params for new and upgraded wallets. Passed to the SDK's + * `KeyFile.encrypt()`, which persists them in the keyfile (self-describing) and + * falls back to its weaker DEFAULT_CONFIG when decrypting legacy keyfiles. Also + * the target for `KeyFile.needsUpgrade()` on unlock. timeCost is tuned to keep + * unlock under ~1.5s in the browser (argon2-browser/WASM); shape matches the + * SDK's `KdfConfig` (memory cost in KiB). + */ +export const KDF_CONFIG = { + timeCost: 3, + memoryCost: 64 * 1024, + hashLength: 32, + parallelism: 4 +} as const diff --git a/src/core/composables/index.ts b/src/core/composables/index.ts index 0ed7640..d6178b2 100644 --- a/src/core/composables/index.ts +++ b/src/core/composables/index.ts @@ -8,6 +8,7 @@ export { useRewards } from './useRewards' export { usePillar } from './usePillar' export type { PillarWithApr } from './usePillar' export { useStorage } from './useStorage' +export { usePasswordStrength } from './usePasswordStrength' export { useToken } from './useToken' export { runActivity } from './useActivity' export type { ActivityController } from './useActivity' diff --git a/src/core/composables/usePasswordStrength.ts b/src/core/composables/usePasswordStrength.ts new file mode 100644 index 0000000..3becaa1 --- /dev/null +++ b/src/core/composables/usePasswordStrength.ts @@ -0,0 +1,36 @@ +import {ref, watch, type Ref} from 'vue' +import { + EMPTY_PASSWORD_STRENGTH, + estimatePasswordStrength, + type PasswordStrength +} from '../password-strength' + +/** + * Reactive password strength. Wraps the async (lazy-loaded zxcvbn) scorer. + * + * Fails closed: every password change immediately resets strength to + * EMPTY_PASSWORD_STRENGTH (meetsFloor = false) until the new score resolves, so + * a stale "strong" result can never gate a now-weaker password. A sequence token + * drops out-of-order async results. This is UX state only — submit handlers MUST + * still re-check the current password (it is the authoritative gate). + */ +export function usePasswordStrength(password: Ref): Ref { + const strength = ref(EMPTY_PASSWORD_STRENGTH) + let seq = 0 + + watch( + password, + (pw) => { + const token = ++seq + // Fail closed; only the resolved score for the current password re-opens it. + strength.value = EMPTY_PASSWORD_STRENGTH + if (!pw) return + void estimatePasswordStrength(pw).then((result) => { + if (token === seq) strength.value = result + }) + }, + {immediate: true} + ) + + return strength +} diff --git a/src/core/index.ts b/src/core/index.ts index f107fae..0cd25b8 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -4,5 +4,7 @@ export type { PlasmaLevel } from './account-service' export type { RewardType, RewardInfo } from './rewards-service' export { isGeneratingPow } from './pow-status' +export { estimatePasswordStrength } from './password-strength' +export type { PasswordStrength } from './password-strength' export * from './composables' export * from './composables/utils/formatters' \ No newline at end of file diff --git a/src/core/password-strength.ts b/src/core/password-strength.ts new file mode 100644 index 0000000..625a31a --- /dev/null +++ b/src/core/password-strength.ts @@ -0,0 +1,72 @@ +import {MIN_PASSWORD_LENGTH, MIN_PASSWORD_SCORE, PASSWORD_STRENGTH_LABELS} from '@/config' + +export interface PasswordStrength { + bits: number + score: 0 | 1 | 2 | 3 | 4 + label: (typeof PASSWORD_STRENGTH_LABELS)[number] + meetsFloor: boolean + suggestions: string[] +} + +/** Neutral strength for empty input (and the initial value before scoring). */ +export const EMPTY_PASSWORD_STRENGTH: PasswordStrength = { + bits: 0, + score: 0, + label: PASSWORD_STRENGTH_LABELS[0], + meetsFloor: false, + suggestions: [] +} + +// zxcvbn-ts (core + dictionaries) is dynamically imported so it lands in its own +// chunk, loaded only when a password is first scored — keeping it out of the +// initial bundle. The promise is memoized so the dictionaries load once. +type ScoreFn = (password: string) => { + score: number + guessesLog10: number + feedback: {warning: string | null; suggestions: string[]} +} +let enginePromise: Promise | null = null + +function loadEngine(): Promise { + if (!enginePromise) { + enginePromise = (async () => { + const [core, common, en] = await Promise.all([ + import('@zxcvbn-ts/core'), + import('@zxcvbn-ts/language-common'), + import('@zxcvbn-ts/language-en') + ]) + core.zxcvbnOptions.setOptions({ + dictionary: {...common.dictionary, ...en.dictionary}, + graphs: common.adjacencyGraphs, + translations: en.translations + }) + return (password: string) => core.zxcvbn(password) + })() + } + return enginePromise +} + +/** + * Estimate password strength with zxcvbn — dictionary, keyboard-pattern, + * sequence and repeat aware, so predictable passwords (common words, "qwerty…", + * "abc…xyz") score low. Async because the scoring engine is lazy-loaded on first + * use. Score 0–4; `meetsFloor` enforces the length + score gate. + */ +export async function estimatePasswordStrength(password: string): Promise { + if (!password) return EMPTY_PASSWORD_STRENGTH + + const score = await loadEngine() + const result = score(password) + const clamped = Math.max(0, Math.min(4, result.score)) as 0 | 1 | 2 | 3 | 4 + const bits = result.guessesLog10 * Math.log2(10) + const meetsFloor = password.length >= MIN_PASSWORD_LENGTH && clamped >= MIN_PASSWORD_SCORE + + const suggestions: string[] = [] + if (password.length < MIN_PASSWORD_LENGTH) { + suggestions.push(`Use at least ${MIN_PASSWORD_LENGTH} characters`) + } + if (result.feedback.warning) suggestions.push(result.feedback.warning) + suggestions.push(...result.feedback.suggestions) + + return {bits, score: clamped, label: PASSWORD_STRENGTH_LABELS[clamped], meetsFloor, suggestions} +} diff --git a/src/core/wallet-service.ts b/src/core/wallet-service.ts index 3edad41..fc85d5a 100644 --- a/src/core/wallet-service.ts +++ b/src/core/wallet-service.ts @@ -1,5 +1,6 @@ import {KeyFile, KeyStore} from 'znn-typescript-sdk' import type {StorageAdapter, Wallet, WalletAccount, WalletStorage} from '@/types' +import {KDF_CONFIG} from '@/config' import {sessionManager} from './session-manager' import {storageService} from './storage/storage-service' import {Buffer} from 'buffer' @@ -64,7 +65,8 @@ export class WalletService { // Shared logic for saving a wallet private async saveWallet(keyStore: KeyStore, password: string, name: string): Promise { const keyFile = KeyFile.setPassword(password) - const encryptedKeyFile = await keyFile.encrypt(keyStore) + // Encrypt with strong KDF params; the SDK persists them in the keyfile. + const encryptedKeyFile = await keyFile.encrypt(keyStore, KDF_CONFIG) // Create base account (index 0) const baseAccount: WalletAccount = { @@ -123,10 +125,24 @@ export class WalletService { try { const keyFile = KeyFile.setPassword(password) + // The keyfile is self-describing: decrypt reads its stored KDF params + // (or falls back to the SDK's legacy DEFAULT_CONFIG for older keyfiles). const keyStore = await keyFile.decrypt(wallet.encryptedKeyFile) this.failedAttempts.delete(address) sessionManager.unlock(address, keyStore) + + // Upgrade-on-unlock: transparently re-encrypt wallets whose KDF params are + // weaker than our target. Best-effort — a failure here must never block + // the unlock. + if (KeyFile.needsUpgrade(wallet.encryptedKeyFile, KDF_CONFIG)) { + try { + wallet.encryptedKeyFile = await keyFile.encrypt(keyStore, KDF_CONFIG) + await this.storage.set(STORAGE_KEY, data!) + } catch (err) { + console.error('Failed to upgrade wallet KDF on unlock:', err) + } + } } catch { const current = this.failedAttempts.get(address) ?? { count: 0, lastAttemptAt: 0 } this.failedAttempts.set(address, { diff --git a/src/types/wallet.ts b/src/types/wallet.ts index c922ca5..5cb88b6 100644 --- a/src/types/wallet.ts +++ b/src/types/wallet.ts @@ -2,8 +2,15 @@ export interface KeyFileEncryptedData { baseAddress: string crypto: { + // Self-describing KDF params (SDK ≥ v1.0.4). Optional so legacy keyfiles, + // which stored only the salt, remain assignable; the SDK falls back to its + // DEFAULT_CONFIG when these are absent on decrypt. argon2Params: { salt: string + timeCost?: number + memoryCost?: number + hashLength?: number + parallelism?: number } cipherData: string cipherName: string