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
280
281
282
283
284
285
286
287
288
289
290
291
292
293
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
252
253
254
255
256
257
258
259
260
261
262
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
265
266
267
268
269
270
271
272
273
274
275
276
277
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
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
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
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 | Auto-completes | 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[/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:
                        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

            # Check if user pasted multiple words at once
            input_parts = user_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)
            if user_input in wordlist:
                words.append(user_input)
                # Only print confirmation if not using realtime (realtime already shows it)
                if not use_realtime:
                    console.print(f"  [green]{user_input}[/green]", highlight=False)
                continue

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

            if len(matches) == 0:
                console.print(f"  [red]'{user_input}' - 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 '{user_input}')[/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
203
204
205
206
207
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
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)

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
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", hide_input=True)
        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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
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)")

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)