Normalize passphrase to NFC before scrypt - #23
Open
yottt-1 wants to merge 1 commit into
Open
Conversation
genIntermediate() and validateConfirmation() pass the passphrase to scryptsy
exactly as received, while decryptEpkVcode() delegates to the npm bip38
package, which normalizes to NFC first (bip38/index.js: `r.normalize("NFC")`).
For any passphrase containing decomposed characters the three paths disagree.
Generation and verification derive one prefactor from the un-normalized bytes;
spending derives a different one. A wallet minted from a decomposed passphrase
therefore verifies as valid and then cannot be decrypted -- and because
bip38.decrypt normalizes whatever it is given, neither the decomposed nor the
precomposed form recovers it afterwards.
Decomposed input is not unusual: macOS produces NFD routinely and several input
methods emit decomposed sequences. The PRO Series passphrase field applies no
charset restriction, so any Unicode the user types reaches scrypt verbatim.
REAL Series is unaffected, since factory passphrases are [0-9A-Z] and are
unchanged by normalization.
This aligns both call sites with what bip38 already does downstream.
Verified: all four vectors in test/confirmation_test.js and test/epk_test.js
still pass, and a full mint/verify/spend cycle with a decomposed passphrase now
succeeds in both forms, yielding the same private key.
yottt-1
force-pushed
the
fix/normalize-passphrase-nfc
branch
from
September 5, 2026 14:56
f541baa to
d168de0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The three code paths that derive a key from a passphrase do not agree on Unicode normalization.
src/Intermediate.js:10src/confirmation.js:26src/bip38.js→ npmbip38r.normalize("NFC")For a passphrase containing decomposed characters, generation and verification agree with each other on the un-normalized bytes while spending uses different bytes.
A PRO Series wallet minted from such a passphrase therefore verifies as valid and then cannot be spent. Verification is the step meant to catch exactly that, and it reports success.
It is also not recoverable afterwards: because
bip38.decryptnormalizes whatever it receives, neither the decomposed nor the precomposed form reproduces the prefactor that minted the wallet.Decomposed input is not exotic — macOS produces NFD routinely and several input methods emit decomposed sequences. The PRO Series passphrase field applies no charset restriction (
evian/src/IntermediateGenerate.js:84-85), so any Unicode the customer types reaches scrypt verbatim.REAL Series is unaffected: factory passphrases are
[0-9A-Z]and are unchanged by normalization.Fix
Two lines. Normalize at the two un-normalized call sites so they match what
bip38already does downstream.Verification
All four existing vectors still pass — I ran them directly, because
yarn testcurrently fails onmasterfor an unrelated reason (mocha 6 against a newerchalk:TypeError: chalk.blue is not a function), with or without this change:I also ran a full mint → verify → spend cycle with a decomposed passphrase against these modules plus a BIP38 EC-multiply printer step. Before the change, spending threw
invalid epk or passphraseafter verification reported valid. After it, both the decomposed and the precomposed form verify and spend, yielding the same private key.Two things worth considering separately from this patch
Fixing generation does not help anyone already affected. A wallet already minted from an un-normalized intermediate code remains spendable only through the un-normalized derivation, which this change removes from the tooling. If you want to support those customers, a recovery path should exist alongside the fix.
It may also be worth checking support history for the signature: a customer reporting that verification succeeded but spending fails with
invalid epk or passphrase, on a self-chosen passphrase. That is hard to diagnose from the outside and easy to misread as user error.I have a fuller write-up with the reproduction harness. I could not find a security contact — there is no
/.well-known/security.txton ballet.com and no disclosure policy I could locate — so if you would like it sent somewhere specific, tell me where and I will send it there rather than discussing details here.