Skip to content

jmwallet.cli.mnemonic

jmwallet.cli.mnemonic

Mnemonic generation, validation, encryption, and interactive input.

Functions

decrypt_mnemonic(encrypted_data: bytes, password: str) -> str

Decrypt a mnemonic with a password.

Args: encrypted_data: The encrypted bytes (salt + Fernet token) password: The password for decryption

Returns: The decrypted mnemonic phrase

Raises: ValueError: If decryption fails (wrong password or corrupted data)

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
def decrypt_mnemonic(encrypted_data: bytes, password: str) -> str:
    """
    Decrypt a mnemonic with a password.

    Args:
        encrypted_data: The encrypted bytes (salt + Fernet token)
        password: The password for decryption

    Returns:
        The decrypted mnemonic phrase

    Raises:
        ValueError: If decryption fails (wrong password or corrupted data)
    """
    import base64

    from cryptography.fernet import Fernet, InvalidToken
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

    if len(encrypted_data) < 16:
        raise ValueError("Invalid encrypted data")

    # Extract salt and encrypted token
    salt = encrypted_data[:16]
    encrypted_token = encrypted_data[16:]

    # Derive key from password
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=salt,
        iterations=600_000,
    )
    key = base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))

    # Decrypt
    fernet = Fernet(key)
    try:
        decrypted = fernet.decrypt(encrypted_token)
        return decrypted.decode("utf-8")
    except InvalidToken as e:
        raise ValueError("Decryption failed - wrong password or corrupted file") from e
    except UnicodeDecodeError as e:
        raise ValueError(
            "Decrypted content is not valid UTF-8. File may be corrupted or "
            "encrypted with a different tool"
        ) from e

encrypt_mnemonic(mnemonic: str, password: str) -> bytes

Encrypt a mnemonic with a password using Fernet (AES-128-CBC).

Uses PBKDF2 to derive a key from the password.

Args: mnemonic: The mnemonic phrase to encrypt password: The password for encryption

Returns: Encrypted bytes (base64-encoded internally by Fernet)

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def encrypt_mnemonic(mnemonic: str, password: str) -> bytes:
    """
    Encrypt a mnemonic with a password using Fernet (AES-128-CBC).

    Uses PBKDF2 to derive a key from the password.

    Args:
        mnemonic: The mnemonic phrase to encrypt
        password: The password for encryption

    Returns:
        Encrypted bytes (base64-encoded internally by Fernet)
    """
    import base64

    from cryptography.fernet import Fernet
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC

    # Generate a random salt
    salt = os.urandom(16)

    # Derive a key from password using PBKDF2
    kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=salt,
        iterations=600_000,  # High iteration count for security
    )
    key = base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))

    # Encrypt the mnemonic
    fernet = Fernet(key)
    encrypted = fernet.encrypt(mnemonic.encode("utf-8"))

    # Prepend salt to encrypted data
    return salt + encrypted

format_word_suggestions(matches: list[str], max_display: int = 8) -> str

Format word suggestions for display.

Args: matches: List of matching words max_display: Maximum number of words to display

Returns: Formatted suggestion string

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
359
360
361
362
363
364
365
366
367
368
369
370
371
372
def format_word_suggestions(matches: list[str], max_display: int = 8) -> str:
    """
    Format word suggestions for display.

    Args:
        matches: List of matching words
        max_display: Maximum number of words to display

    Returns:
        Formatted suggestion string
    """
    if len(matches) <= max_display:
        return ", ".join(matches)
    return ", ".join(matches[:max_display]) + f", ... (+{len(matches) - max_display} more)"

generate_mnemonic_secure(word_count: int = 24) -> str

Generate a BIP39 mnemonic from secure entropy.

Args: word_count: Number of words (12, 15, 18, 21, or 24)

Returns: BIP39 mnemonic phrase with valid checksum

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def generate_mnemonic_secure(word_count: int = 24) -> str:
    """
    Generate a BIP39 mnemonic from secure entropy.

    Args:
        word_count: Number of words (12, 15, 18, 21, or 24)

    Returns:
        BIP39 mnemonic phrase with valid checksum
    """
    from mnemonic import Mnemonic

    if word_count not in (12, 15, 18, 21, 24):
        raise ValueError("word_count must be 12, 15, 18, 21, or 24")

    # Calculate entropy bits: 12 words = 128 bits, 24 words = 256 bits
    # Formula: word_count * 11 = entropy_bits + checksum_bits
    # checksum_bits = entropy_bits / 32
    # So: word_count * 11 = entropy_bits * (1 + 1/32) = entropy_bits * 33/32
    # entropy_bits = word_count * 11 * 32 / 33
    entropy_bits = {12: 128, 15: 160, 18: 192, 21: 224, 24: 256}[word_count]

    m = Mnemonic("english")
    return m.generate(strength=entropy_bits)

