Skip to content

Normalize passphrase to NFC before scrypt - #23

Open
yottt-1 wants to merge 1 commit into
balletcrypto:masterfrom
yottt-1:fix/normalize-passphrase-nfc
Open

Normalize passphrase to NFC before scrypt#23
yottt-1 wants to merge 1 commit into
balletcrypto:masterfrom
yottt-1:fix/normalize-passphrase-nfc

Conversation

@yottt-1

@yottt-1 yottt-1 commented Sep 5, 2026

Copy link
Copy Markdown

Problem

The three code paths that derive a key from a passphrase do not agree on Unicode normalization.

Path File Normalizes?
Generation src/Intermediate.js:10 no
Verification src/confirmation.js:26 no
Spending src/bip38.js → npm bip38 yes, r.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.decrypt normalizes 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 bip38 already does downstream.

Verification

All four existing vectors still pass — I ran them directly, because yarn test currently fails on master for an unrelated reason (mocha 6 against a newer chalk: TypeError: chalk.blue is not a function), with or without this change:

PASS confirmation_test #1 (uncompressed) 1HjL3uq8gHZcghRL9XgBpuzHTW67fhn4o1
PASS confirmation_test #2 (compressed)   1MwWPJpapWfMyCJuEsp24Aut3M2Yda8xBR
PASS epk_test #1 (uncompressed)          731284cd60421fcbcc68...
PASS epk_test #2 (compressed)            2154f9c69479511e0adb...

4 PASS, 0 FAIL

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 passphrase after 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.txt on 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.

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
yottt-1 force-pushed the fix/normalize-passphrase-nfc branch from f541baa to d168de0 Compare September 5, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant