diff --git a/src/App.tsx b/src/App.tsx
index 42f175d..b7d06aa 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -22,6 +22,7 @@ import Header from './components/Header';
import { StorageDetails } from './components/StorageDetails';
import { ClipboardDetails } from './components/ClipboardDetails';
import Footer from './components/Footer';
+import { SeedBlender } from './components/SeedBlender';
console.log("OpenPGP.js version:", openpgp.config.versionString);
@@ -39,11 +40,12 @@ interface ClipboardEvent {
}
function App() {
- const [activeTab, setActiveTab] = useState<'backup' | 'restore'>('backup');
+ const [activeTab, setActiveTab] = useState<'backup' | 'restore' | 'seedblender'>('backup');
const [mnemonic, setMnemonic] = useState('');
const [backupMessagePassword, setBackupMessagePassword] = useState('');
const [restoreMessagePassword, setRestoreMessagePassword] = useState('');
+
const [publicKeyInput, setPublicKeyInput] = useState('');
const [privateKeyInput, setPrivateKeyInput] = useState('');
const [privateKeyPassphrase, setPrivateKeyPassphrase] = useState('');
@@ -418,7 +420,7 @@ function App() {
readOnly={isReadOnly}
/>
>
- ) : (
+ ) : activeTab === 'restore' ? (
<>
diff --git a/src/bip39_wordlist.txt b/src/bip39_wordlist.txt
new file mode 100644
index 0000000..942040e
--- /dev/null
+++ b/src/bip39_wordlist.txt
@@ -0,0 +1,2048 @@
+abandon
+ability
+able
+about
+above
+absent
+absorb
+abstract
+absurd
+abuse
+access
+accident
+account
+accuse
+achieve
+acid
+acoustic
+acquire
+across
+act
+action
+actor
+actress
+actual
+adapt
+add
+addict
+address
+adjust
+admit
+adult
+advance
+advice
+aerobic
+affair
+afford
+afraid
+again
+age
+agent
+agree
+ahead
+aim
+air
+airport
+aisle
+alarm
+album
+alcohol
+alert
+alien
+all
+alley
+allow
+almost
+alone
+alpha
+already
+also
+alter
+always
+amateur
+amazing
+among
+amount
+amused
+analyst
+anchor
+ancient
+anger
+angle
+angry
+animal
+ankle
+announce
+annual
+another
+answer
+antenna
+antique
+anxiety
+any
+apart
+apology
+appear
+apple
+approve
+april
+arch
+arctic
+area
+arena
+argue
+arm
+armed
+armor
+army
+around
+arrange
+arrest
+arrive
+arrow
+art
+artefact
+artist
+artwork
+ask
+aspect
+assault
+asset
+assist
+assume
+asthma
+athlete
+atom
+attack
+attend
+attitude
+attract
+auction
+audit
+august
+aunt
+author
+auto
+autumn
+average
+avocado
+avoid
+awake
+aware
+away
+awesome
+awful
+awkward
+axis
+baby
+bachelor
+bacon
+badge
+bag
+balance
+balcony
+ball
+bamboo
+banana
+banner
+bar
+barely
+bargain
+barrel
+base
+basic
+basket
+battle
+beach
+bean
+beauty
+because
+become
+beef
+before
+begin
+behave
+behind
+believe
+below
+belt
+bench
+benefit
+best
+betray
+better
+between
+beyond
+bicycle
+bid
+bike
+bind
+biology
+bird
+birth
+bitter
+black
+blade
+blame
+blanket
+blast
+bleak
+bless
+blind
+blood
+blossom
+blouse
+blue
+blur
+blush
+board
+boat
+body
+boil
+bomb
+bone
+bonus
+book
+boost
+border
+boring
+borrow
+boss
+bottom
+bounce
+box
+boy
+bracket
+brain
+brand
+brass
+brave
+bread
+breeze
+brick
+bridge
+brief
+bright
+bring
+brisk
+broccoli
+broken
+bronze
+broom
+brother
+brown
+brush
+bubble
+buddy
+budget
+buffalo
+build
+bulb
+bulk
+bullet
+bundle
+bunker
+burden
+burger
+burst
+bus
+business
+busy
+butter
+buyer
+buzz
+cabbage
+cabin
+cable
+cactus
+cage
+cake
+call
+calm
+camera
+camp
+can
+canal
+cancel
+candy
+cannon
+canoe
+canvas
+canyon
+capable
+capital
+captain
+car
+carbon
+card
+cargo
+carpet
+carry
+cart
+case
+cash
+casino
+castle
+casual
+cat
+catalog
+catch
+category
+cattle
+caught
+cause
+caution
+cave
+ceiling
+celery
+cement
+census
+century
+cereal
+certain
+chair
+chalk
+champion
+change
+chaos
+chapter
+charge
+chase
+chat
+cheap
+check
+cheese
+chef
+cherry
+chest
+chicken
+chief
+child
+chimney
+choice
+choose
+chronic
+chuckle
+chunk
+churn
+cigar
+cinnamon
+circle
+citizen
+city
+civil
+claim
+clap
+clarify
+claw
+clay
+clean
+clerk
+clever
+click
+client
+cliff
+climb
+clinic
+clip
+clock
+clog
+close
+cloth
+cloud
+clown
+club
+clump
+cluster
+clutch
+coach
+coast
+coconut
+code
+coffee
+coil
+coin
+collect
+color
+column
+combine
+come
+comfort
+comic
+common
+company
+concert
+conduct
+confirm
+congress
+connect
+consider
+control
+convince
+cook
+cool
+copper
+copy
+coral
+core
+corn
+correct
+cost
+cotton
+couch
+country
+couple
+course
+cousin
+cover
+coyote
+crack
+cradle
+craft
+cram
+crane
+crash
+crater
+crawl
+crazy
+cream
+credit
+creek
+crew
+cricket
+crime
+crisp
+critic
+crop
+cross
+crouch
+crowd
+crucial
+cruel
+cruise
+crumble
+crunch
+crush
+cry
+crystal
+cube
+culture
+cup
+cupboard
+curious
+current
+curtain
+curve
+cushion
+custom
+cute
+cycle
+dad
+damage
+damp
+dance
+danger
+daring
+dash
+daughter
+dawn
+day
+deal
+debate
+debris
+decade
+december
+decide
+decline
+decorate
+decrease
+deer
+defense
+define
+defy
+degree
+delay
+deliver
+demand
+demise
+denial
+dentist
+deny
+depart
+depend
+deposit
+depth
+deputy
+derive
+describe
+desert
+design
+desk
+despair
+destroy
+detail
+detect
+develop
+device
+devote
+diagram
+dial
+diamond
+diary
+dice
+diesel
+diet
+differ
+digital
+dignity
+dilemma
+dinner
+dinosaur
+direct
+dirt
+disagree
+discover
+disease
+dish
+dismiss
+disorder
+display
+distance
+divert
+divide
+divorce
+dizzy
+doctor
+document
+dog
+doll
+dolphin
+domain
+donate
+donkey
+donor
+door
+dose
+double
+dove
+draft
+dragon
+drama
+drastic
+draw
+dream
+dress
+drift
+drill
+drink
+drip
+drive
+drop
+drum
+dry
+duck
+dumb
+dune
+during
+dust
+dutch
+duty
+dwarf
+dynamic
+eager
+eagle
+early
+earn
+earth
+easily
+east
+easy
+echo
+ecology
+economy
+edge
+edit
+educate
+effort
+egg
+eight
+either
+elbow
+elder
+electric
+elegant
+element
+elephant
+elevator
+elite
+else
+embark
+embody
+embrace
+emerge
+emotion
+employ
+empower
+empty
+enable
+enact
+end
+endless
+endorse
+enemy
+energy
+enforce
+engage
+engine
+enhance
+enjoy
+enlist
+enough
+enrich
+enroll
+ensure
+enter
+entire
+entry
+envelope
+episode
+equal
+equip
+era
+erase
+erode
+erosion
+error
+erupt
+escape
+essay
+essence
+estate
+eternal
+ethics
+evidence
+evil
+evoke
+evolve
+exact
+example
+excess
+exchange
+excite
+exclude
+excuse
+execute
+exercise
+exhaust
+exhibit
+exile
+exist
+exit
+exotic
+expand
+expect
+expire
+explain
+expose
+express
+extend
+extra
+eye
+eyebrow
+fabric
+face
+faculty
+fade
+faint
+faith
+fall
+false
+fame
+family
+famous
+fan
+fancy
+fantasy
+farm
+fashion
+fat
+fatal
+father
+fatigue
+fault
+favorite
+feature
+february
+federal
+fee
+feed
+feel
+female
+fence
+festival
+fetch
+fever
+few
+fiber
+fiction
+field
+figure
+file
+film
+filter
+final
+find
+fine
+finger
+finish
+fire
+firm
+first
+fiscal
+fish
+fit
+fitness
+fix
+flag
+flame
+flash
+flat
+flavor
+flee
+flight
+flip
+float
+flock
+floor
+flower
+fluid
+flush
+fly
+foam
+focus
+fog
+foil
+fold
+follow
+food
+foot
+force
+forest
+forget
+fork
+fortune
+forum
+forward
+fossil
+foster
+found
+fox
+fragile
+frame
+frequent
+fresh
+friend
+fringe
+frog
+front
+frost
+frown
+frozen
+fruit
+fuel
+fun
+funny
+furnace
+fury
+future
+gadget
+gain
+galaxy
+gallery
+game
+gap
+garage
+garbage
+garden
+garlic
+garment
+gas
+gasp
+gate
+gather
+gauge
+gaze
+general
+genius
+genre
+gentle
+genuine
+gesture
+ghost
+giant
+gift
+giggle
+ginger
+giraffe
+girl
+give
+glad
+glance
+glare
+glass
+glide
+glimpse
+globe
+gloom
+glory
+glove
+glow
+glue
+goat
+goddess
+gold
+good
+goose
+gorilla
+gospel
+gossip
+govern
+gown
+grab
+grace
+grain
+grant
+grape
+grass
+gravity
+great
+green
+grid
+grief
+grit
+grocery
+group
+grow
+grunt
+guard
+guess
+guide
+guilt
+guitar
+gun
+gym
+habit
+hair
+half
+hammer
+hamster
+hand
+happy
+harbor
+hard
+harsh
+harvest
+hat
+have
+hawk
+hazard
+head
+health
+heart
+heavy
+hedgehog
+height
+hello
+helmet
+help
+hen
+hero
+hidden
+high
+hill
+hint
+hip
+hire
+history
+hobby
+hockey
+hold
+hole
+holiday
+hollow
+home
+honey
+hood
+hope
+horn
+horror
+horse
+hospital
+host
+hotel
+hour
+hover
+hub
+huge
+human
+humble
+humor
+hundred
+hungry
+hunt
+hurdle
+hurry
+hurt
+husband
+hybrid
+ice
+icon
+idea
+identify
+idle
+ignore
+ill
+illegal
+illness
+image
+imitate
+immense
+immune
+impact
+impose
+improve
+impulse
+inch
+include
+income
+increase
+index
+indicate
+indoor
+industry
+infant
+inflict
+inform
+inhale
+inherit
+initial
+inject
+injury
+inmate
+inner
+innocent
+input
+inquiry
+insane
+insect
+inside
+inspire
+install
+intact
+interest
+into
+invest
+invite
+involve
+iron
+island
+isolate
+issue
+item
+ivory
+jacket
+jaguar
+jar
+jazz
+jealous
+jeans
+jelly
+jewel
+job
+join
+joke
+journey
+joy
+judge
+juice
+jump
+jungle
+junior
+junk
+just
+kangaroo
+keen
+keep
+ketchup
+key
+kick
+kid
+kidney
+kind
+kingdom
+kiss
+kit
+kitchen
+kite
+kitten
+kiwi
+knee
+knife
+knock
+know
+lab
+label
+labor
+ladder
+lady
+lake
+lamp
+language
+laptop
+large
+later
+latin
+laugh
+laundry
+lava
+law
+lawn
+lawsuit
+layer
+lazy
+leader
+leaf
+learn
+leave
+lecture
+left
+leg
+legal
+legend
+leisure
+lemon
+lend
+length
+lens
+leopard
+lesson
+letter
+level
+liar
+liberty
+library
+license
+life
+lift
+light
+like
+limb
+limit
+link
+lion
+liquid
+list
+little
+live
+lizard
+load
+loan
+lobster
+local
+lock
+logic
+lonely
+long
+loop
+lottery
+loud
+lounge
+love
+loyal
+lucky
+luggage
+lumber
+lunar
+lunch
+luxury
+lyrics
+machine
+mad
+magic
+magnet
+maid
+mail
+main
+major
+make
+mammal
+man
+manage
+mandate
+mango
+mansion
+manual
+maple
+marble
+march
+margin
+marine
+market
+marriage
+mask
+mass
+master
+match
+material
+math
+matrix
+matter
+maximum
+maze
+meadow
+mean
+measure
+meat
+mechanic
+medal
+media
+melody
+melt
+member
+memory
+mention
+menu
+mercy
+merge
+merit
+merry
+mesh
+message
+metal
+method
+middle
+midnight
+milk
+million
+mimic
+mind
+minimum
+minor
+minute
+miracle
+mirror
+misery
+miss
+mistake
+mix
+mixed
+mixture
+mobile
+model
+modify
+mom
+moment
+monitor
+monkey
+monster
+month
+moon
+moral
+more
+morning
+mosquito
+mother
+motion
+motor
+mountain
+mouse
+move
+movie
+much
+muffin
+mule
+multiply
+muscle
+museum
+mushroom
+music
+must
+mutual
+myself
+mystery
+myth
+naive
+name
+napkin
+narrow
+nasty
+nation
+nature
+near
+neck
+need
+negative
+neglect
+neither
+nephew
+nerve
+nest
+net
+network
+neutral
+never
+news
+next
+nice
+night
+noble
+noise
+nominee
+noodle
+normal
+north
+nose
+notable
+note
+nothing
+notice
+novel
+now
+nuclear
+number
+nurse
+nut
+oak
+obey
+object
+oblige
+obscure
+observe
+obtain
+obvious
+occur
+ocean
+october
+odor
+off
+offer
+office
+often
+oil
+okay
+old
+olive
+olympic
+omit
+once
+one
+onion
+online
+only
+open
+opera
+opinion
+oppose
+option
+orange
+orbit
+orchard
+order
+ordinary
+organ
+orient
+original
+orphan
+ostrich
+other
+outdoor
+outer
+output
+outside
+oval
+oven
+over
+own
+owner
+oxygen
+oyster
+ozone
+pact
+paddle
+page
+pair
+palace
+palm
+panda
+panel
+panic
+panther
+paper
+parade
+parent
+park
+parrot
+party
+pass
+patch
+path
+patient
+patrol
+pattern
+pause
+pave
+payment
+peace
+peanut
+pear
+peasant
+pelican
+pen
+penalty
+pencil
+people
+pepper
+perfect
+permit
+person
+pet
+phone
+photo
+phrase
+physical
+piano
+picnic
+picture
+piece
+pig
+pigeon
+pill
+pilot
+pink
+pioneer
+pipe
+pistol
+pitch
+pizza
+place
+planet
+plastic
+plate
+play
+please
+pledge
+pluck
+plug
+plunge
+poem
+poet
+point
+polar
+pole
+police
+pond
+pony
+pool
+popular
+portion
+position
+possible
+post
+potato
+pottery
+poverty
+powder
+power
+practice
+praise
+predict
+prefer
+prepare
+present
+pretty
+prevent
+price
+pride
+primary
+print
+priority
+prison
+private
+prize
+problem
+process
+produce
+profit
+program
+project
+promote
+proof
+property
+prosper
+protect
+proud
+provide
+public
+pudding
+pull
+pulp
+pulse
+pumpkin
+punch
+pupil
+puppy
+purchase
+purity
+purpose
+purse
+push
+put
+puzzle
+pyramid
+quality
+quantum
+quarter
+question
+quick
+quit
+quiz
+quote
+rabbit
+raccoon
+race
+rack
+radar
+radio
+rail
+rain
+raise
+rally
+ramp
+ranch
+random
+range
+rapid
+rare
+rate
+rather
+raven
+raw
+razor
+ready
+real
+reason
+rebel
+rebuild
+recall
+receive
+recipe
+record
+recycle
+reduce
+reflect
+reform
+refuse
+region
+regret
+regular
+reject
+relax
+release
+relief
+rely
+remain
+remember
+remind
+remove
+render
+renew
+rent
+reopen
+repair
+repeat
+replace
+report
+require
+rescue
+resemble
+resist
+resource
+response
+result
+retire
+retreat
+return
+reunion
+reveal
+review
+reward
+rhythm
+rib
+ribbon
+rice
+rich
+ride
+ridge
+rifle
+right
+rigid
+ring
+riot
+ripple
+risk
+ritual
+rival
+river
+road
+roast
+robot
+robust
+rocket
+romance
+roof
+rookie
+room
+rose
+rotate
+rough
+round
+route
+royal
+rubber
+rude
+rug
+rule
+run
+runway
+rural
+sad
+saddle
+sadness
+safe
+sail
+salad
+salmon
+salon
+salt
+salute
+same
+sample
+sand
+satisfy
+satoshi
+sauce
+sausage
+save
+say
+scale
+scan
+scare
+scatter
+scene
+scheme
+school
+science
+scissors
+scorpion
+scout
+scrap
+screen
+script
+scrub
+sea
+search
+season
+seat
+second
+secret
+section
+security
+seed
+seek
+segment
+select
+sell
+seminar
+senior
+sense
+sentence
+series
+service
+session
+settle
+setup
+seven
+shadow
+shaft
+shallow
+share
+shed
+shell
+sheriff
+shield
+shift
+shine
+ship
+shiver
+shock
+shoe
+shoot
+shop
+short
+shoulder
+shove
+shrimp
+shrug
+shuffle
+shy
+sibling
+sick
+side
+siege
+sight
+sign
+silent
+silk
+silly
+silver
+similar
+simple
+since
+sing
+siren
+sister
+situate
+six
+size
+skate
+sketch
+ski
+skill
+skin
+skirt
+skull
+slab
+slam
+sleep
+slender
+slice
+slide
+slight
+slim
+slogan
+slot
+slow
+slush
+small
+smart
+smile
+smoke
+smooth
+snack
+snake
+snap
+sniff
+snow
+soap
+soccer
+social
+sock
+soda
+soft
+solar
+soldier
+solid
+solution
+solve
+someone
+song
+soon
+sorry
+sort
+soul
+sound
+soup
+source
+south
+space
+spare
+spatial
+spawn
+speak
+special
+speed
+spell
+spend
+sphere
+spice
+spider
+spike
+spin
+spirit
+split
+spoil
+sponsor
+spoon
+sport
+spot
+spray
+spread
+spring
+spy
+square
+squeeze
+squirrel
+stable
+stadium
+staff
+stage
+stairs
+stamp
+stand
+start
+state
+stay
+steak
+steel
+stem
+step
+stereo
+stick
+still
+sting
+stock
+stomach
+stone
+stool
+story
+stove
+strategy
+street
+strike
+strong
+struggle
+student
+stuff
+stumble
+style
+subject
+submit
+subway
+success
+such
+sudden
+suffer
+sugar
+suggest
+suit
+summer
+sun
+sunny
+sunset
+super
+supply
+supreme
+sure
+surface
+surge
+surprise
+surround
+survey
+suspect
+sustain
+swallow
+swamp
+swap
+swarm
+swear
+sweet
+swift
+swim
+swing
+switch
+sword
+symbol
+symptom
+syrup
+system
+table
+tackle
+tag
+tail
+talent
+talk
+tank
+tape
+target
+task
+taste
+tattoo
+taxi
+teach
+team
+tell
+ten
+tenant
+tennis
+tent
+term
+test
+text
+thank
+that
+theme
+then
+theory
+there
+they
+thing
+this
+thought
+three
+thrive
+throw
+thumb
+thunder
+ticket
+tide
+tiger
+tilt
+timber
+time
+tiny
+tip
+tired
+tissue
+title
+toast
+tobacco
+today
+toddler
+toe
+together
+toilet
+token
+tomato
+tomorrow
+tone
+tongue
+tonight
+tool
+tooth
+top
+topic
+topple
+torch
+tornado
+tortoise
+toss
+total
+tourist
+toward
+tower
+town
+toy
+track
+trade
+traffic
+tragic
+train
+transfer
+trap
+trash
+travel
+tray
+treat
+tree
+trend
+trial
+tribe
+trick
+trigger
+trim
+trip
+trophy
+trouble
+truck
+true
+truly
+trumpet
+trust
+truth
+try
+tube
+tuition
+tumble
+tuna
+tunnel
+turkey
+turn
+turtle
+twelve
+twenty
+twice
+twin
+twist
+two
+type
+typical
+ugly
+umbrella
+unable
+unaware
+uncle
+uncover
+under
+undo
+unfair
+unfold
+unhappy
+uniform
+unique
+unit
+universe
+unknown
+unlock
+until
+unusual
+unveil
+update
+upgrade
+uphold
+upon
+upper
+upset
+urban
+urge
+usage
+use
+used
+useful
+useless
+usual
+utility
+vacant
+vacuum
+vague
+valid
+valley
+valve
+van
+vanish
+vapor
+various
+vast
+vault
+vehicle
+velvet
+vendor
+venture
+venue
+verb
+verify
+version
+very
+vessel
+veteran
+viable
+vibrant
+vicious
+victory
+video
+view
+village
+vintage
+violin
+virtual
+virus
+visa
+visit
+visual
+vital
+vivid
+vocal
+voice
+void
+volcano
+volume
+vote
+voyage
+wage
+wagon
+wait
+walk
+wall
+walnut
+want
+warfare
+warm
+warrior
+wash
+wasp
+waste
+water
+wave
+way
+wealth
+weapon
+wear
+weasel
+weather
+web
+wedding
+weekend
+weird
+welcome
+west
+wet
+whale
+what
+wheat
+wheel
+when
+where
+whip
+whisper
+wide
+width
+wife
+wild
+will
+win
+window
+wine
+wing
+wink
+winner
+winter
+wire
+wisdom
+wise
+wish
+witness
+wolf
+woman
+wonder
+wood
+wool
+word
+work
+world
+worry
+worth
+wrap
+wreck
+wrestle
+wrist
+write
+wrong
+yard
+year
+yellow
+you
+young
+youth
+zebra
+zero
+zone
+zoo
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 6ea53ac..28f6808 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -25,8 +25,8 @@ interface HeaderProps {
sessionItems: StorageItem[];
events: ClipboardEvent[];
onOpenClipboardModal: () => void;
- activeTab: 'backup' | 'restore';
- setActiveTab: (tab: 'backup' | 'restore') => void;
+ activeTab: 'backup' | 'restore' | 'seedblender';
+ setActiveTab: (tab: 'backup' | 'restore' | 'seedblender') => void;
encryptedMnemonicCache: any;
handleLockAndClear: () => void;
appVersion: string;
@@ -101,6 +101,12 @@ const Header: React.FC = ({
>
Restore
+
diff --git a/src/components/SeedBlender.tsx b/src/components/SeedBlender.tsx
new file mode 100644
index 0000000..a1cf4d8
--- /dev/null
+++ b/src/components/SeedBlender.tsx
@@ -0,0 +1,337 @@
+/**
+ * @file SeedBlender.tsx
+ * @summary Main component for the Seed Blending feature.
+ */
+import { useState, useEffect } from 'react';
+import { QrCode, X, Plus, CheckCircle2, AlertTriangle } from 'lucide-react';
+import QRScanner from './QRScanner';
+import { blendMnemonicsAsync, checkXorStrength, mnemonicToEntropy } from '../lib/seedblend';
+
+// A simple debounce function
+function debounce any>(func: F, waitFor: number) {
+ let timeout: ReturnType | null = null;
+
+ return (...args: Parameters): Promise> =>
+ new Promise(resolve => {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+
+ timeout = setTimeout(() => resolve(func(...args)), waitFor);
+ });
+}
+
+export function SeedBlender() {
+ const [mnemonics, setMnemonics] = useState(['']);
+ const [validity, setValidity] = useState>([null]);
+ const [showQRScanner, setShowQRScanner] = useState(false);
+ const [scanTargetIndex, setScanTargetIndex] = useState(null);
+
+ // State for Step 2
+ const [blendedResult, setBlendedResult] = useState<{ blendedMnemonic12: string; blendedMnemonic24?: string; } | null>(null);
+ const [xorStrength, setXorStrength] = useState<{ isWeak: boolean; uniqueBytes: number; } | null>(null);
+ const [blendError, setBlendError] = useState('');
+ const [blending, setBlending] = useState(false);
+
+
+ // Effect to validate and blend mnemonics
+ useEffect(() => {
+ const processMnemonics = async () => {
+ setBlending(true);
+ setBlendError('');
+
+ const filledMnemonics = mnemonics.map(m => m.trim()).filter(m => m.length > 0);
+ if (filledMnemonics.length === 0) {
+ setBlendedResult(null);
+ setXorStrength(null);
+ setValidity(mnemonics.map(() => null));
+ setBlending(false);
+ return;
+ }
+
+ const newValidity: Array = [...mnemonics].fill(null);
+ const validMnemonics: string[] = [];
+
+ await Promise.all(mnemonics.map(async (mnemonic, index) => {
+ if (mnemonic.trim()) {
+ try {
+ await mnemonicToEntropy(mnemonic.trim());
+ newValidity[index] = true;
+ validMnemonics.push(mnemonic.trim());
+ } catch (e) {
+ newValidity[index] = false;
+ }
+ }
+ }));
+
+ setValidity(newValidity);
+
+ if (validMnemonics.length > 0) {
+ try {
+ const result = await blendMnemonicsAsync(validMnemonics);
+ const strength = checkXorStrength(result.blendedEntropy);
+ setBlendedResult(result);
+ setXorStrength(strength);
+ } catch (e: any) {
+ setBlendError(e.message);
+ setBlendedResult(null);
+ setXorStrength(null);
+ }
+ } else {
+ setBlendedResult(null);
+ setXorStrength(null);
+ }
+ setBlending(false);
+ };
+
+ // Debounce the processing to avoid running on every keystroke
+ const debouncedProcess = debounce(processMnemonics, 300);
+ debouncedProcess();
+
+ }, [mnemonics]);
+
+
+ const handleAddMnemonic = () => {
+ setMnemonics([...mnemonics, '']);
+ setValidity([...validity, null]);
+ };
+
+ const handleMnemonicChange = (index: number, value: string) => {
+ const newMnemonics = [...mnemonics];
+ newMnemonics[index] = value;
+ setMnemonics(newMnemonics);
+ };
+
+ const handleRemoveMnemonic = (index: number) => {
+ if (mnemonics.length > 1) {
+ const newMnemonics = mnemonics.filter((_, i) => i !== index);
+ const newValidity = validity.filter((_, i) => i !== index);
+ setMnemonics(newMnemonics);
+ setValidity(newValidity);
+ } else {
+ setMnemonics(['']);
+ setValidity([null]);
+ }
+ };
+
+ const handleScan = (index: number) => {
+ setScanTargetIndex(index);
+ setShowQRScanner(true);
+ };
+
+ const handleScanSuccess = (scannedText: string) => {
+ if (scanTargetIndex !== null) {
+ handleMnemonicChange(scanTargetIndex, scannedText);
+ }
+ setShowQRScanner(false);
+ setScanTargetIndex(null);
+ };
+
+ const getBorderColor = (isValid: boolean | null) => {
+ if (isValid === true) return 'border-green-500 focus:ring-green-500';
+ if (isValid === false) return 'border-red-500 focus:ring-red-500';
+ return 'border-slate-200 focus:ring-teal-500';
+ }
+
+ return (
+ <>
+
+ {/* Header */}
+
+
Seed Blender
+
+
+ {/* Step 1: Input Mnemonics */}
+
+
Step 1: Input Mnemonics
+
+ {mnemonics.map((mnemonic, index) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* Step 2: Blended Preview */}
+
+
Step 2: Blended Preview
+ {blending &&
Blending...
}
+ {blendError &&
{blendError}
}
+
+ {!blending && !blendError && blendedResult && (
+
+ {xorStrength?.isWeak && (
+
+
+
+ Weak XOR Result: Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.
+
+
+ )}
+
+
+
+
+ {blendedResult.blendedMnemonic12}
+
+
+
+ {blendedResult.blendedMnemonic24 && (
+
+
+
+ {blendedResult.blendedMnemonic24}
+
+
+ )}
+
+ )}
+
+ {!blending && !blendError && !blendedResult && (
+
Previews will appear here once you enter one or more valid mnemonics.
+ )}
+
+
+ {/* Step 3: Input Dice Rolls */}
+
+
Step 3: Input Dice Rolls
+
+
+
+ {/* Step 4: Final Mnemonic */}
+
+
Step 4: Generate Final Mnemonic
+
+ {!finalMnemonic ? (
+ <>
+
+ Once you have entered valid mnemonics and at least 50 dice rolls, you can generate the final, hardened mnemonic.
+
+
+ >
+ ) : (
+
+
+
+ Final Mnemonic Generated
+
+
+
+
+
+
+
+
+ Security Warning: Write this mnemonic down immediately on paper or metal. Do not save it digitally. Clear when done.
+
+
+
+ )}
+
+
+
+ {/* QR Scanner Modal */}
+ {showQRScanner && (
+ setShowQRScanner(false)}
+ />
+ )}
+ >
+ );
+}
diff --git a/src/lib/seedblend.test.ts b/src/lib/seedblend.test.ts
new file mode 100644
index 0000000..6c9d6c0
--- /dev/null
+++ b/src/lib/seedblend.test.ts
@@ -0,0 +1,154 @@
+/**
+ * @file Unit tests for the seedblend library.
+ * @summary These tests are a direct port of the unit tests from the
+ * 'dice_mix_interactive.py' script. Their purpose is to verify that the
+ * TypeScript/Web Crypto implementation is 100% logic-compliant with the
+ * Python reference script, producing identical, deterministic outputs for
+ * the same inputs.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ xorBytes,
+ hkdfExtractExpand,
+ mnemonicToEntropy,
+ entropyToMnemonic,
+ blendMnemonicsAsync,
+ mixWithDiceAsync,
+ diceToBytes,
+ detectBadPatterns,
+ calculateDiceStats,
+} from './seedblend';
+
+// Helper to convert hex strings to Uint8Array
+const fromHex = (hex: string) => new Uint8Array(hex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
+
+describe('SeedBlend Logic Compliance Tests (Ported from Python)', () => {
+
+ it('should ensure XOR blending is order-independent (commutative)', () => {
+ const ent1 = fromHex("a1".repeat(16));
+ const ent2 = fromHex("b2".repeat(16));
+ const ent3 = fromHex("c3".repeat(16));
+
+ const blended1 = xorBytes(xorBytes(ent1, ent2), ent3);
+ const blended2 = xorBytes(xorBytes(ent3, ent2), ent1);
+
+ expect(blended1).toEqual(blended2);
+ });
+
+ it('should handle XOR of different length inputs correctly', () => {
+ const ent128 = fromHex("a1".repeat(16)); // 12-word seed
+ const ent256 = fromHex("b2".repeat(32)); // 24-word seed
+
+ const blended = xorBytes(ent128, ent256);
+ expect(blended.length).toBe(32);
+
+ // Verify cycling: first 16 bytes should be a1^b2, last 16 should also be a1^b2
+ const expectedChunk = fromHex("a1b2".repeat(8));
+ expect(blended.slice(0, 16)).toEqual(xorBytes(fromHex("a1".repeat(16)), fromHex("b2".repeat(16))));
+ expect(blended.slice(16, 32)).toEqual(xorBytes(fromHex("a1".repeat(16)), fromHex("b2".repeat(16))));
+ });
+
+ it('should perform a basic round-trip and validation for mnemonics', async () => {
+ const valid12 = "army van defense carry jealous true garbage claim echo media make crunch";
+ const ent12 = await mnemonicToEntropy(valid12);
+ expect(ent12.length).toBe(16);
+
+ const mnBack = await entropyToMnemonic(ent12);
+ const entBack = await mnemonicToEntropy(mnBack);
+ expect(ent12).toEqual(entBack);
+
+ const valid24 = "zone huge rather sad stomach ostrich real decline laptop glimpse gasp reunion garbage rain reopen furnace catch hire feed charge cheese liquid earn exchange";
+ const ent24 = await mnemonicToEntropy(valid24);
+ expect(ent24.length).toBe(32);
+ });
+
+ it('should be deterministic for the same HKDF inputs', async () => {
+ const data = new Uint8Array(64).fill(0x01);
+ const info1 = new TextEncoder().encode('test');
+ const info2 = new TextEncoder().encode('different');
+
+ const out1 = await hkdfExtractExpand(data, 32, info1);
+ const out2 = await hkdfExtractExpand(data, 32, info1);
+ const out3 = await hkdfExtractExpand(data, 32, info2);
+
+ expect(out1).toEqual(out2);
+ expect(out1).not.toEqual(out3);
+ });
+
+ it('should produce correct HKDF lengths and match prefixes', async () => {
+ const data = fromHex('ab'.repeat(32));
+ const info = new TextEncoder().encode('len-test');
+
+ const out16 = await hkdfExtractExpand(data, 16, info);
+ const out32 = await hkdfExtractExpand(data, 32, info);
+
+ expect(out16.length).toBe(16);
+ expect(out32.length).toBe(32);
+ expect(out16).toEqual(out32.slice(0, 16));
+ });
+
+ it('should detect bad dice patterns', () => {
+ expect(detectBadPatterns("1111111111").bad).toBe(true);
+ expect(detectBadPatterns("123456123456").bad).toBe(true);
+ expect(detectBadPatterns("222333444555").bad).toBe(true);
+ expect(detectBadPatterns("314159265358979323846264338327950").bad).toBe(false);
+ });
+
+ it('should calculate dice stats correctly', () => {
+ const rolls = "123456".repeat(10); // 60 rolls, perfectly uniform
+ const stats = calculateDiceStats(rolls);
+
+ expect(stats.length).toBe(60);
+ expect(stats.distribution).toEqual({ 1: 10, 2: 10, 3: 10, 4: 10, 5: 10, 6: 10 });
+ expect(stats.mean).toBeCloseTo(3.5);
+ expect(stats.chiSquare).toBe(0); // Perfect uniformity
+ });
+
+ it('should convert dice to bytes using integer math', () => {
+ const rolls = "123456".repeat(17); // 102 rolls
+ const bytes = diceToBytes(rolls);
+
+ // Based on python script: `(102 * 2584965 // 1000000 + 7) // 8` = 33 bytes
+ expect(bytes.length).toBe(33);
+ });
+
+ // --- Crucial Integration Tests ---
+
+ it('[CRITICAL] must reproduce the exact blended mnemonic for 4 seeds', async () => {
+ const sessionMnemonics = [
+ // 2x 24-word seeds
+ "dog guitar hotel random owner gadget salute riot patrol work advice panic erode leader pass cross section laundry elder asset soul scale immune scatter",
+ "unable point minimum sun peanut habit ready high nothing cherry silver eagle pen fabric list collect impact loan casual lyrics pig train middle screen",
+ // 2x 12-word seeds
+ "ethics super fog off merge misery atom sail domain bullet rather lamp",
+ "life repeat play screen initial slow run stumble vanish raven civil exchange"
+ ];
+
+ const expectedMnemonic = "gasp question busy coral shrug jacket sample return main issue finish truck cage task tiny nerve desk treat feature balance idea timber dose crush";
+
+ const { blendedMnemonic24 } = await blendMnemonicsAsync(sessionMnemonics);
+
+ expect(blendedMnemonic24).toBe(expectedMnemonic);
+ });
+
+ it('[CRITICAL] must reproduce the exact final mixed output with 4 seeds and dice', async () => {
+ const sessionMnemonics = [
+ "dog guitar hotel random owner gadget salute riot patrol work advice panic erode leader pass cross section laundry elder asset soul scale immune scatter",
+ "unable point minimum sun peanut habit ready high nothing cherry silver eagle pen fabric list collect impact loan casual lyrics pig train middle screen",
+ "ethics super fog off merge misery atom sail domain bullet rather lamp",
+ "life repeat play screen initial slow run stumble vanish raven civil exchange"
+ ];
+ const diceRolls = "3216534562134256361653421342634265362163523652413643616523462134652431625362543";
+
+ const expectedFinalMnemonic = "satisfy sphere banana negative blood divide force crime window fringe private market sense enjoy diet talent super abuse toss miss until visa inform dignity";
+
+ // Stage 1: Blend
+ const { blendedEntropy } = await blendMnemonicsAsync(sessionMnemonics);
+
+ // Stage 2: Mix
+ const { finalMnemonic } = await mixWithDiceAsync(blendedEntropy, diceRolls, 256);
+
+ expect(finalMnemonic).toBe(expectedFinalMnemonic);
+ });
+});
diff --git a/src/lib/seedblend.ts b/src/lib/seedblend.ts
new file mode 100644
index 0000000..62a2248
--- /dev/null
+++ b/src/lib/seedblend.ts
@@ -0,0 +1,450 @@
+/**
+ * @file Seed Blending Library for seedpgp-web
+ * @author Gemini
+ * @version 1.0.0
+ *
+ * @summary
+ * A direct and 100% logic-compliant port of the 'dice_mix_interactive.py'
+ * Python script to TypeScript for use in browser environments. This module
+ * implements XOR-based seed blending and HKDF-SHA256 enhancement with dice
+ * rolls using the Web Crypto API.
+ *
+ * @description
+ * The process involves two stages:
+ * 1. **Mnemonic Blending**: Multiple BIP39 mnemonics are converted to their
+ * raw entropy and commutatively blended using a bitwise XOR operation.
+ * 2. **Dice Mixing**: The blended entropy is combined with entropy from a
+ * long string of physical dice rolls. The result is processed through
+ * HKDF-SHA256 to produce a final, cryptographically-strong mnemonic.
+ *
+ * This implementation strictly follows the Python script's logic, including
+ * checksum validation, bitwise operations, and cryptographic constructions,
+ * to ensure verifiable, deterministic outputs that match the reference script.
+ */
+
+import wordlistTxt from '../bip39_wordlist.txt?raw';
+import { webcrypto } from 'crypto';
+
+// --- Isomorphic Crypto Setup ---
+
+// Use browser crypto if available, otherwise fallback to Node.js webcrypto.
+// This allows the library to run in both the browser and the test environment (Node.js).
+const subtle = (typeof window !== 'undefined' && window.crypto?.subtle)
+ ? window.crypto.subtle
+ : webcrypto.subtle;
+
+
+// --- BIP39 Wordlist Loading ---
+
+/**
+ * The BIP39 English wordlist, loaded directly from the project file.
+ */
+export const BIP39_WORDLIST: readonly string[] = wordlistTxt.trim().split('\n');
+
+/**
+ * A Map for fast, case-insensitive lookup of a word's index.
+ */
+export const WORD_INDEX = new Map(
+ BIP39_WORDLIST.map((word, index) => [word, index])
+);
+
+if (BIP39_WORDLIST.length !== 2048) {
+ throw new Error(`Invalid wordlist loaded: expected 2048 words, got ${BIP39_WORDLIST.length}`);
+}
+
+
+// --- Web Crypto API Helpers ---
+
+/**
+ * Computes the SHA-256 hash of the given data.
+ * @param data The data to hash.
+ * @returns A promise that resolves to the hash as a Uint8Array.
+ */
+async function sha256(data: Uint8Array): Promise {
+ const hashBuffer = await subtle.digest('SHA-256', data);
+ return new Uint8Array(hashBuffer);
+}
+
+/**
+ * Performs an HMAC-SHA256 operation.
+ * @param key The HMAC key.
+ * @param data The data to authenticate.
+ * @returns A promise that resolves to the HMAC tag.
+ */
+async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise {
+ const cryptoKey = await subtle.importKey(
+ 'raw',
+ key,
+ { name: 'HMAC', hash: 'SHA-256' },
+ false, // not exportable
+ ['sign']
+ );
+ const signature = await subtle.sign('HMAC', cryptoKey, data);
+ return new Uint8Array(signature);
+}
+
+
+// --- Core Cryptographic Functions (Ported from Python) ---
+
+/**
+ * XOR two byte arrays, cycling the shorter one if lengths differ.
+ * This is a direct port of `xor_bytes` from the Python script.
+ */
+export function xorBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
+ const maxLen = Math.max(a.length, b.length);
+ const result = new Uint8Array(maxLen);
+ for (let i = 0; i < maxLen; i++) {
+ result[i] = a[i % a.length] ^ b[i % b.length];
+ }
+ return result;
+}
+
+/**
+ * An asynchronous, browser-compatible port of `hkdf_extract_expand` from the Python script.
+ * Implements HKDF using HMAC-SHA256 according to RFC 5869.
+ *
+ * @param keyMaterial The input keying material (IKM).
+ * @param length The desired output length in bytes.
+ * @param info Optional context and application specific information.
+ * @returns A promise resolving to the output keying material (OKM).
+ */
+export async function hkdfExtractExpand(
+ keyMaterial: Uint8Array,
+ length: number = 32,
+ info: Uint8Array = new Uint8Array(0)
+): Promise {
+ // 1. Extract
+ const salt = new Uint8Array(32).fill(0); // Fixed zero salt, as in Python script
+ const prk = await hmacSha256(salt, keyMaterial);
+
+ // 2. Expand
+ let t = new Uint8Array(0);
+ let okm = new Uint8Array(length);
+ let written = 0;
+ let counter = 1;
+
+ while (written < length) {
+ const dataToHmac = new Uint8Array(t.length + info.length + 1);
+ dataToHmac.set(t, 0);
+ dataToHmac.set(info, t.length);
+ dataToHmac.set([counter], t.length + info.length);
+
+ t = await hmacSha256(prk, dataToHmac);
+
+ const toWrite = Math.min(t.length, length - written);
+ okm.set(t.slice(0, toWrite), written);
+ written += toWrite;
+ counter++;
+ }
+
+ return okm;
+}
+
+/**
+ * Converts a BIP39 mnemonic string to its raw entropy bytes.
+ * Asynchronously performs checksum validation.
+ * This is a direct port of `mnemonic_to_bytes` from the Python script.
+ */
+export async function mnemonicToEntropy(mnemonicStr: string): Promise {
+ const words = mnemonicStr.trim().toLowerCase().split(/\s+/);
+ if (words.length !== 12 && words.length !== 24) {
+ throw new Error("Mnemonic must be 12 or 24 words");
+ }
+
+ let fullInt = 0n;
+ for (const word of words) {
+ const index = WORD_INDEX.get(word);
+ if (index === undefined) {
+ throw new Error(`Invalid word: ${word}`);
+ }
+ fullInt = (fullInt << 11n) | BigInt(index);
+ }
+
+ const totalBits = words.length * 11;
+ const CS = totalBits / 33; // 4 for 12 words, 8 for 24 words
+ const entropyBits = totalBits - CS;
+
+ let entropyInt = fullInt >> BigInt(CS);
+ const entropyBytes = new Uint8Array(entropyBits / 8);
+
+ for (let i = entropyBytes.length - 1; i >= 0; i--) {
+ entropyBytes[i] = Number(entropyInt & 0xFFn);
+ entropyInt >>= 8n;
+ }
+
+ // Verify checksum
+ const hashBytes = await sha256(entropyBytes);
+ const computedChecksum = hashBytes[0] >> (8 - CS);
+ const originalChecksum = Number(fullInt & ((1n << BigInt(CS)) - 1n));
+
+ if (originalChecksum !== computedChecksum) {
+ throw new Error("Invalid mnemonic checksum");
+ }
+
+ return entropyBytes;
+}
+
+/**
+ * Converts raw entropy bytes to a BIP39 mnemonic string.
+ * Asynchronously calculates and appends the checksum.
+ * This is a direct port of `bytes_to_mnemonic` from the Python script.
+ */
+export async function entropyToMnemonic(entropyBytes: Uint8Array): Promise {
+ const ENT = entropyBytes.length * 8;
+ if (ENT !== 128 && ENT !== 256) {
+ throw new Error("Entropy must be 128 or 256 bits");
+ }
+ const CS = ENT / 32;
+
+ const hashBytes = await sha256(entropyBytes);
+ const checksum = hashBytes[0] >> (8 - CS);
+
+ let entropyInt = 0n;
+ for (const byte of entropyBytes) {
+ entropyInt = (entropyInt << 8n) | BigInt(byte);
+ }
+
+ const fullInt = (entropyInt << BigInt(CS)) | BigInt(checksum);
+ const totalBits = ENT + CS;
+
+ const mnemonicWords: string[] = [];
+ for (let i = 0; i < totalBits / 11; i++) {
+ const shift = BigInt(totalBits - (i + 1) * 11);
+ const index = Number((fullInt >> shift) & 0x7FFn);
+ mnemonicWords.push(BIP39_WORDLIST[index]);
+ }
+
+ return mnemonicWords.join(' ');
+}
+
+
+// --- Dice and Statistical Functions ---
+
+/**
+ * Converts a string of dice rolls to a byte array using integer-based math
+ * to avoid floating point precision issues.
+ * This is a direct port of the dice conversion logic from the Python script.
+ */
+export function diceToBytes(diceRolls: string): Uint8Array {
+ const n = diceRolls.length;
+
+ // Integer-based calculation of bits: n * log2(6)
+ // log2(6) ≈ 2.5849625, so we use a scaled integer 2584965 for precision.
+ const totalBits = Math.floor(n * 2584965 / 1000000);
+ const diceBytesLen = Math.ceil(totalBits / 8);
+
+ let diceInt = 0n;
+ for (const roll of diceRolls) {
+ const value = parseInt(roll, 10);
+ if (isNaN(value) || value < 1 || value > 6) {
+ throw new Error(`Invalid dice roll: '${roll}'. Must be 1-6.`);
+ }
+ diceInt = diceInt * 6n + BigInt(value - 1);
+ }
+
+ if (diceBytesLen === 0 && diceInt > 0n) {
+ // This case should not be hit with reasonable inputs but is a safeguard.
+ throw new Error("Cannot represent non-zero dice value in zero bytes.");
+ }
+
+ const diceBytes = new Uint8Array(diceBytesLen);
+ for (let i = diceBytes.length - 1; i >= 0; i--) {
+ diceBytes[i] = Number(diceInt & 0xFFn);
+ diceInt >>= 8n;
+ }
+
+ return diceBytes;
+}
+
+/**
+ * Detects statistically unlikely patterns in a string of dice rolls.
+ * This is a direct port of `detect_bad_patterns`.
+ */
+export function detectBadPatterns(diceRolls: string): { bad: boolean; message?: string } {
+ const patterns = [
+ /1{5,}/, /2{5,}/, /3{5,}/, /4{5,}/, /5{5,}/, /6{5,}/, // Long repeats
+ /(123456){2,}/, /(654321){2,}/, /(123){3,}/, /(321){3,}/, // Sequences
+ /(?:222333444|333444555|444555666)/, // Grouped increments
+ /(\d)\1{4,}/, // Any digit repeated 5+
+ /(?:121212|131313|141414|151515|161616){2,}/, // Alternating
+ ];
+
+ for (const pattern of patterns) {
+ if (pattern.test(diceRolls)) {
+ return { bad: true, message: `Bad pattern detected: matches ${pattern.source}` };
+ }
+ }
+ return { bad: false };
+}
+
+/**
+ * Interface for dice roll statistics.
+ */
+export interface DiceStats {
+ length: number;
+ distribution: Record;
+ mean: number;
+ stdDev: number;
+ estimatedEntropyBits: number;
+ chiSquare: number;
+}
+
+/**
+ * Calculates and returns various statistics for the given dice rolls.
+ * Ported from `calculate_dice_stats` and the main script's stats logic.
+ */
+export function calculateDiceStats(diceRolls: string): DiceStats {
+ if (!diceRolls) {
+ return { length: 0, distribution: {}, mean: 0, stdDev: 0, estimatedEntropyBits: 0, chiSquare: 0 };
+ }
+ const rolls = diceRolls.split('').map(c => parseInt(c, 10));
+ const n = rolls.length;
+
+ const counts: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
+ for (const roll of rolls) {
+ counts[roll]++;
+ }
+
+ const sum = rolls.reduce((a, b) => a + b, 0);
+ const mean = sum / n;
+
+ const variance = rolls.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / n;
+ const stdDev = n > 1 ? Math.sqrt(rolls.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n - 1)) : 0;
+
+ const estimatedEntropyBits = n * Math.log2(6);
+
+ const expected = n / 6;
+ let chiSquare = 0;
+ for (let i = 1; i <= 6; i++) {
+ chiSquare += Math.pow(counts[i] - expected, 2) / expected;
+ }
+
+ return {
+ length: n,
+ distribution: counts,
+ mean: mean,
+ stdDev: stdDev,
+ estimatedEntropyBits,
+ chiSquare,
+ };
+}
+
+
+// --- Main Blending Logic ---
+
+/**
+ * Checks for weak XOR results (low diversity or all zeros).
+ * Ported from the main logic in the Python script.
+ */
+export function checkXorStrength(blendedEntropy: Uint8Array): {
+ isWeak: boolean;
+ uniqueBytes: number;
+ allZeros: boolean;
+} {
+ const uniqueBytes = new Set(blendedEntropy).size;
+ const allZeros = blendedEntropy.every(byte => byte === 0);
+
+ // Heuristic from Python script: < 32 unique bytes is a warning.
+ return {
+ isWeak: uniqueBytes < 32 || allZeros,
+ uniqueBytes,
+ allZeros,
+ };
+}
+
+
+// --- Main Blending & Mixing Orchestration ---
+
+/**
+ * Stage 1: Asynchronously blends multiple mnemonics using XOR.
+ *
+ * @param mnemonics An array of mnemonic strings to blend.
+ * @returns A promise that resolves to the blended entropy and preview mnemonics.
+ */
+export async function blendMnemonicsAsync(mnemonics: string[]): Promise<{
+ blendedEntropy: Uint8Array;
+ blendedMnemonic12: string;
+ blendedMnemonic24?: string;
+ maxEntropyBits: number;
+}> {
+ if (mnemonics.length === 0) {
+ throw new Error("At least one mnemonic is required for blending.");
+ }
+
+ const entropies = await Promise.all(mnemonics.map(mnemonicToEntropy));
+
+ let maxEntropyBits = 128;
+ for (const entropy of entropies) {
+ if (entropy.length * 8 > maxEntropyBits) {
+ maxEntropyBits = entropy.length * 8;
+ }
+ }
+
+ // Commutative XOR blending
+ let blendedEntropy = entropies[0];
+ for (let i = 1; i < entropies.length; i++) {
+ blendedEntropy = xorBytes(blendedEntropy, entropies[i]);
+ }
+
+ // Generate previews
+ const blendedMnemonic12 = await entropyToMnemonic(blendedEntropy.slice(0, 16));
+ let blendedMnemonic24: string | undefined;
+ if (blendedEntropy.length >= 32) {
+ blendedMnemonic24 = await entropyToMnemonic(blendedEntropy.slice(0, 32));
+ }
+
+ return {
+ blendedEntropy,
+ blendedMnemonic12,
+ blendedMnemonic24,
+ maxEntropyBits
+ };
+}
+
+/**
+ * Stage 2: Asynchronously mixes blended entropy with dice rolls using HKDF.
+ *
+ * @param blendedEntropy The result from the XOR blending stage.
+ * @param diceRolls A string of dice rolls (e.g., "16345...").
+ * @param outputBits The desired final entropy size (128 or 256).
+ * @param info A domain separation tag for HKDF.
+ * @returns A promise that resolves to the final mnemonic and related data.
+ */
+export async function mixWithDiceAsync(
+ blendedEntropy: Uint8Array,
+ diceRolls: string,
+ outputBits: 128 | 256 = 256,
+ info: string = 'seedsigner-dice-mix'
+): Promise<{
+ finalEntropy: Uint8Array;
+ finalMnemonic: string;
+ diceOnlyMnemonic: string;
+}> {
+ if (diceRolls.length < 50) {
+ throw new Error("A minimum of 50 dice rolls is required (99+ recommended).");
+ }
+
+ const diceBytes = diceToBytes(diceRolls);
+ const outputByteLength = outputBits === 128 ? 16 : 32;
+ const infoBytes = new TextEncoder().encode(info);
+ const diceOnlyInfoBytes = new TextEncoder().encode('dice-only');
+
+ // Generate dice-only preview
+ const diceOnlyEntropy = await hkdfExtractExpand(diceBytes, outputByteLength, diceOnlyInfoBytes);
+ const diceOnlyMnemonic = await entropyToMnemonic(diceOnlyEntropy);
+
+ // Combine blended entropy with dice bytes
+ const combinedMaterial = new Uint8Array(blendedEntropy.length + diceBytes.length);
+ combinedMaterial.set(blendedEntropy, 0);
+ combinedMaterial.set(diceBytes, blendedEntropy.length);
+
+ // Apply HKDF to the combined material
+ const finalEntropy = await hkdfExtractExpand(combinedMaterial, outputByteLength, infoBytes);
+ const finalMnemonic = await entropyToMnemonic(finalEntropy);
+
+ return {
+ finalEntropy,
+ finalMnemonic,
+ diceOnlyMnemonic,
+ };
+}