get_bip39_wordlist() -> list[str]

Get the BIP39 English wordlist.

Returns: List of 2048 BIP39 words in order.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
331
332
333
334
335
336
337
338
339
340
341
def get_bip39_wordlist() -> list[str]:
    """
    Get the BIP39 English wordlist.

    Returns:
        List of 2048 BIP39 words in order.
    """
    from mnemonic import Mnemonic

    m = Mnemonic("english")
    return list(m.wordlist)

get_word_completions(prefix: str, wordlist: list[str]) -> list[str]

Get BIP39 words that start with the given prefix.

Args: prefix: The prefix to match (case-insensitive) wordlist: The BIP39 wordlist

Returns: List of matching words

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
344
345
346
347
348
349
350
351
352
353
354
355
356
def get_word_completions(prefix: str, wordlist: list[str]) -> list[str]:
    """
    Get BIP39 words that start with the given prefix.

    Args:
        prefix: The prefix to match (case-insensitive)
        wordlist: The BIP39 wordlist

    Returns:
        List of matching words
    """
    prefix_lower = prefix.lower()
    return [w for w in wordlist if w.startswith(prefix_lower)]

interactive_mnemonic_input(word_count: int = 24) -> str

Interactively input a BIP39 mnemonic with autocomplete support.

Features: - Real-time suggestions as you type (shows matches when <= 10) - Auto-completes when only one word matches (after 3+ chars typed) - Tab completion for partial matches - Supports pasting all words at once - Validates each word against BIP39 wordlist

Args: word_count: Expected number of words (12, 15, 18, 21, or 24)

Returns: The complete mnemonic phrase

Raises: typer.Exit: If user cancels input (Ctrl+C)

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
def interactive_mnemonic_input(word_count: int = 24) -> str:
    """
    Interactively input a BIP39 mnemonic with autocomplete support.

    Features:
    - Real-time suggestions as you type (shows matches when <= 10)
    - Auto-completes when only one word matches (after 3+ chars typed)
    - Tab completion for partial matches
    - Supports pasting all words at once
    - Validates each word against BIP39 wordlist

    Args:
        word_count: Expected number of words (12, 15, 18, 21, or 24)

    Returns:
        The complete mnemonic phrase

    Raises:
        typer.Exit: If user cancels input (Ctrl+C)
    """
    from rich.console import Console

    console = Console()
    wordlist = get_bip39_wordlist()
    words: list[str] = []

    # Check if we can use real-time input
    use_realtime = _supports_raw_terminal()

    # Fallback: set up readline completion if available
    has_readline = False
    if not use_realtime:
        try:
            import readline

            def completer(text: str, state: int) -> str | None:
                matches = get_word_completions(text, wordlist)
                if state < len(matches):
                    return matches[state]
                return None

            readline.set_completer(completer)
            readline.parse_and_bind("tab: complete")
            readline.set_completer_delims(" ")
            has_readline = True
        except ImportError:
            pass

    console.print("\n[bold]Enter your BIP39 mnemonic phrase[/bold]")
    if use_realtime:
        console.print(
            f"[dim]Expected: {word_count} words | Tab to complete | "
            f"Backspace to go back | Ctrl+C to cancel[/dim]"
        )
    else:
        console.print(
            f"[dim]Expected: {word_count} words | Tab to autocomplete | Ctrl+C to cancel[/dim]"
        )
    console.print(
        "[dim]Tip: You can paste all words at once (space, comma, or semicolon separated)[/dim]"
    )
    console.print()

    try:
        while len(words) < word_count:
            word_num = len(words) + 1
            prompt_text = f"Word {word_num}/{word_count}: "

            try:
                if use_realtime:
                    user_input = _interactive_word_input(prompt_text, wordlist)
                    if user_input is None:
                        # Go back to previous word if possible
                        if words:
                            removed = words.pop()
                            console.print(f"  [yellow]Removed: {removed}[/yellow]")
                        continue
                    user_input = user_input.strip().lower()
                elif has_readline:
                    user_input = input(prompt_text).strip().lower()
                else:
                    # For terminals without readline, use typer.prompt
                    user_input = (
                        typer.prompt(
                            f"Word {word_num}/{word_count}",
                            prompt_suffix=": ",
                            show_default=False,
                        )
                        .strip()
                        .lower()
                    )
            except EOFError:
                console.print("\n[red]Input cancelled[/red]")
                raise typer.Exit(1)

            if not user_input:
                continue

            # Normalize separators: support comma, semicolon, and space
            import re

            normalized_input = re.sub(r"[,;\s]+", " ", user_input).strip()

            # Check if user pasted multiple words at once
            input_parts = normalized_input.split()
            if len(input_parts) > 1:
                # Validate all pasted words
                all_valid = all(part in wordlist for part in input_parts)
                if all_valid:
                    remaining_slots = word_count - len(words)
                    if len(input_parts) <= remaining_slots:
                        for part in input_parts:
                            words.append(part)
                            console.print(f"  [green]{part}[/green]", highlight=False)
                        continue
                    else:
                        console.print(
                            f"  [red]Too many words: got {len(input_parts)}, "
                            f"only {remaining_slots} remaining[/red]"
                        )
                        continue
                else:
                    # Find which words are invalid
                    invalid_words = [part for part in input_parts if part not in wordlist]
                    console.print(f"  [red]Invalid BIP39 words: {', '.join(invalid_words)}[/red]")
                    continue

            # Check for exact match (single word)
            single_word = input_parts[0] if len(input_parts) == 1 else normalized_input
            if single_word in wordlist:
                words.append(single_word)
                # Only print confirmation if not using realtime (realtime already shows it)
                if not use_realtime:
                    console.print(f"  [green]{single_word}[/green]", highlight=False)
                continue

            # Check for prefix matches
            matches = get_word_completions(single_word, wordlist)

            if len(matches) == 0:
                console.print(f"  [red]'{single_word}' - no matching BIP39 word[/red]")
                continue
            elif len(matches) == 1:
                # Auto-complete unique match
                word = matches[0]
                words.append(word)
                if not use_realtime:
                    console.print(
                        f"  [green]{word}[/green] [dim](auto-completed from '{single_word}')[/dim]"
                    )
            else:
                # Show suggestions
                console.print(f"  [yellow]Matches: {format_word_suggestions(matches)}[/yellow]")
                console.print("  [dim]Type more characters to narrow down[/dim]")

    except KeyboardInterrupt:
        console.print("\n[red]Input cancelled[/red]")
        raise typer.Exit(1)
    finally:
        # Restore readline settings if we modified them
        if has_readline:
            try:
                import readline

                readline.set_completer(None)
            except ImportError:
                pass

    mnemonic = " ".join(words)

    # Validate the complete mnemonic
    console.print()
    if validate_mnemonic(mnemonic):
        console.print("[bold green]Mnemonic checksum valid![/bold green]")
    else:
        console.print("[bold red]WARNING: Mnemonic checksum INVALID![/bold red]")
        console.print(
            "[yellow]The words are valid BIP39 words but the checksum doesn't match.[/yellow]"
        )
        console.print("[yellow]This could mean a word was entered incorrectly.[/yellow]")
        if not typer.confirm("Continue anyway?", default=False):
            raise typer.Exit(1)

    return mnemonic

