Skip to content

Commit 04e3c74

Browse files
Added pam split command (#1362)
* added pam split command * fixed a typo
1 parent fba591f commit 04e3c74

File tree

3 files changed

+281
-6
lines changed

3 files changed

+281
-6
lines changed

keepercommander/commands/discoveryrotation.py

Lines changed: 270 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
from ..error import CommandError, KeeperApiError
6060
from ..params import KeeperParams, LAST_RECORD_UID
6161
from ..proto import pam_pb2, router_pb2, record_pb2
62-
from ..subfolder import find_parent_top_folder, try_resolve_path, BaseFolderNode
62+
from ..subfolder import find_folders, find_parent_top_folder, \
63+
try_resolve_path, BaseFolderNode
6364
from ..vault import TypedField
6465
from .discover.job_start import PAMGatewayActionDiscoverJobStartCommand
6566
from .discover.job_status import PAMGatewayActionDiscoverJobStatusCommand
@@ -92,6 +93,7 @@ def __init__(self):
9293
self.register_command('rotation', PAMRotationCommand(), 'Manage Rotations', 'r')
9394
self.register_command('action', GatewayActionCommand(), 'Execute action on the Gateway', 'a')
9495
self.register_command('tunnel', PAMTunnelCommand(), 'Manage Tunnels', 't')
96+
self.register_command('split', PAMSplitCommand(), 'Split credentials from legacy PAM Machine', 's')
9597
self.register_command('legacy', PAMLegacyCommand(), 'Switch to legacy PAM commands')
9698

9799

@@ -2587,10 +2589,17 @@ def execute(self, params, **kwargs):
25872589
# Generate a 256-bit (32-byte) random seed
25882590
seed = os.urandom(32)
25892591
dirty = False
2590-
if not traffic_encryption_key.value:
2592+
if not traffic_encryption_key or not traffic_encryption_key.value:
25912593
base64_seed = bytes_to_base64(seed)
25922594
record_seed = vault.TypedField.new_field('trafficEncryptionSeed', base64_seed, "")
2593-
record.custom.append(record_seed)
2595+
# if field is present update in-place, if in rec definition add to fields[] else custom[]
2596+
record_types_with_seed = ("pamDatabase", "pamDirectory", "pamMachine", "pamRemoteBrowser")
2597+
if traffic_encryption_key:
2598+
traffic_encryption_key.value = [base64_seed]
2599+
elif record.get_record_type() in record_types_with_seed:
2600+
record.fields.append(record_seed) # DU-469
2601+
else:
2602+
record.custom.append(record_seed)
25942603
dirty = True
25952604
if dirty:
25962605
record_management.update_record(params, record)
@@ -3111,3 +3120,261 @@ def get_gateway_uid_from_record(self, params, record_uid):
31113120
gateway_uid = value.get('controllerUid', '') or ''
31123121

31133122
return gateway_uid
3123+
3124+
3125+
class PAMSplitCommand(Command):
3126+
pam_cmd_parser = argparse.ArgumentParser(prog='pam split')
3127+
pam_cmd_parser.add_argument('pam_machine_record', type=str, action='store',
3128+
help='The record UID or title of the legacy PAM Machine '
3129+
'record with built-in PAM User credentials.')
3130+
pam_cmd_parser.add_argument('--configuration', '-c', required=False, dest='pam_config', action='store',
3131+
help='The PAM Configuration Name or UID - If the legacy record was configured '
3132+
'for rotation this command will try to autodetect PAM Configuration settings '
3133+
'otherwise you\'ll be prompted to provide the PAM Config.')
3134+
pam_cmd_parser.add_argument('--folder', '-f', required=False, dest='pam_user_folder', action='store',
3135+
help='The folder where to store the new PAM User record - '
3136+
'folder names/paths are case sensitive!'
3137+
'(if skipped - PAM User will be created into the '
3138+
'same folder as PAM Machine)')
3139+
3140+
def get_parser(self):
3141+
return PAMSplitCommand.pam_cmd_parser
3142+
3143+
def execute(self, params, **kwargs):
3144+
def remove_field(record, field): # type: (vault.TypedRecord, vault.TypedField) -> bool
3145+
# Since TypedRecord.get_typed_field scans both fields[] and custom[]
3146+
# we need corresponding remove field lookup
3147+
fld = next((x for x in record.fields if field.type == x.type and
3148+
(not field.label or
3149+
(x.label and field.label.casefold() == x.label.casefold()))), None)
3150+
if fld is not None:
3151+
record.fields.remove(field)
3152+
return True
3153+
3154+
fld = next((x for x in record.custom if field.type == x.type and
3155+
(not field.label or
3156+
(x.label and field.label.casefold() == x.label.casefold()))), None)
3157+
if fld is not None:
3158+
record.custom.remove(field)
3159+
return True
3160+
3161+
return False
3162+
3163+
def resolve_record(params, name):
3164+
record_uid = None
3165+
if name in params.record_cache:
3166+
record_uid = name # unique record UID
3167+
else:
3168+
# lookup unique folder/record path
3169+
rs = try_resolve_path(params, name)
3170+
if rs is not None:
3171+
folder, name = rs
3172+
if folder is not None and name is not None:
3173+
folder_uid = folder.uid or ''
3174+
if folder_uid in params.subfolder_record_cache:
3175+
for uid in params.subfolder_record_cache[folder_uid]:
3176+
r = api.get_record(params, uid)
3177+
if r.title.lower() == name.lower():
3178+
record_uid = uid
3179+
break
3180+
if not record_uid:
3181+
# lookup unique record title
3182+
records = []
3183+
for uid in params.record_cache:
3184+
data_json = params.record_cache[uid].get("data_unencrypted", "{}") or {}
3185+
data = json.loads(data_json)
3186+
if "pamMachine" == str(data.get("type", "")):
3187+
title = data.get('title', '') or ''
3188+
if title.lower() == name.lower():
3189+
records.append(uid)
3190+
uniq_recs = len(set(records))
3191+
if uniq_recs > 1:
3192+
print(f"{bcolors.FAIL}Multiple PAM Machine records match title '{name}' - "
3193+
f"specify unique record path/name.{bcolors.ENDC}")
3194+
elif records:
3195+
record_uid = records[0]
3196+
return record_uid
3197+
3198+
def resolve_folder(params, name):
3199+
folder_uid = ''
3200+
if name:
3201+
# lookup unique folder path
3202+
folder_uid = FolderMixin.resolve_folder(params, name)
3203+
# lookup unique folder name/uid
3204+
if not folder_uid and name != '/':
3205+
folders = []
3206+
for fkey in params.subfolder_cache:
3207+
data_json = params.subfolder_cache[fkey].get('data_unencrypted', '{}') or {}
3208+
data = json.loads(data_json)
3209+
fname = data.get('name', '') or ''
3210+
if fname == name:
3211+
folders.append(fkey)
3212+
uniq_items = len(set(folders))
3213+
if uniq_items > 1:
3214+
print(f"{bcolors.FAIL}Multiple folders match '{name}' - specify unique "
3215+
f"folder name or use folder UID (or omit --folder parameter to create "
3216+
f"PAM User record in same folder as PAM Machine record).{bcolors.ENDC}")
3217+
folders = []
3218+
folder_uid = folders[0] if folders else ''
3219+
return folder_uid
3220+
3221+
def resolve_pam_config(params, record_uid, pam_config_option):
3222+
# PAM Config lookup - Legacy PAM Machine will have associated PAM Config
3223+
# only if it is set up for rotation - otherwise PAM Config must be provided
3224+
encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params)
3225+
pamcfg_rec = get_config_uid(params, encrypted_session_token, encrypted_transmission_key, record_uid)
3226+
if not pamcfg_rec and not pam_config_option:
3227+
print(f"{bcolors.FAIL}Unable to find PAM Config associated with record '{record_uid}' "
3228+
"- please provide PAM Config with --configuration|-c option. "
3229+
"(Note: Legacy PAM Machine is linked to PAM Config only if "
3230+
f"the machine is set up for rotation).{bcolors.ENDC}")
3231+
return
3232+
3233+
pamcfg_cmd = ''
3234+
if pam_config_option:
3235+
pam_uids = []
3236+
for uid in params.record_cache:
3237+
if params.record_cache[uid].get('version', 0) == 6:
3238+
r = api.get_record(params, uid)
3239+
if r.record_uid == pam_config_option or r.title.lower() == pam_config_option.lower():
3240+
pam_uids.append(uid)
3241+
uniq_recs = len(set(pam_uids))
3242+
if uniq_recs > 1:
3243+
print(f"{bcolors.FAIL}Multiple PAM Config records match '{pam_config_option}' - "
3244+
f"specify unique record UID/Title.{bcolors.ENDC}")
3245+
elif pam_uids:
3246+
pamcfg_cmd = pam_uids[0]
3247+
elif not pamcfg_rec:
3248+
print(f"{bcolors.FAIL}Unable to find PAM Configuration '{pam_config_option}'.{bcolors.ENDC}")
3249+
3250+
# PAM Config set on command line overrides the PAM Machine associated PAM Config
3251+
pam_config_uid = pamcfg_cmd or pamcfg_rec or ""
3252+
if pamcfg_cmd and pamcfg_rec and pamcfg_cmd != pamcfg_rec:
3253+
print(f"{bcolors.WARNING}PAM Config associated with record '{record_uid}' "
3254+
"is different from PAM Config set with --configuration|-c option. "
3255+
f"Using the configuration from command line option.{bcolors.ENDC}")
3256+
3257+
return pam_config_uid
3258+
3259+
# Parse command params
3260+
pam_config = kwargs.get('pam_config', '') # PAM Configuration Name or UID
3261+
folder = kwargs.get('pam_user_folder', '') # destination folder
3262+
record_uid = kwargs.get('pam_machine_record', '') # existing record UID
3263+
3264+
record_uid = resolve_record(params, record_uid) or record_uid
3265+
record = vault.KeeperRecord.load(params, record_uid)
3266+
if not record:
3267+
raise CommandError('', f"{bcolors.FAIL}Record {record_uid} not found.{bcolors.ENDC}")
3268+
if not isinstance(record, vault.TypedRecord) or record.record_type != "pamMachine":
3269+
raise CommandError('', f"{bcolors.FAIL}Record {record_uid} is not of the expected type 'pamMachine'.{bcolors.ENDC}")
3270+
3271+
pam_config_uid = resolve_pam_config(params, record_uid, pam_config)
3272+
if not pam_config_uid:
3273+
print(f"{bcolors.FAIL}Please provide a valid PAM Configuration.{bcolors.ENDC}")
3274+
return
3275+
# print(f"{bcolors.WARNING}Failed to find PAM Configuration for {record_uid} "
3276+
# "and unable to link new PAM User to PAM Machine. Remember to manually link "
3277+
# f"Administrative Credentials record later.{bcolors.ENDC}")
3278+
3279+
folder_uid = resolve_folder(params, folder)
3280+
if folder and not folder_uid:
3281+
print(f"{bcolors.WARNING}Unable to find destination folder '{folder}' "
3282+
"(Note: folder names/paths are case sensitive) "
3283+
"- PAM User record will be stored into same folder "
3284+
f"as the originating PAM Machine record.{bcolors.ENDC}")
3285+
3286+
flogin = record.get_typed_field('login')
3287+
vlogin = flogin.get_default_value(str) if flogin else ''
3288+
fpass = record.get_typed_field('password')
3289+
vpass = fpass.get_default_value(str) if fpass else ''
3290+
fpkey = record.get_typed_field('secret')
3291+
vpkey = fpkey.get_default_value(str) if fpkey else ''
3292+
if not(vlogin or vpass or vpkey):
3293+
if not(flogin or fpass or fpkey):
3294+
print(f"{bcolors.WARNING}Record {record_uid} is already in the new format.{bcolors.ENDC}")
3295+
else:
3296+
# No values present - just drop the old fields and add new ones
3297+
# thus converting the record to the new pamMachine format
3298+
# NB! If record was edited - newer clients moved these to custom fields
3299+
if flogin:
3300+
remove_field(record, flogin)
3301+
if fpass:
3302+
remove_field(record, fpass)
3303+
if fpkey:
3304+
remove_field(record, fpkey)
3305+
3306+
if not record.get_typed_field('trafficEncryptionSeed'):
3307+
record_seed = vault.TypedField.new_field('trafficEncryptionSeed', "", "")
3308+
record.fields.append(record_seed)
3309+
if not record.get_typed_field('pamSettings'):
3310+
pam_settings = vault.TypedField.new_field('pamSettings', "", "")
3311+
record.fields.append(pam_settings)
3312+
3313+
record_management.update_record(params, record)
3314+
params.sync_data = True
3315+
3316+
print(f"{bcolors.WARNING}Record {record_uid} has no data to split and "
3317+
"was converted to the new format. Remember to manually add "
3318+
f"Administrative Credentials later.{bcolors.ENDC}")
3319+
return
3320+
elif not vlogin or not(vpass or vpkey):
3321+
print(f"{bcolors.WARNING}Record {record_uid} has incomplete user data "
3322+
"but splitting anyway. Remember to manually update linked "
3323+
f"Administrative Credentials record later.{bcolors.ENDC}")
3324+
3325+
# Create new pamUser record
3326+
user_rec = vault.KeeperRecord.create(params, 'pamUser')
3327+
user_rec.type_name = 'pamUser'
3328+
user_rec.title = str(record.title) + ' Admin User'
3329+
if flogin:
3330+
field = user_rec.get_typed_field('login')
3331+
field.value = flogin.value
3332+
if fpass:
3333+
field = user_rec.get_typed_field('password')
3334+
field.value = fpass.value
3335+
if fpkey:
3336+
field = user_rec.get_typed_field('secret')
3337+
field.value = fpkey.value
3338+
3339+
if not folder_uid: # use the folder of the PAM Machine record
3340+
folders = list(find_folders(params, record.record_uid))
3341+
uniq_items = len(set(folders))
3342+
if uniq_items < 1:
3343+
print(f"{bcolors.WARNING}The new record will be created in root folder.{bcolors.ENDC}")
3344+
elif uniq_items > 1:
3345+
print(f"{bcolors.FAIL}Record '{record.record_uid}' is probably "
3346+
"a linked record with copies/links across multiple folders "
3347+
f"and PAM User record will be created in folder '{folders[0]}'.{bcolors.ENDC}")
3348+
folder_uid = folders[0] if folders else '' # '' means root folder
3349+
3350+
record_management.add_record_to_folder(params, user_rec, folder_uid)
3351+
pam_user_uid = params.environment_variables.get(LAST_RECORD_UID, '')
3352+
api.sync_down(params)
3353+
3354+
if flogin:
3355+
remove_field(record, flogin)
3356+
if fpass:
3357+
remove_field(record, fpass)
3358+
if fpkey:
3359+
remove_field(record, fpkey)
3360+
3361+
if not record.get_typed_field('trafficEncryptionSeed'):
3362+
record_seed = vault.TypedField.new_field('trafficEncryptionSeed', "", "")
3363+
record.fields.append(record_seed)
3364+
if not record.get_typed_field('pamSettings'):
3365+
pam_settings = vault.TypedField.new_field('pamSettings', "", "")
3366+
record.fields.append(pam_settings)
3367+
3368+
record_management.update_record(params, record)
3369+
params.sync_data = True
3370+
3371+
if pam_config_uid:
3372+
encrypted_session_token, encrypted_transmission_key, transmission_key = get_keeper_tokens(params)
3373+
tdag = TunnelDAG(params, encrypted_session_token, encrypted_transmission_key, pam_config_uid)
3374+
tdag.link_resource_to_config(record_uid)
3375+
tdag.link_user_to_resource(pam_user_uid, record_uid, True, True)
3376+
3377+
print(f"PAM Machine record {record_uid} user credentials were split into "
3378+
f"a new PAM User record {pam_user_uid}")
3379+
3380+
return

