|
59 | 59 | from ..error import CommandError, KeeperApiError
|
60 | 60 | from ..params import KeeperParams, LAST_RECORD_UID
|
61 | 61 | 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 |
63 | 64 | from ..vault import TypedField
|
64 | 65 | from .discover.job_start import PAMGatewayActionDiscoverJobStartCommand
|
65 | 66 | from .discover.job_status import PAMGatewayActionDiscoverJobStatusCommand
|
@@ -92,6 +93,7 @@ def __init__(self):
|
92 | 93 | self.register_command('rotation', PAMRotationCommand(), 'Manage Rotations', 'r')
|
93 | 94 | self.register_command('action', GatewayActionCommand(), 'Execute action on the Gateway', 'a')
|
94 | 95 | self.register_command('tunnel', PAMTunnelCommand(), 'Manage Tunnels', 't')
|
| 96 | + self.register_command('split', PAMSplitCommand(), 'Split credentials from legacy PAM Machine', 's') |
95 | 97 | self.register_command('legacy', PAMLegacyCommand(), 'Switch to legacy PAM commands')
|
96 | 98 |
|
97 | 99 |
|
@@ -2587,10 +2589,17 @@ def execute(self, params, **kwargs):
|
2587 | 2589 | # Generate a 256-bit (32-byte) random seed
|
2588 | 2590 | seed = os.urandom(32)
|
2589 | 2591 | dirty = False
|
2590 |
| - if not traffic_encryption_key.value: |
| 2592 | + if not traffic_encryption_key or not traffic_encryption_key.value: |
2591 | 2593 | base64_seed = bytes_to_base64(seed)
|
2592 | 2594 | 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) |
2594 | 2603 | dirty = True
|
2595 | 2604 | if dirty:
|
2596 | 2605 | record_management.update_record(params, record)
|
@@ -3111,3 +3120,261 @@ def get_gateway_uid_from_record(self, params, record_uid):
|
3111 | 3120 | gateway_uid = value.get('controllerUid', '') or ''
|
3112 | 3121 |
|
3113 | 3122 | 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 |
0 commit comments