load_mnemonic_file(mnemonic_file: Path, password: str | None = None) -> str

Load a mnemonic from a file, decrypting if necessary.

Args: mnemonic_file: Path to the mnemonic file password: Password for decryption (required if file is encrypted)

Returns: The mnemonic phrase

Raises: ValueError: If file is encrypted but no password provided

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
def load_mnemonic_file(
    mnemonic_file: Path,
    password: str | None = None,
) -> str:
    """
    Load a mnemonic from a file, decrypting if necessary.

    Args:
        mnemonic_file: Path to the mnemonic file
        password: Password for decryption (required if file is encrypted)

    Returns:
        The mnemonic phrase

    Raises:
        ValueError: If file is encrypted but no password provided
    """
    if not mnemonic_file.exists():
        raise FileNotFoundError(f"Mnemonic file not found: {mnemonic_file}")

    data = mnemonic_file.read_bytes()

    # Try to detect if file is encrypted
    # Encrypted files start with 16-byte salt + Fernet token
    # Plaintext files are ASCII only
    try:
        text = data.decode("utf-8")
        # Check if it looks like a valid mnemonic (words separated by spaces)
        words = text.strip().split()
        if len(words) in (12, 15, 18, 21, 24) and all(w.isalpha() for w in words):
            return text.strip()
    except UnicodeDecodeError:
        pass

    # File appears to be encrypted
    if not password:
        raise ValueError(
            "Mnemonic file appears to be encrypted. "
            "Set wallet.mnemonic_password in config or use interactive prompt"
        )

    return decrypt_mnemonic(data, password)

load_mnemonic_meta(mnemonic_file: Path) -> dict[str, int]

Load wallet metadata from a companion .meta file.

Returns an empty dict if the file does not exist (backward-compatible with mnemonics created before this feature was added).

Args: mnemonic_file: Path to the mnemonic file.

