From ce1235d85f520b4e2f5b964752cd6212dcb2196c Mon Sep 17 00:00:00 2001 From: Manan Tyagi Date: Wed, 1 Jul 2026 10:10:41 +0530 Subject: [PATCH 1/3] Add Vault-style passphrase generation for record-add and record-update via :passphrase --- .../src/keepercli/commands/record_edit.py | 63 +++- keepersdk-package/src/keepersdk/generator.py | 316 +++++++++++++++++- .../unit_tests/test_passphrase_generator.py | 134 ++++++++ 3 files changed, 501 insertions(+), 12 deletions(-) create mode 100644 keepersdk-package/unit_tests/test_passphrase_generator.py diff --git a/keepercli-package/src/keepercli/commands/record_edit.py b/keepercli-package/src/keepercli/commands/record_edit.py index 156eebaa..2010e73b 100644 --- a/keepercli-package/src/keepercli/commands/record_edit.py +++ b/keepercli-package/src/keepercli/commands/record_edit.py @@ -6,7 +6,7 @@ import itertools import json import os -from typing import Iterable, Optional, List, Any, Sequence, Union, Dict +from typing import Iterable, Optional, List, Any, Sequence, Union, Dict, Tuple from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 @@ -118,8 +118,9 @@ class ParsedFieldValue: Value Field type Description Example ==================== =============== =================== ============== $GEN:[alg],[n] password Generates a random password $GEN:dice,5 - Default algorith is rand alg: [rand | dice | crypto] + Default algorith is rand alg: [rand | dice | crypto | passphrase] Optional: password length + passphrase: $GEN:passphrase[,word_count][,separator][,capitalize][,number] $GEN oneTimeCode Generates TOTP URL $GEN:[alg,][enc] keyPair Generates a key pair and $GEN:ec,enc optional passcode alg: [rsa | ec | ed25519], enc @@ -183,8 +184,13 @@ def assign_legacy_fields(self, record: vault_record.PasswordRecord, fields: List if parsed_field.type == 'login': record.login = parsed_field.value elif parsed_field.type == 'password': + action_params.clear() if self.is_generate_value(parsed_field.value, action_params): - record.password = self.generate_password(action_params) + password, gen_error = self.generate_password(action_params) + if gen_error: + self.on_warning(gen_error) + elif password is not None: + record.password = password else: record.password = parsed_field.value elif parsed_field.type == 'url': @@ -260,22 +266,49 @@ def generate_key_pair(key_type: str, passphrase: str) -> Dict: } @staticmethod - def generate_password(parameters: Optional[Sequence[str]]=None) -> str: + def generate_password(parameters: Optional[Sequence[str]] = None, + policy: Optional[dict] = None) -> Tuple[Optional[str], Optional[str]]: + algorithm, error = generator.resolve_gen_password_algorithm( + parameters if isinstance(parameters, (tuple, list, set)) else None) + if error: + return None, error + + length = None if isinstance(parameters, (tuple, list, set)): - algorithm = next((x for x in parameters if x in ('rand', 'dice', 'crypto')), 'rand') length = next((x for x in parameters if x.isnumeric()), None) if isinstance(length, str) and len(length) > 0: try: length = int(length) except ValueError: pass - else: - algorithm = 'rand' - length = None gen: generator.PasswordGenerator if algorithm == 'crypto': gen = generator.CryptoPassphraseGenerator() + elif algorithm == 'passphrase': + pp_opts, pp_error = generator.parse_passphrase_gen_parameters(parameters) + if pp_error: + return None, pp_error + if policy and policy.get('passphrase-allow') is False: + logger.warning( + 'Passphrase generation is disabled by enterprise policy; using random password.') + fallback_length = pp_opts.word_count or length + if isinstance(fallback_length, int): + if fallback_length < 4: + fallback_length = 4 + elif fallback_length > 200: + fallback_length = 200 + else: + fallback_length = 20 + gen = generator.KeeperPasswordGenerator(length=fallback_length) + else: + gen = generator.KeeperPassphraseGenerator.create_with_options( + policy, + word_count=pp_opts.word_count if pp_opts.word_count is not None else length, + separator=pp_opts.separator, + capitalize=pp_opts.capitalize, + append_number=pp_opts.append_number, + ) elif algorithm == 'dice': if isinstance(length, int): if length < 1: @@ -294,7 +327,7 @@ def generate_password(parameters: Optional[Sequence[str]]=None) -> str: else: length = 20 gen = generator.KeeperPasswordGenerator(length=length) - return gen.generate() + return gen.generate(), None @staticmethod def generate_totp_url() -> str: @@ -456,12 +489,20 @@ def assign_typed_fields(self, record: vault_record.TypedRecord, fields: List[Par value: Any = None if self.is_generate_value(parsed_field.value, action_params): if record_field.type == 'password': - value = self.generate_password(action_params) + value, gen_error = self.generate_password(action_params) + if gen_error: + self.on_warning(gen_error) + value = None elif record_field.type in ('oneTimeCode', 'otp'): value = self.generate_totp_url() elif record_field.type in ('keyPair', 'privateKey'): should_encrypt = 'enc' in action_params - passphrase = self.generate_password() if should_encrypt else '' + passphrase = '' + if should_encrypt: + passphrase, gen_error = self.generate_password() + if gen_error: + self.on_warning(gen_error) + continue key_type = next((x for x in action_params if x in ('rsa', 'ec', 'ed25519')), 'rsa') value = self.generate_key_pair(key_type, passphrase) if passphrase: diff --git a/keepersdk-package/src/keepersdk/generator.py b/keepersdk-package/src/keepersdk/generator.py index a9d94bc5..3e05fd1f 100644 --- a/keepersdk-package/src/keepersdk/generator.py +++ b/keepersdk-package/src/keepersdk/generator.py @@ -1,15 +1,242 @@ import abc +import difflib import hashlib import logging import os import secrets import string -from typing import Optional, List, Any, Iterator +from collections import namedtuple +from typing import Optional, List, Any, Iterator, Sequence, Tuple from . import crypto DEFAULT_PASSWORD_LENGTH = 32 PW_SPECIAL_CHARACTERS = '!@#$%()+;<>=?[]{}^.,' +PP_SEPARATOR_CHARACTERS = '-._?! ' +DEFAULT_PASSPHRASE_SEPARATOR = '-' +DEFAULT_PASSPHRASE_WORD_COUNT = 5 +MIN_PASSPHRASE_WORD_COUNT = 5 +MAX_PASSPHRASE_WORD_COUNT = 9 +DEFAULT_PASSPHRASE_CAPITALIZE = True +DEFAULT_PASSPHRASE_NUMBER = True +GEN_PASSWORD_ALGORITHMS = ('rand', 'dice', 'crypto', 'passphrase') +DEFAULT_DICEWARE_WORDLIST = 'diceware.wordlist.asc.txt' +PASSPHRASE_SEPARATOR_HELP = '- . _ ? ! space' + +PassphraseGenOptions = namedtuple( + 'PassphraseGenOptions', ('word_count', 'separator', 'capitalize', 'append_number')) +PassphraseGenOptions.__doc__ = ( + 'Parsed optional parameters for $GEN:passphrase. ' + 'None fields use Vault/CLI defaults when building a generator.' +) + + +def clamp_passphrase_word_count(word_count: Optional[int]) -> int: + """Clamp passphrase word count to the Vault range (5-9 words).""" + if not isinstance(word_count, int): + return DEFAULT_PASSPHRASE_WORD_COUNT + original = word_count + if word_count < MIN_PASSPHRASE_WORD_COUNT: + word_count = MIN_PASSPHRASE_WORD_COUNT + elif word_count > MAX_PASSPHRASE_WORD_COUNT: + word_count = MAX_PASSPHRASE_WORD_COUNT + if word_count != original: + logging.warning( + 'Passphrase word count must be between %d and %d; using %d.', + MIN_PASSPHRASE_WORD_COUNT, MAX_PASSPHRASE_WORD_COUNT, word_count) + return word_count + + +def format_passphrase_separators_for_display(separators: Optional[str] = None) -> str: + """Human-readable list of allowed passphrase separator characters.""" + if not separators: + separators = PP_SEPARATOR_CHARACTERS + parts: List[str] = [] + for ch in separators: + parts.append('space' if ch == ' ' else ch) + return ', '.join(parts) + + +def _normalize_passphrase_separator(separator: Optional[str]) -> str: + """Normalize a separator string to a single allowed character.""" + if not separator: + return DEFAULT_PASSPHRASE_SEPARATOR + if separator == '\u2423': # OPEN BOX (Vault UI glyph for space) + return ' ' + return separator[0] + + +def _passphrase_separators_from_policy(policy_sep: str) -> str: + """Return allowed separators in Vault order (see getPasswordRules.ts).""" + normalized = policy_sep.replace('\u2423', ' ') + allowed = '' + for ch in PP_SEPARATOR_CHARACTERS: + if ch in normalized: + allowed += ch + return allowed + + +def _default_passphrase_separator_from_policy(policy_sep: Optional[str]) -> str: + """Pick the default generation separator matching Vault / PowerCommander.""" + if not policy_sep or not isinstance(policy_sep, str) or not policy_sep.strip(): + return DEFAULT_PASSPHRASE_SEPARATOR + allowed = _passphrase_separators_from_policy(policy_sep.strip()) + return allowed[0] if allowed else DEFAULT_PASSPHRASE_SEPARATOR + + +def resolve_gen_password_algorithm( + parameters: Optional[Sequence[str]]) -> Tuple[Optional[str], Optional[str]]: + """Resolve $GEN password algorithm; return (algorithm, error_message).""" + if not parameters: + return 'rand', None + first = parameters[0].strip() + first_lower = first.lower() + if first_lower in GEN_PASSWORD_ALGORITHMS: + return first_lower, None + if first.isdigit(): + return 'rand', None + suggestions = difflib.get_close_matches(first_lower, GEN_PASSWORD_ALGORITHMS, n=1, cutoff=0.6) + message = f'Unknown $GEN password algorithm "{first}".' + if suggestions: + message += f' Did you mean "{suggestions[0]}"?' + message += f' Valid algorithms: {", ".join(GEN_PASSWORD_ALGORITHMS)}.' + return None, message + + +def _is_strict_gen_bool_token(value: str) -> bool: + """Return True if value is exactly 'true' or 'false' (case-insensitive).""" + return value.strip().lower() in ('true', 'false') + + +def _parse_gen_bool_strict(value: str, param_name: str) -> Tuple[Optional[bool], Optional[str]]: + """Parse a strict true/false token for $GEN:passphrase; return (value, error).""" + normalized = value.strip().lower() + if normalized == 'true': + return True, None + if normalized == 'false': + return False, None + return None, ( + f'Invalid $GEN:passphrase {param_name} parameter "{value}". ' + f'Expected true or false.') + + +def _is_passphrase_separator_token(token: str) -> bool: + """Return True if token is a valid passphrase separator or 'space'/'sp' alias.""" + if token.lower() in ('space', 'sp'): + return True + return len(token) == 1 and token in PP_SEPARATOR_CHARACTERS + + +def _parse_passphrase_separator_token(token: str) -> Tuple[Optional[str], Optional[str]]: + """Parse a separator token; return (separator_char, error_message).""" + if token.lower() in ('space', 'sp'): + return ' ', None + if len(token) == 1 and token in PP_SEPARATOR_CHARACTERS: + return token, None + return None, ( + f'Invalid passphrase separator "{token}". ' + f'Allowed: {format_passphrase_separators_for_display(PP_SEPARATOR_CHARACTERS)}.') + + +def parse_passphrase_gen_parameters( + parameters: Optional[Sequence[str]]) -> Tuple[PassphraseGenOptions, Optional[str]]: + """Parse $GEN:passphrase optional parameters. + + Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number] + word_count must be between 5 and 9 (Vault range). + """ + empty = PassphraseGenOptions(None, None, None, None) + if not parameters: + return empty, None + + tokens = [p if isinstance(p, str) else str(p) for p in parameters] + if not tokens or tokens[0].strip().lower() != 'passphrase': + return empty, None + + extras = tokens[1:] + if any(t.strip() == '' for t in extras): + return empty, ( + 'Incomplete $GEN:passphrase parameters: missing value after comma. ' + 'Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number]') + + word_count = None + separator = None + capitalize = None + append_number = None + idx = 0 + + if idx < len(extras): + token = extras[idx].strip() + if token.isdigit(): + word_count = int(token) + if word_count < MIN_PASSPHRASE_WORD_COUNT or word_count > MAX_PASSPHRASE_WORD_COUNT: + return empty, ( + f'Passphrase word count must be between {MIN_PASSPHRASE_WORD_COUNT} ' + f'and {MAX_PASSPHRASE_WORD_COUNT} (got {word_count}).') + idx += 1 + elif not _is_passphrase_separator_token(token) and not _is_strict_gen_bool_token(token): + return empty, ( + f'Invalid passphrase word count "{token}". ' + f'Expected an integer between {MIN_PASSPHRASE_WORD_COUNT} ' + f'and {MAX_PASSPHRASE_WORD_COUNT}.') + + if idx < len(extras) and not _is_strict_gen_bool_token(extras[idx].strip()): + separator, sep_error = _parse_passphrase_separator_token(extras[idx].strip()) + if sep_error: + return empty, sep_error + idx += 1 + + if idx < len(extras): + capitalize, cap_error = _parse_gen_bool_strict(extras[idx].strip(), 'capitalize') + if cap_error: + return empty, cap_error + idx += 1 + + if idx < len(extras): + append_number, num_error = _parse_gen_bool_strict(extras[idx].strip(), 'number') + if num_error: + return empty, num_error + idx += 1 + + if idx < len(extras): + return empty, f'Unexpected $GEN:passphrase parameter "{extras[idx].strip()}".' + + return PassphraseGenOptions(word_count, separator, capitalize, append_number), None + + +def _resolve_wordlist_path(word_list_file: Optional[str] = None) -> str: + """Resolve bundled or user-supplied diceware word list path.""" + if word_list_file: + dice_path = os.path.join(os.path.dirname(__file__), 'resources', word_list_file) + if not os.path.isfile(dice_path): + dice_path = os.path.expanduser(word_list_file) + else: + dice_path = os.path.join(os.path.dirname(__file__), 'resources', DEFAULT_DICEWARE_WORDLIST) + return dice_path + + +def _load_wordlist(word_list_file: Optional[str] = None) -> List[str]: + """Load and validate the diceware word list from disk.""" + dice_path = _resolve_wordlist_path(word_list_file) + if not os.path.isfile(dice_path): + raise Exception(f'Word list file \"{dice_path}\" not found.') + + vocabulary: List[str] = [] + unique_words = set() + with open(dice_path, 'r', encoding='utf-8') as dw: + for line in dw: + line = line.strip() + if not line or line.startswith('--'): + continue + if line.lower().startswith('source url:') or line.lower().startswith('title:'): + continue + parts = line.split() + word = parts[1] if len(parts) >= 2 else parts[0] + vocabulary.append(word) + unique_words.add(word.lower()) + if len(vocabulary) != len(unique_words): + raise Exception(f'Word list file \"{dice_path}\" contains non-unique words.') + return vocabulary class PasswordGenerator(abc.ABC): @@ -170,3 +397,90 @@ def generate(self): words.reverse() return ' '.join((self._vocabulary[x] for x in words)) + + +class KeeperPassphraseGenerator(PasswordGenerator): + """Vault-style passphrase generator using the bundled diceware word list. + + Produces ordered word passphrases (not shuffled) matching Keeper Vault behavior: + configurable word count (5-9), separator, first-word capitalization, and + optional digit appended to the first word. + + Use :meth:`create_with_options` or :meth:`create_from_policy` to apply + enterprise passphrase policy defaults with optional CLI/$GEN overrides. + """ + + def __init__(self, word_count: int = DEFAULT_PASSPHRASE_WORD_COUNT, + separator: str = DEFAULT_PASSPHRASE_SEPARATOR, + capitalize: bool = DEFAULT_PASSPHRASE_CAPITALIZE, + append_number: bool = DEFAULT_PASSPHRASE_NUMBER, + word_list_file: Optional[str] = None) -> None: + """Initialize a Vault-style passphrase generator with the given options.""" + self.word_count = clamp_passphrase_word_count( + word_count if isinstance(word_count, int) else DEFAULT_PASSPHRASE_WORD_COUNT) + self.separator = _normalize_passphrase_separator(separator) + self.capitalize = capitalize + self.append_number = append_number + self._vocabulary = _load_wordlist(word_list_file) + + def generate(self) -> str: + """Generate a passphrase using the configured word count, separator, and formatting.""" + if not self._vocabulary: + raise Exception('Passphrase word list was not loaded') + + passphrase = '' + first_word = True + for _ in range(self.word_count): + word = secrets.choice(self._vocabulary) + if self.capitalize and word: + word = word[0].upper() + word[1:] + if self.append_number and first_word: + word += str(secrets.randbelow(10)) + if not first_word: + passphrase += self.separator + passphrase += word + first_word = False + return passphrase + + @classmethod + def create_with_options(cls, policy: Optional[dict] = None, word_count: Optional[int] = None, + separator: Optional[str] = None, capitalize: Optional[bool] = None, + append_number: Optional[bool] = None) -> 'KeeperPassphraseGenerator': + """Build a generator from CLI/$GEN overrides with optional policy defaults.""" + wc = word_count + if wc is None: + if policy: + wc = policy.get('passphrase-length', DEFAULT_PASSPHRASE_WORD_COUNT) + else: + wc = DEFAULT_PASSPHRASE_WORD_COUNT + + sep = separator + if sep is None: + if policy: + policy_sep = policy.get('passphrase-separator') + sep = _default_passphrase_separator_from_policy( + policy_sep if isinstance(policy_sep, str) else None) + else: + sep = DEFAULT_PASSPHRASE_SEPARATOR + + cap = capitalize + if cap is None: + cap = DEFAULT_PASSPHRASE_CAPITALIZE + + num = append_number + if num is None: + num = DEFAULT_PASSPHRASE_NUMBER + + return cls( + word_count=clamp_passphrase_word_count(wc) if isinstance(wc, int) else wc, + separator=sep, capitalize=cap, append_number=num) + + @classmethod + def create_from_policy(cls, policy: dict, length_override: Optional[int] = None, + separator_override: Optional[str] = None) -> 'KeeperPassphraseGenerator': + """Build a generator using enterprise passphrase policy defaults.""" + return cls.create_with_options( + policy, + word_count=length_override, + separator=separator_override, + ) diff --git a/keepersdk-package/unit_tests/test_passphrase_generator.py b/keepersdk-package/unit_tests/test_passphrase_generator.py new file mode 100644 index 00000000..60032e6c --- /dev/null +++ b/keepersdk-package/unit_tests/test_passphrase_generator.py @@ -0,0 +1,134 @@ +from unittest import TestCase, mock + +from keepersdk import generator + + +class TestKeeperPassphraseGenerator(TestCase): + + def test_default_generates_five_hyphen_separated_words(self): + gen = generator.KeeperPassphraseGenerator() + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']): + with mock.patch('secrets.randbelow', return_value=3): + result = gen.generate() + self.assertEqual(result, 'Alpha3-Bravo-Charlie-Delta-Echo') + + def test_does_not_shuffle_words_like_diceware(self): + gen = generator.KeeperPassphraseGenerator( + word_count=5, separator=' ', capitalize=False, append_number=False) + with mock.patch('secrets.choice', side_effect=['one', 'two', 'three', 'four', 'five']): + result = gen.generate() + self.assertEqual(result, 'one two three four five') + + def test_capitalize_and_number_apply_to_first_word_only(self): + gen = generator.KeeperPassphraseGenerator( + word_count=5, separator='-', capitalize=True, append_number=True) + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']): + with mock.patch('secrets.randbelow', return_value=7): + result = gen.generate() + self.assertEqual(result, 'Alpha7-Bravo-Charlie-Delta-Echo') + + def test_create_from_policy_honors_passphrase_fields(self): + gen = generator.KeeperPassphraseGenerator.create_from_policy({ + 'passphrase-length': 5, + 'passphrase-separator': '-', + 'passphrase-capitalize': True, + 'passphrase-number': True, + }) + with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']): + with mock.patch('secrets.randbelow', return_value=4): + result = gen.generate() + self.assertEqual(result, 'Alpha4-Bravo-Charlie-Delta-Echo') + + def test_parse_passphrase_gen_parameters(self): + opts, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '_', 'true', 'false']) + self.assertIsNone(error) + self.assertEqual(opts.word_count, 7) + self.assertEqual(opts.separator, '_') + self.assertTrue(opts.capitalize) + self.assertFalse(opts.append_number) + + def test_parse_passphrase_rejects_invalid_separator(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '@', 'true', 'true']) + self.assertIn('Invalid passphrase separator', error) + + def test_parse_passphrase_rejects_invalid_boolean(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '_', 'tr', 'true']) + self.assertIn('capitalize', error) + + def test_parse_passphrase_rejects_trailing_comma(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '9', '_', 'true', '']) + self.assertIn('missing value after comma', error) + + def test_parse_passphrase_rejects_extra_parameters(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '_', 'true', 'true', 'test']) + self.assertIn('Unexpected', error) + + def test_parse_passphrase_rejects_out_of_range_word_count(self): + _, error = generator.parse_passphrase_gen_parameters(['passphrase', '12']) + self.assertIn('between 5 and 9', error) + + def test_create_with_options_overrides_policy(self): + policy = { + 'passphrase-length': 5, + 'passphrase-separator': '-', + 'passphrase-capitalize': False, + 'passphrase-number': False, + } + gen = generator.KeeperPassphraseGenerator.create_with_options( + policy, word_count=3, separator='_', capitalize=True, append_number=True) + self.assertEqual(gen.word_count, 5) + self.assertEqual(gen.separator, '_') + self.assertTrue(gen.capitalize) + self.assertTrue(gen.append_number) + + def test_commander_defaults_override_policy_capitalize_and_number(self): + gen = generator.KeeperPassphraseGenerator.create_with_options({ + 'passphrase-capitalize': False, + 'passphrase-number': False, + }) + self.assertTrue(gen.capitalize) + self.assertTrue(gen.append_number) + + def test_policy_separator_uses_vault_order_not_raw_first_char(self): + gen = generator.KeeperPassphraseGenerator.create_with_options({ + 'passphrase-separator': '!._?-', + }) + self.assertEqual(gen.separator, '-') + + def test_invalid_separator_override_is_rejected_by_parser(self): + _, error = generator.parse_passphrase_gen_parameters( + ['passphrase', '7', '~', 'true', 'true']) + self.assertIn('Invalid passphrase separator', error) + + def test_word_count_clamped_to_vault_range(self): + self.assertEqual(generator.clamp_passphrase_word_count(2), 5) + self.assertEqual(generator.clamp_passphrase_word_count(9), 9) + self.assertEqual(generator.clamp_passphrase_word_count(12), 9) + + def test_word_count_clamp_logs_warning(self): + with mock.patch('keepersdk.generator.logging.warning') as mock_warning: + generator.clamp_passphrase_word_count(12) + mock_warning.assert_called_once() + args, _ = mock_warning.call_args + self.assertIn('between', args[0]) + self.assertEqual(args[1:], (5, 9, 9)) + + def test_loads_bundled_diceware_wordlist(self): + words = generator._load_wordlist() + self.assertEqual(len(words), 7776) + self.assertEqual(words[0], 'abacus') + + def test_resolve_gen_password_algorithm_rejects_typos(self): + algorithm, error = generator.resolve_gen_password_algorithm(['passphra']) + self.assertIsNone(algorithm) + self.assertIn('passphrase', error) + + def test_resolve_gen_password_algorithm_accepts_numeric_length(self): + algorithm, error = generator.resolve_gen_password_algorithm(['16']) + self.assertEqual(algorithm, 'rand') + self.assertIsNone(error) From fe16d1e33e17a5bca642e9b14bbdeb08920453f8 Mon Sep 17 00:00:00 2001 From: Manan Tyagi Date: Wed, 1 Jul 2026 11:30:52 +0530 Subject: [PATCH 2/3] adding passphrase for generate command --- .../keepercli/commands/password_generate.py | 59 ++++++++++++++++++- .../src/keepercli/helpers/password_utils.py | 13 ++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/keepercli-package/src/keepercli/commands/password_generate.py b/keepercli-package/src/keepercli/commands/password_generate.py index 16cbab89..0aa20eee 100644 --- a/keepercli-package/src/keepercli/commands/password_generate.py +++ b/keepercli-package/src/keepercli/commands/password_generate.py @@ -22,9 +22,11 @@ from . import base from .. import api +from keepersdk import generator + from ..helpers.password_utils import ( PasswordGenerationService, GenerationRequest, GeneratedPassword, - BreachStatus + BreachStatus, DEFAULT_PASSWORD_LENGTH, ) from ..params import KeeperParams @@ -86,6 +88,37 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: help='Generate crypto-style strong password') special_group.add_argument('--recoveryphrase', dest='recoveryphrase', action='store_true', help='Generate 24-word recovery phrase') + + passphrase_group = parser.add_argument_group('Keeper Passphrase') + passphrase_group.add_argument( + '--passphrase', dest='passphrase', action='store_true', + help='Generate a vault-style passphrase from the EFF word list', + ) + passphrase_group.add_argument( + '--pp-separator', '-pps', dest='pp_separator', action='store', + help=( + f'Word separator (single character, or "space"). Allowed: ' + f'{generator.PASSPHRASE_SEPARATOR_HELP}. ' + 'Overrides enterprise policy for this command.' + ), + ) + passphrase_group.add_argument( + '--pp-capitalize', '-ppc', dest='pp_capitalize', action='store_true', + help='Capitalize the first letter of each word. Overrides enterprise policy for this command.', + ) + passphrase_group.add_argument( + '--pp-no-capitalize', dest='pp_capitalize', action='store_false', + help='Do not capitalize words. Overrides enterprise policy for this command.', + ) + passphrase_group.add_argument( + '--pp-number', '-ppn', dest='pp_number', action='store_true', + help='Append a digit (0-9) to the first word only. Overrides enterprise policy for this command.', + ) + passphrase_group.add_argument( + '--pp-no-number', dest='pp_number', action='store_false', + help='Do not append a digit to the first word. Overrides enterprise policy for this command.', + ) + passphrase_group.set_defaults(pp_capitalize=None, pp_number=None) diceware_group = parser.add_argument_group('Diceware Options') diceware_group.add_argument('--dice-rolls', '-dr', dest='dice_rolls', type=int, @@ -139,13 +172,17 @@ def _generate_passwords(self, service: PasswordGenerationService, request: Gener def _create_generation_request(self, **kwargs) -> GenerationRequest: """Create a GenerationRequest from command line arguments.""" count = self._validate_count(kwargs.get('number', 1)) - length = self._validate_length(kwargs.get('length', 20)) + length = self._validate_length(kwargs.get('length', DEFAULT_PASSWORD_LENGTH)) algorithm = self._determine_algorithm(kwargs) symbols, digits, uppercase, lowercase = self._validate_complexity_parameters(kwargs) rules = self._validate_rules(kwargs.get('rules')) dice_rolls = self._validate_dice_rolls(kwargs.get('dice_rolls')) - + pp_separator = self._parse_passphrase_separator(kwargs.get('pp_separator')) + passphrase_word_count = None + if algorithm == 'passphrase' and length != DEFAULT_PASSWORD_LENGTH: + passphrase_word_count = length + return GenerationRequest( length=length, count=count, @@ -158,6 +195,10 @@ def _create_generation_request(self, **kwargs) -> GenerationRequest: dice_rolls=dice_rolls, delimiter=kwargs.get('delimiter', ' '), word_list_file=kwargs.get('word_list'), + pp_separator=pp_separator, + pp_capitalize=kwargs.get('pp_capitalize'), + pp_number=kwargs.get('pp_number'), + passphrase_word_count=passphrase_word_count, enable_breach_scan=not kwargs.get('no_breachwatch', False) # max_breach_attempts uses GenerationRequest default value ) @@ -182,12 +223,24 @@ def _determine_algorithm(self, kwargs: Dict[str, Any]) -> str: """Determine password generation algorithm from arguments.""" if kwargs.get('crypto'): return 'crypto' + elif kwargs.get('passphrase'): + return 'passphrase' elif kwargs.get('recoveryphrase'): return 'recovery' elif kwargs.get('dice_rolls'): return 'diceware' else: return 'random' # default + + @staticmethod + def _parse_passphrase_separator(pp_separator: Optional[str]) -> Optional[str]: + """Parse and validate passphrase separator from CLI.""" + if not isinstance(pp_separator, str) or not pp_separator.strip(): + return None + separator, error = generator._parse_passphrase_separator_token(pp_separator.strip()) + if error: + raise base.CommandError(error) + return separator def _validate_complexity_parameters(self, kwargs: Dict[str, Any]) -> tuple: """Validate complexity parameters (symbols, digits, uppercase, lowercase).""" diff --git a/keepercli-package/src/keepercli/helpers/password_utils.py b/keepercli-package/src/keepercli/helpers/password_utils.py index a9bdb229..5bb3d40d 100644 --- a/keepercli-package/src/keepercli/helpers/password_utils.py +++ b/keepercli-package/src/keepercli/helpers/password_utils.py @@ -125,6 +125,11 @@ class GenerationRequest: dice_rolls: Optional[int] = None delimiter: str = ' ' word_list_file: Optional[str] = None + + pp_separator: Optional[str] = None + pp_capitalize: Optional[bool] = None + pp_number: Optional[bool] = None + passphrase_word_count: Optional[int] = None enable_breach_scan: bool = True max_breach_attempts: int = BREACHWATCH_MAX @@ -278,6 +283,14 @@ def _create_generator(self, request: GenerationRequest) -> generator.PasswordGen word_list_file=request.word_list_file, delimiter=request.delimiter ) + elif algorithm == 'passphrase': + return generator.KeeperPassphraseGenerator.create_with_options( + None, + word_count=request.passphrase_word_count, + separator=request.pp_separator, + capitalize=request.pp_capitalize, + append_number=request.pp_number, + ) else: if request.rules and all(i is None for i in (request.symbols, request.digits, request.uppercase, request.lowercase)): kpg = generator.KeeperPasswordGenerator.create_from_rules(request.rules, request.length) From e1f9f1b7a0220ec9111a9653011fcfccae60fb6f Mon Sep 17 00:00:00 2001 From: Manan Tyagi Date: Thu, 2 Jul 2026 15:46:18 +0530 Subject: [PATCH 3/3] comment resolution --- keepersdk-package/src/keepersdk/generator.py | 14 ++++++++------ .../unit_tests/test_passphrase_generator.py | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/keepersdk-package/src/keepersdk/generator.py b/keepersdk-package/src/keepersdk/generator.py index 3e05fd1f..8d617cc6 100644 --- a/keepersdk-package/src/keepersdk/generator.py +++ b/keepersdk-package/src/keepersdk/generator.py @@ -400,11 +400,12 @@ def generate(self): class KeeperPassphraseGenerator(PasswordGenerator): - """Vault-style passphrase generator using the bundled diceware word list. + """Vault-style passphrase generator using the bundled EFF large word list. - Produces ordered word passphrases (not shuffled) matching Keeper Vault behavior: - configurable word count (5-9), separator, first-word capitalization, and - optional digit appended to the first word. + Matches Keeper Vault / Commander behavior: each word is chosen with a + cryptographically secure random selector (``secrets``), using configurable + word count (5-9), separator, optional capitalization of every word, and + an optional single digit appended to the first word only. Use :meth:`create_with_options` or :meth:`create_from_policy` to apply enterprise passphrase policy defaults with optional CLI/$GEN overrides. @@ -431,11 +432,12 @@ def generate(self) -> str: passphrase = '' first_word = True for _ in range(self.word_count): + # secrets.choice / secrets.randbelow use os.urandom (CSPRNG). word = secrets.choice(self._vocabulary) if self.capitalize and word: - word = word[0].upper() + word[1:] + word = word[0].upper() + word[1:] # Vault UI: capitalize every word if self.append_number and first_word: - word += str(secrets.randbelow(10)) + word += str(secrets.randbelow(10)) # Vault UI: one digit on first word only if not first_word: passphrase += self.separator passphrase += word diff --git a/keepersdk-package/unit_tests/test_passphrase_generator.py b/keepersdk-package/unit_tests/test_passphrase_generator.py index 60032e6c..6ccb5b1b 100644 --- a/keepersdk-package/unit_tests/test_passphrase_generator.py +++ b/keepersdk-package/unit_tests/test_passphrase_generator.py @@ -19,7 +19,7 @@ def test_does_not_shuffle_words_like_diceware(self): result = gen.generate() self.assertEqual(result, 'one two three four five') - def test_capitalize_and_number_apply_to_first_word_only(self): + def test_capitalize_applies_to_every_word_number_to_first_word_only(self): gen = generator.KeeperPassphraseGenerator( word_count=5, separator='-', capitalize=True, append_number=True) with mock.patch('secrets.choice', side_effect=['alpha', 'bravo', 'charlie', 'delta', 'echo']):