keepercommander/commands/tunnel/port_forward/endpoint.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ def set_resource_allowed(self, resource_uid, tunneling=None, connections=None, r
472472
session_recording=None, typescript_recording=None,
473473
allowed_settings_name='allowedSettings', is_config=False,
474474
v_type: RefType=str(RefType.PAM_MACHINE)):
475-
v_type = RefType(v_type)
475+
v_type = RefType(v_type)
476476
allowed_ref_types = [RefType.PAM_MACHINE, RefType.PAM_DATABASE, RefType.PAM_DIRECTORY, RefType.PAM_BROWSER]
477477
if v_type not in allowed_ref_types:
478478
# default to machine
@@ -506,6 +506,12 @@ def set_resource_allowed(self, resource_uid, tunneling=None, connections=None, r
506506
if rotation is not None and rotation != settings.get("rotation", True):
507507
settings["rotation"] = rotation
508508
dirty = True
509+
# some clients disagree with error "Rotation is disabled by the PAM configuration."
510+
# where it seems to default to False, and PAM configurations block all records
511+
# as a workaround always explicitly set rotation for PAM Config types
512+
if resource_vertex.vertex_type == RefType.PAM_NETWORK and rotation is not None and rotation != settings.get("rotation", False):
513+
settings["rotation"] = rotation
514+
dirty = True
509515
if session_recording is not None and session_recording != settings.get("sessionRecording", False):
510516
settings["sessionRecording"] = session_recording
511517
dirty = True
@@ -541,7 +547,7 @@ def print_tunneling_config(self, record_uid, pam_settings=None, config_uid=None)
541547
self.linking_dag.load()
542548
vertex = self.linking_dag.get_vertex(record_uid)
543549
content = self.get_vertex_content(vertex)
544-
config_id = config_uid if config_uid else pam_settings.value[0].get('configUid')
550+
config_id = config_uid if config_uid else pam_settings.value[0].get('configUid') if pam_settings else None
545551
if content and content.get('allowedSettings'):
546552
allowed_settings = content['allowedSettings']
547553
print(f"{bcolors.OKGREEN}Settings configured for {record_uid}{bcolors.ENDC}")

keepercommander/record_management.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from . import api, subfolder, utils, crypto, vault, vault_extensions
2020
from .error import KeeperApiError
21-
from .params import KeeperParams
21+
from .params import KeeperParams, LAST_RECORD_UID
2222
from .proto import record_pb2
2323

2424

@@ -134,6 +134,8 @@ def add_record_to_folder(params, record, folder_uid=None):
134134
else:
135135
raise ValueError('Unsupported Keeper record')
136136

137+
params.environment_variables[LAST_RECORD_UID] = record.record_uid
138+
137139

138140
class RecordChangeStatus(enum.Flag):
139141
Title = enum.auto()

0 commit comments

Comments
 (0)