Skip to content

Harden checkphrase scheme: Argon2id, address normalization, configurable word count - #18

Open
illuzen wants to merge 1 commit into
mainfrom
illuzen/hardening
Open

illuzen wants to merge 1 commit into
mainfrom
illuzen/hardening

Conversation

@illuzen

@illuzen illuzen commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR upgrades the checkphrase construction from PBKDF2-HMAC-SHA256 to a
memory-hard KDF and fixes several structural weaknesses, across all four
implementations (Rust, JavaScript, Dart, Python reference).

This is a breaking change: every checkphrase changes. The scheme is
versioned as human-checkphrase-v2 via the salt.

Argon2id replaces PBKDF2 (the security fix)

PBKDF2 iterations impose only compute cost, and SHA-256 is the most
hardware-accelerated function in existence: a consumer GPU computes
~250,000 PBKDF2-40k guesses/second (~3,500x faster than the ~14 ms a wallet
pays per phrase), and mining ASICs widen the gap by several more orders of
magnitude. The old README's "GPU = 10x CPU" estimate significantly understated
this.

Argon2id (RFC 9106) with 64 MiB memory, 3 passes, parallelism 1 bounds
attacker throughput by memory bandwidth instead of core count, collapsing the
attacker/defender gap to roughly two orders of magnitude. Defender cost stays
UI-friendly at ~50–150 ms per phrase. Parallelism is fixed at 1 because
Argon2's output depends on it; this keeps all ports byte-identical.

  • Rust: RustCrypto argon2 crate
  • JavaScript: hash-wasm (browser-compatible; addressToChecksum is now
    async
    )
  • Dart: pointycastle Argon2BytesGenerator
  • Python: argon2-cffi (new shared reference module scripts/checkphrase.py)

Address normalization

Addresses are canonicalized before hashing: surrounding whitespace is
stripped, and 0x-prefixed hex addresses are lowercased so EIP-55 checksum
casing cannot produce two different phrases for the same Ethereum address.
Base58/bech32/SS58 are case-sensitive and pass through unchanged; callers
must supply canonical forms (e.g. lowercase bech32). A test vector pins the
fact that lowercased base58 is deliberately a different address.

Versioned, domain-separable salt

The salt is now the scheme version string human-checkphrase-v2, optionally
extended with a caller-supplied context: human-checkphrase-v2|<context>
(e.g. eip155:1). The version enables clean future migrations; the context
lets deployments domain-separate phrases per chain. Context is optional so
uncoordinated wallets still agree by default.

Configurable word count

Word count is now a parameter (3–11, default 5). Five words (55 bits) remain
appropriate for verification; 8+ words are recommended if phrases are ever
used as lookup identifiers, where multi-target collision attacks apply. The
floor comes from Argon2's 4-byte minimum output, the ceiling from the u128
bit-slicing in the Rust port.

  • Rust: address_to_checksum_with_options(address, word_list, word_count, context)
    (existing address_to_checksum keeps its signature and defaults)
  • JS: addressToChecksum(address, wordList, { wordCount, context })
  • Dart: addressToChecksum(address, wordCount: ..., context: ...)

Other changes

  • Test vectors regenerated (test-vectors/checksums.json): 149 cases
    including normalization variants, word counts 3/4/6/8/11, and context
    vectors. The suite shrank from 1171 cases because full 2048-word coverage
    at ~100 ms+ per Argon2 hash would make test runs (especially pure-Dart,
    ~500 ms/hash) take 10+ minutes; wordlist consistency across packages is
    already enforced by scripts/sync_wordlists.py --check in run_tests.sh.
    Vectors may carry optional wordCount/context fields; implementations
    default to 5 words / no context when absent.
  • README rewritten: new example phrases, a precise spec of the v2 scheme,
    and an honest security analysis (verification vs. lookup threat models,
    multi-target attack costs, realistic GPU throughput numbers).
  • Rust cleanup: dropped now-unused hex, hmac, pbkdf2, and reqwest
    dependencies (reqwest was unused before this PR too); bench now compares
    Argon2id against bare SHA-256.
  • scripts/words.py demo now delegates to the shared reference
    implementation instead of duplicating the (old) scheme.
  • .gitignore: added .venv and __pycache__.

Test plan

  • ./run_tests.sh passes: wordlist sync check, Rust (7 tests), JavaScript
    (14 tests), Dart (7 tests), all validating the same 149 shared vectors
  • Cross-implementation parity: identical phrases from Rust, JS, Dart, and
    Python for all vectors, including normalization/word-count/context cases
  • Normalization: EIP-55, 0X-uppercase, and whitespace variants of the
    same Ethereum address produce identical phrases; lowercased base58
    produces a different phrase
  • Invalid word counts (<3, >11) rejected in all implementations
  • Review Argon2 parameters (64 MiB) for acceptability on low-end mobile
    targets; drop to 32 MiB if needed (breaking, so decide before release)
  • Bump package versions and coordinate the v1 -> v2 migration for any
    existing consumers (all phrases change)

Note

High Risk
This is a breaking cryptographic migration: all phrases change, JS consumers must adopt async addressToChecksum, and wallet integrators must align on v2 parameters, normalization, and optional context or users will see mismatched phrases.

Overview
Breaking: every checkphrase changes. The scheme is versioned as human-checkphrase-v2 (salt) and replaces PBKDF2-HMAC-SHA256 with Argon2id (64 MiB, t=3, p=1) across Rust, JavaScript, Dart, and a new Python reference (scripts/checkphrase.py).

Before hashing, addresses are normalized (trim; lowercase 0x hex for EIP-55 consistency). Phrases can use 3–11 words (default 5) and optional domain context (human-checkphrase-v2|<context>), exposed via address_to_checksum_with_options / JS ChecksumOptions / Dart named params. JavaScript addressToChecksum is now async (hash-wasm).

Shared test vectors are regenerated for v2 (smaller suite due to Argon2 cost) with normalization, word-count, and context cases. Docs and benches are updated; Rust drops PBKDF2/reqwest and related deps.

Reviewed by Cursor Bugbot for commit 5a51238. Configure here.

…ble word count (v2)

Replace PBKDF2-HMAC-SHA256 with memory-hard Argon2id (64 MiB, t=3, p=1) so
GPU/ASIC farms cannot grind phrase collisions orders of magnitude faster than
defenders. Normalize addresses before hashing (trim whitespace, lowercase
0x-hex) so EIP-55 casing cannot change a phrase. Version the salt
(human-checkphrase-v2) with optional domain-separation context, and make word
count configurable (3-11, default 5). Applied across Rust, JS (now async via
hash-wasm), Dart, and the Python reference; test vectors regenerated with
normalization, word-count, and context cases.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5a51238. Configure here.

Comment thread dart/pubspec.yaml
@@ -1,5 +1,5 @@
name: human_checksum
description: A tool to generate human-readable checksums from cryptocurrency addresses using the BIP-39 word list and PBKDF2
description: A tool to generate human-readable checksums from cryptocurrency addresses using the BIP-39 word list and Argon2id

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused crypto dependency remains

Low Severity

The Dart implementation no longer imports or uses package:crypto after switching from PBKDF2 to Pointycastle Argon2id, but crypto: ^3.0.3 is still listed under direct dependencies in pubspec.yaml.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5a51238. Configure here.

Comment thread js/src/index.ts
throw new RangeError(
`wordCount must be between ${MIN_WORD_COUNT} and ${MAX_WORD_COUNT}, got ${wordCount}`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-integer wordCount accepted

Medium Severity

addressToChecksum only bounds-checks wordCount with numeric comparisons and never requires an integer. Values such as NaN pass validation, and fractional counts can yield a phrase length that does not match the configured wordCount, unlike the Rust, Dart, and Python APIs that only accept integer word counts.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5a51238. Configure here.

@n13

n13 commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Yeah not sure on that, would like to see if we can put the previous on the keystone HW wallet

If we need 64MB memory say goodbye to HW wallet support

But I haven't tried the previous version on HW either - I need to run some experiments, previous vs now

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