Skip to content

Normalize passwords to NFC; decrypt tries NFC then the exact typed form - #43

Closed
deanrie wants to merge 1 commit into
seQRets:mainfrom
deanrie:fix/password-nfc-normalization
Closed

deanrie wants to merge 1 commit into
seQRets:mainfrom
deanrie:fix/password-nfc-normalization

Conversation

@deanrie

@deanrie deanrie commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The same visible password can arrive as different Unicode code points. é is U+00E9 (NFC) on most keyboards, but e + U+0301 (NFD) from some macOS inputs, IMEs and pasted text. TextEncoder turns those into different UTF-8 bytes:

NFC: 63 61 66 c3 a9
NFD: 63 61 66 65 cc 81

PBKDF2 therefore derives a different key, and a file encrypted on one machine fails on another with the generic "wrong password" message — the user has no way to tell what went wrong. Any password containing an accented or composed character is affected.

Fix

  • Encrypt: the password is NFC-normalized before key derivation, in all three implementations (src/lib/crypto.ts, scripts/build/crypto-core.js, and the hand-maintained decrypt core in site/ittybitz-recovery.html).
  • Decrypt: try NFC first; only if that fails and the typed string was not already NFC, retry with the exact typed bytes — which is how every pre-existing ciphertext was keyed.

Backward compatibility

I know crypto.ts is frozen for long-horizon ciphertexts, so this was designed so that nothing that decrypted before stops decrypting:

  • Already-NFC passwords (the overwhelming majority) produce identical bytes to before → identical key → one PBKDF2 run, same as today.
  • Pre-existing files encrypted with a non-NFC password still open via the exact-bytes fallback.
  • The only cost: a wrong password containing non-NFC characters now costs two PBKDF2 runs instead of one.
  • The wire format, salt/IV sizes, iteration count and the key-file concatenation are untouched. Errors thrown on failure are the same DOMException as before, so app.js's message mapping is unchanged.

Regression suite

  • New fixture nfd-password: a ciphertext produced by the unmodified v3.0.11 crypto.ts with an NFD password (Cafe\u0301-…), stored as a JSON \u0301 escape so no editor can silently recompose it. It proves the exact-bytes fallback keeps pre-normalization files openable — in crypto.ts, the app core, and the recovery file.
  • Fixture entries may now carry their own "password" (falls back to the suite-wide one).
  • Six new NFC↔NFD cross checks across crypto.ts, the app core and the recovery file.

Verified the checks are not vacuous: with the previous crypto.ts / crypto-core.js / recovery core swapped back in, the suite reports exactly those 6 as FAIL (95 passed, 6 failed); with this branch, 101 passed, 0 failed. All 33 historical fixtures (v1.0 → v3.0.11) pass in both configurations.

Artifacts

site/index.html, site/ittybitz-recovery.html and SHA256SUMS.txt are the rebuilt output of npm run build with re-pinned CSP hashes. I left the version string and CHANGELOG to you, since I don't know what you'd want to call the release.

Note: this touches the built site/index.html, so it will conflict with #44 if that lands too — whichever merges second just needs npm run build re-run.

The same visible password can arrive as different code points: "é" is
U+00E9 (NFC) on most keyboards but "e"+U+0301 (NFD) from some macOS
inputs, IMEs and pasted text. TextEncoder turns those into different
UTF-8 bytes, so PBKDF2 derived a different key and a file encrypted on
one machine failed on another with a misleading "wrong password".

Encryption now NFC-normalizes the password in all three implementations
(src/lib/crypto.ts, the app's crypto core, the recovery tool). Decryption
tries NFC first and, only when the typed string was not already NFC,
retries with the exact typed bytes — which is how every pre-existing
ciphertext was keyed. Nothing that decrypted before stops decrypting;
the only cost is one extra PBKDF2 run for a wrong non-NFC password.

Regression suite:
- new fixture "nfd-password": produced by the unmodified v3.0.11
  crypto.ts with an NFD password, kept as a JSON \u0301 escape so no
  editor can recompose it; proves the exact-bytes fallback.
- fixtures may now carry their own "password".
- NFC<->NFD cross checks for crypto.ts, the app core and the recovery
  file (6 checks that fail against the previous code, pass now).

site/index.html, site/ittybitz-recovery.html and SHA256SUMS.txt are the
rebuilt artifacts with re-pinned CSP hashes (npm run build).
@deanrie

deanrie commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Also available merged with every other review PR, in dependency order and verified together, as #57 — merge that or the individual PRs, not both.

@seQRets

seQRets commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Thank you, Dean, for all the work and support you've put into IttyBitz. This review was thorough and genuinely useful.

I'm closing this one without merging. I'm deeply committed to password security, and I'm keeping the core cryptography exactly as it is. I'm also determined to keep the file as small and tight as possible.

I love the changes you've contributed, and several of them are already live in v3.0.12: several files at once, the key-file fingerprint, the printable emergency card, refusing empty key files, keyboard tabs and the accessibility fixes, and the CI reproducibility check. They're credited in the release notes.

Please keep the suggestions coming!

@seQRets seQRets closed this Oct 9, 2026
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.

2 participants