Returns: Dict with metadata fields (currently only creation_height).

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
def load_mnemonic_meta(mnemonic_file: Path) -> dict[str, int]:
    """Load wallet metadata from a companion ``.meta`` file.

    Returns an empty dict if the file does not exist (backward-compatible
    with mnemonics created before this feature was added).

    Args:
        mnemonic_file: Path to the mnemonic file.

    Returns:
        Dict with metadata fields (currently only ``creation_height``).
    """
    import json

    path = _meta_path(mnemonic_file)
    if not path.exists():
        return {}

    try:
        data = json.loads(path.read_text())
        if isinstance(data, dict):
            return data
        logger.warning(f"Mnemonic metadata file has unexpected format: {path}")
        return {}
    except (json.JSONDecodeError, OSError) as exc:
        logger.warning(f"Failed to read mnemonic metadata from {path}: {exc}")
        return {}

prompt_password_with_confirmation(max_attempts: int = 3) -> str

Prompt for a password with confirmation, retrying on mismatch.

Args: max_attempts: Maximum number of attempts before giving up

Returns: The confirmed password

Raises: typer.Exit: If passwords don't match after max_attempts

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
def prompt_password_with_confirmation(max_attempts: int = 3) -> str:
    """
    Prompt for a password with confirmation, retrying on mismatch.

    Args:
        max_attempts: Maximum number of attempts before giving up

    Returns:
        The confirmed password

    Raises:
        typer.Exit: If passwords don't match after max_attempts
    """
    for attempt in range(max_attempts):
        password = typer.prompt("Enter encryption password", default="", hide_input=True)
        if not password:
            typer.echo("WARNING: Empty password means the mnemonic will be stored in PLAINTEXT.")
            if not typer.confirm("Continue without encryption?", default=False):
                continue
            return password
        confirm = typer.prompt("Confirm password", hide_input=True)
        if password == confirm:
            return password
        remaining = max_attempts - attempt - 1
        if remaining > 0:
            typer.echo(f"Passwords do not match. {remaining} attempt(s) remaining.")
        else:
            logger.error("Passwords do not match after maximum attempts")
            raise typer.Exit(1)
    # Should not reach here, but satisfy type checker
    raise typer.Exit(1)

save_mnemonic_file(mnemonic: str, output_file: Path, password: str | None = None) -> None

Save a mnemonic to a file, optionally encrypted.

Args: mnemonic: The mnemonic phrase to save output_file: The output file path password: Optional password for encryption

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def save_mnemonic_file(
    mnemonic: str,
    output_file: Path,
    password: str | None = None,
) -> None:
    """
    Save a mnemonic to a file, optionally encrypted.

    Args:
        mnemonic: The mnemonic phrase to save
        output_file: The output file path
        password: Optional password for encryption
    """
    output_file.parent.mkdir(parents=True, exist_ok=True)

    if password:
        encrypted = encrypt_mnemonic(mnemonic, password)
        output_file.write_bytes(encrypted)
        os.chmod(output_file, 0o600)
        logger.info(f"Encrypted mnemonic saved to {output_file}")
    else:
        output_file.write_text(mnemonic)
        os.chmod(output_file, 0o600)
        logger.warning(f"Mnemonic saved to {output_file} (PLAINTEXT - consider using --password)")

save_mnemonic_meta(mnemonic_file: Path, *, creation_height: int | None = None) -> None

Persist wallet metadata alongside a mnemonic file.

The metadata is stored as a small JSON file (<mnemonic_file>.meta) next to the mnemonic file. Currently only creation_height is stored, but the format is extensible.

Args: mnemonic_file: Path to the mnemonic file (the .meta suffix is added). creation_height: Block height at the time the wallet was created.

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def save_mnemonic_meta(
    mnemonic_file: Path,
    *,
    creation_height: int | None = None,
) -> None:
    """Persist wallet metadata alongside a mnemonic file.

    The metadata is stored as a small JSON file (``<mnemonic_file>.meta``)
    next to the mnemonic file.  Currently only ``creation_height`` is
    stored, but the format is extensible.

    Args:
        mnemonic_file: Path to the mnemonic file (the .meta suffix is added).
        creation_height: Block height at the time the wallet was created.
    """
    import json

    meta: dict[str, int] = {}
    if creation_height is not None:
        meta["creation_height"] = creation_height

    if not meta:
        return  # Nothing to persist

    path = _meta_path(mnemonic_file)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(meta, indent=2) + "\n")
    os.chmod(path, 0o600)
    logger.debug(f"Saved mnemonic metadata to {path}")

validate_mnemonic(mnemonic: str) -> bool

Validate a BIP39 mnemonic phrase.

Args: mnemonic: The mnemonic phrase to validate

Returns: True if valid, False otherwise

Source code in jmwallet/src/jmwallet/cli/mnemonic.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def validate_mnemonic(mnemonic: str) -> bool:
    """
    Validate a BIP39 mnemonic phrase.

    Args:
        mnemonic: The mnemonic phrase to validate

    Returns:
        True if valid, False otherwise
    """
    from mnemonic import Mnemonic

    m = Mnemonic("english")
    return m.check(mnemonic)