Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions keepercommander/commands/record_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,10 @@
Value Field type Description Example
==================== =============== =================== ==============
$GEN:[alg],[n] password Generates a random password $GEN:dice,5
Default algorith is rand alg: [rand | dice | crypto]
Optional: password length
Default algorithm is rand alg: [rand | dice | crypto]
Optional: length (min 8) $GEN or $GEN:rand,24
Includes upper, lower,
digits, and symbols
$GEN oneTimeCode Generates TOTP URL
$GEN:[alg,][enc] keyPair Generates a key pair and $GEN:ec,enc
optional passcode alg: [rsa | ec | ed25519], enc
Expand All @@ -205,8 +207,8 @@
pam config new --environment=local --title=config1 --gateway=gateway1 -sf=SHARED_FOLDER_UID \
--connections=on --tunneling=on --rotation=on --remote-browser-isolation=on

record-add --folder=SHARED_FOLDER_UID --title=admin1 -rt=pamUser login=admin1 password="$GEN:rand,16"
record-add --folder=SHARED_FOLDER_UID --title=user1 -rt=pamUser login=user1 password="$GEN:rand,16"
record-add --folder=SHARED_FOLDER_UID --title=admin1 -rt=pamUser login=admin1 password="$GEN"
record-add --folder=SHARED_FOLDER_UID --title=user1 -rt=pamUser login=user1 password="$GEN"
record-add --folder=SHARED_FOLDER_UID --title=machine1 -rt=pamMachine \
pamHostname="$JSON:{\"hostName\": \"127.0.0.1\", \"port\": \"22\"}"

Expand Down Expand Up @@ -416,8 +418,9 @@ def generate_password(parameters=None): # type: (Optional[Sequence[str]]) -> s
gen = generator.DicewarePasswordGenerator(length)
else:
if isinstance(length, int):
if length < 4:
length = 4
if length < 8:
logging.warning('Password length %d is below minimum 8. Using 8.', length)
length = 8
elif length > 200:
length = 200
else:
Expand Down
80 changes: 80 additions & 0 deletions unit-tests/test_password_generator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# _ __
# | |/ /___ ___ _ __ ___ _ _ ®
# | ' </ -_) -_) '_ \/ -_) '_|
# |_|\_\___\___| .__/\___|_|
# |_|
#
# Keeper Commander
# Contact: ops@keepersecurity.com
#

"""
Unit tests for password generator minimum-length enforcement.

Verifies that generate_password() clamps short lengths to 8 and warns,
while leaving valid lengths and other algorithms unchanged.
"""

import logging
import unittest

from keepercommander.commands.record_edit import RecordEditMixin


class TestPasswordGeneratorMinimumLength(unittest.TestCase):
"""Test the minimum password length clamp introduced for rand algorithm."""

def test_rand_below_minimum_clamps_to_8(self):
"""$GEN:rand,4 should produce a password of length 8, not 4."""
pw = RecordEditMixin.generate_password(['rand', '4'])
self.assertEqual(len(pw), 8)

def test_rand_below_minimum_warns(self):
"""$GEN:rand,6 should emit a warning about clamping to 8."""
with self.assertLogs(level=logging.WARNING) as cm:
RecordEditMixin.generate_password(['rand', '6'])
self.assertTrue(any('below minimum 8' in msg for msg in cm.output))

def test_rand_at_minimum_no_warning(self):
"""$GEN:rand,8 should not warn."""
with self.assertRaises(AssertionError):
# assertLogs fails if no log is emitted — that's what we want
with self.assertLogs(level=logging.WARNING):
RecordEditMixin.generate_password(['rand', '8'])

def test_rand_at_minimum_length(self):
"""$GEN:rand,8 should produce exactly 8 characters."""
pw = RecordEditMixin.generate_password(['rand', '8'])
self.assertEqual(len(pw), 8)

def test_rand_above_minimum(self):
"""$GEN:rand,24 should produce exactly 24 characters."""
pw = RecordEditMixin.generate_password(['rand', '24'])
self.assertEqual(len(pw), 24)

def test_rand_default_length(self):
"""$GEN (no params) should produce default length (20)."""
pw = RecordEditMixin.generate_password(None)
self.assertEqual(len(pw), 20)

def test_rand_default_with_algorithm_only(self):
"""$GEN:rand (no length) should produce default length (20)."""
pw = RecordEditMixin.generate_password(['rand'])
self.assertEqual(len(pw), 20)

def test_dice_unchanged(self):
"""$GEN:dice,5 should still work — dice uses word count, not char length."""
pw = RecordEditMixin.generate_password(['dice', '5'])
# Diceware produces space-separated words; just verify it runs
self.assertIsInstance(pw, str)
self.assertGreater(len(pw), 0)

def test_crypto_unchanged(self):
"""$GEN:crypto should still work without errors."""
pw = RecordEditMixin.generate_password(['crypto'])
self.assertIsInstance(pw, str)
self.assertGreater(len(pw), 0)


if __name__ == '__main__':
unittest.main()
Loading