Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ dist-extension
.idea
*.log
.env
.air
.air
docs/superpowers/
41 changes: 37 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 28 additions & 16 deletions src/components/CreateWalletForm.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script setup lang="ts">
import {computed, ref} from 'vue'
import {useWallet} from '@/core'
import {MIN_PASSWORD_LENGTH} from '@/config'
import {estimatePasswordStrength, usePasswordStrength, useWallet} from '@/core'
import {
Button,
Field,
Expand All @@ -17,6 +16,7 @@ import {
} from '@nom/ui'
import {EyeIcon, EyeOffIcon} from 'lucide-vue-next'
import MnemonicDisplay from './MnemonicDisplay.vue'
import PasswordStrengthMeter from './PasswordStrengthMeter.vue'

const emit = defineEmits<{
success: [walletAddress: string]
Expand All @@ -36,21 +36,35 @@ const address = ref<string | null>(null)
const isCreating = ref(false)

const passwordsMatch = computed(() => password.value === confirmPassword.value)
const passwordStrong = computed(() => password.value.length >= MIN_PASSWORD_LENGTH)
const strength = usePasswordStrength(password)
const passwordStrong = computed(() => strength.value.meetsFloor)

async function handleCreate() {
if (!passwordStrong.value) {
toast.show(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`, 'warning')
return
}
if (!passwordsMatch.value) {
toast.show('Passwords do not match', 'warning')
return
}

// Guard re-entry before any await so a fast double-click can't start two
// concurrent creates while the password is being (re-)scored.
if (isCreating.value) return
isCreating.value = true
try {
const newWallet = await wallet.createWallet(password.value, name.value || 'Main Wallet')
// Snapshot the inputs before any await. Fields stay editable during scoring,
// so we must validate and create with the exact values submitted — never
// re-read password.value later, or an edit mid-await could swap in a weaker
// password than the one we scored.
const pw = password.value
const confirm = confirmPassword.value
const walletName = name.value || 'Main Wallet'

// Authoritative gate: re-score the snapshotted password.
const finalStrength = await estimatePasswordStrength(pw)
if (!finalStrength.meetsFloor) {
toast.show('Please choose a stronger password', 'warning')
return
}
if (pw !== confirm) {
toast.show('Passwords do not match', 'warning')
return
}

const newWallet = await wallet.createWallet(pw, walletName)
mnemonic.value = wallet.exportMnemonic(newWallet.baseAddress)
address.value = newWallet.baseAddress
step.value = 'mnemonic'
Expand Down Expand Up @@ -103,9 +117,7 @@ function handleComplete() {
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldDescription v-if="password && !passwordStrong" class="text-destructive">
Password must be at least {{ MIN_PASSWORD_LENGTH }} characters
</FieldDescription>
<PasswordStrengthMeter v-if="password" :strength="strength" class="mt-2" />
</Field>

<Field>
Expand Down
35 changes: 24 additions & 11 deletions src/components/ImportWalletForm.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script setup lang="ts">
import {computed, ref} from 'vue'
import {useWallet} from '@/core'
import {MIN_PASSWORD_LENGTH} from '@/config'
import {estimatePasswordStrength, usePasswordStrength, useWallet} from '@/core'
import {
Button,
Field,
Expand All @@ -17,6 +16,7 @@ import {
useToast
} from '@nom/ui'
import {EyeIcon, EyeOffIcon} from 'lucide-vue-next'
import PasswordStrengthMeter from './PasswordStrengthMeter.vue'

const emit = defineEmits<{
success: [walletAddress: string]
Expand All @@ -36,21 +36,36 @@ const isImporting = ref(false)
const mnemonicWords = computed(() => mnemonic.value.trim().split(/\s+/).filter(Boolean))
const mnemonicValid = computed(() => mnemonicWords.value.length === 12 || mnemonicWords.value.length === 24)
const passwordsMatch = computed(() => password.value === confirmPassword.value)
const passwordStrong = computed(() => password.value.length >= MIN_PASSWORD_LENGTH)
const strength = usePasswordStrength(password)
const passwordStrong = computed(() => strength.value.meetsFloor)
const canSubmit = computed(
() => mnemonicValid.value && passwordStrong.value && passwordsMatch.value && !isImporting.value
)

async function handleImport() {
if (!canSubmit.value) return

// Guard re-entry before any await so a fast double-click can't start two
// concurrent imports while the password is being (re-)scored.
if (isImporting.value) return
isImporting.value = true
try {
const importedWallet = await wallet.importWallet(
mnemonic.value.trim(),
password.value,
name.value || 'Imported Wallet'
)
// Snapshot the inputs before any await. Fields stay editable during scoring,
// so we must validate and import with the exact values submitted — never
// re-read password.value later, or an edit mid-await could swap in a weaker
// password than the one we scored.
const pw = password.value
const mnemonicValue = mnemonic.value.trim()
const walletName = name.value || 'Imported Wallet'

// Authoritative gate: re-score the snapshotted password.
const finalStrength = await estimatePasswordStrength(pw)
if (!finalStrength.meetsFloor) {
toast.show('Please choose a stronger password', 'warning')
return
}

const importedWallet = await wallet.importWallet(mnemonicValue, pw, walletName)
emit('success', importedWallet.baseAddress)
} catch (error) {
console.error('Failed to import wallet:', error)
Expand Down Expand Up @@ -110,9 +125,7 @@ async function handleImport() {
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldDescription v-if="password && !passwordStrong" class="text-destructive">
Password must be at least {{ MIN_PASSWORD_LENGTH }} characters
</FieldDescription>
<PasswordStrengthMeter v-if="password" :strength="strength" class="mt-2" />
</Field>

<Field>
Expand Down
33 changes: 33 additions & 0 deletions src/components/PasswordStrengthMeter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup lang="ts">
import {computed} from 'vue'
import type {PasswordStrength} from '@/core'

const props = defineProps<{strength: PasswordStrength}>()

const barClass = computed(() => {
const s = props.strength.score
if (s <= 1) return 'bg-destructive'
if (s === 2) return 'bg-amber-500'
if (s === 3) return 'bg-green-500'
return 'bg-green-600'
})
</script>

<template>
<div class="space-y-1">
<div class="flex gap-1">
<div
v-for="i in 4"
:key="i"
class="h-1.5 flex-1 rounded-full"
:class="i <= strength.score ? barClass : 'bg-muted'"
/>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">{{ strength.label }}</span>
<span v-if="strength.suggestions.length" class="text-muted-foreground">
{{ strength.suggestions[0] }}
</span>
</div>
</div>
</template>
32 changes: 31 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/core/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
36 changes: 36 additions & 0 deletions src/core/composables/usePasswordStrength.ts
Original file line number Diff line number Diff line change
@@ -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<string>): Ref<PasswordStrength> {
const strength = ref<PasswordStrength>(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
}
2 changes: 2 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading