Skip to content

Fix v3 keystore migration for legacy empty scrypt salt#541

Merged
0xh3rman merged 1 commit into
mainfrom
empty-salt-fix
Jun 19, 2026
Merged

Fix v3 keystore migration for legacy empty scrypt salt#541
0xh3rman merged 1 commit into
mainfrom
empty-salt-fix

Conversation

@0xh3rman

@0xh3rman 0xh3rman commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

The oldest wallets (created by WalletCore ~2024-11) have an empty scrypt salt ("salt": "") in their v3 keystore file. The v4 migrator's v3 reader rejected any salt shorter than MIN_SALT_LEN (16) during JSON deserialization — before any decryption — with CorruptFile("invalid v3 hex length"). Migration runs on every launch and fails identically every time, so the v4 file is never written and the wallet's secret phrase becomes unviewable (e.g. "Not Found" on iOS).

The seed is intact — scrypt accepts an empty salt and the original app read these files fine. This is over-strict validation, not data loss or password loss. Confirmed against a real affected file (structurally intact: 16-byte iv, 32-byte mac, only the salt zeroed).

Fix (core)

Relax the v3 reader to accept any salt length up to MAX_SALT_LEN (lower bound → 0), keeping the upper bound. Correctness is still gated by:

  • the existing Keccak256 MAC (wrong password / tampering → AuthenticationFailed, before any plaintext is returned), and
  • migrate_v3's binding verification (the decrypted secret must derive the wallet id, else the staged v4 file is deleted and the v3 file preserved).

v4 always writes a fresh 16-byte Argon2id salt on a separate path, so going-forward security is unchanged. A scoped audit confirmed the salt floor is the only over-rejection for the Gem population (n/r/p/dklen/kdf/cipher/version all match production exactly).

Recovery: shipping this is the recovery — the installed app re-runs migration on launch (holding the keychain password) and completes it. Conditional only on the keychain password still being present (strong evidence it is: the failure is at salt parsing, not decryption).

Also included (cause-agnostic safety nets — help any keystore-read failure)

  • Android: the reveal screen surfaces a keystore failure as an explicit error instead of a silent blank phrase (WalletSecretDataValue.isError); cancellation is rethrown (no false isError on coroutine cancellation).
  • iOS: inaccessible-keys errors show actionable guidance instead of "keystore password is missing". getOrCreatePassword keeps its existing fail-closed behavior (thrown keychain errors propagate; never wipes the password).

Tests

  • Core unit: empty/short-salt v3 round-trip; wrong password still fails closed; oversized salt still rejected.
  • End-to-end migration of an empty-salt keystore through the full path (parse → decrypt → binding-verify → v4 write → key recovered):
    • Rust: gemstone migrate_v3_empty_salt_mnemonic_round_trip
    • iOS: MigrateV3KeystoreTests.migrateV3WithEmptyScryptSalt
    • Android (instrumented): MigrateV3KeystoreServiceTest.migrateV3WithEmptyScryptSalt_createsV4AndRecoversKey
  • Android unit: GetWalletSecretDataImplTest (error surfacing + cancellation).

Verified: cargo test -p gem_keystore --features v3 (27), cargo test -p gemstone migrate_v3 (6), clippy -D warnings clean; iOS KeystoreTests + MigrateV3KeystoreTests; Android unit + instrumented (emulator). The same empty-salt issue affects both platforms (shared WalletCore origin + shared core reader) and the fix covers both.

Legacy WalletCore keystores (pre-2025) sometimes wrote an empty scrypt salt. The v4 migrator's v3 reader rejected salts shorter than 16 bytes during deserialization -- before any decryption -- so migration failed on every launch and the wallet's secret became unviewable. The seed is intact. Relax the v3 reader to accept any salt length up to the maximum; the MAC and the migrate_v3 binding verification still gate correctness, and v4 always writes a fresh 16-byte Argon2 salt.

Also (cause-agnostic): surface keystore-read failures instead of a silent blank phrase (Android) and as an actionable message (iOS), and rethrow CancellationException in the Android secret-data flow.

Tests: empty-salt v3 reader round-trip (+ wrong-password/oversized negatives) and iOS/Android end-to-end migration of an empty-salt keystore.
@0xh3rman 0xh3rman merged commit 377940e into main Jun 19, 2026
6 checks passed
@0xh3rman 0xh3rman deleted the empty-salt-fix branch June 19, 2026 04:13
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