diff --git a/keepersdk-package/src/keepersdk/vault/nsf_crypto.py b/keepersdk-package/src/keepersdk/vault/nsf_crypto.py index 2353020..ec960ab 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_crypto.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_crypto.py @@ -196,29 +196,42 @@ def _folder_needs_access_fallback( """True when FolderKey links require folderAccesses (TEAM_KEY or failed PARENT/USER).""" if folder_uid in decrypted_keys: return False - for fk in keys_by_folder.get(folder_uid, []): + folder_keys = keys_by_folder.get(folder_uid, []) + if not folder_keys: + # No FolderKey links — still try folderAccesses (bare sync rows). + return True + for fk in folder_keys: if fk.encrypted_by == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_TEAM_KEY): return True if fk.encrypted_by == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_PARENT_KEY): parent_uid = fk.parent_uid if not parent_uid or parent_uid not in decrypted_keys: + # Parent missing or not yet unwrapped — try accesses (Vault fallback). return True + # Parent key already available; PARENT_KEY path will handle this folder. + continue if fk.encrypted_by == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_USER_KEY): return True # USER_KEY already tried; fall back to accesses - return True + return False def decrypt_folder_keys( storage: INSFStorage, auth_context: keeper_auth.AuthContext, teams: Optional[Mapping[str, TeamKeyMaterial]] = None) -> Dict[str, bytes]: - """Decrypt NSF folder keys. Pass *teams* for team-shared folder unwrap.""" + """Decrypt NSF folder keys. Pass *teams* for team-shared folder unwrap. + + Mirrors Commander / Web Vault: folderAccesses unwrap runs inside the progress + loop so TEAM_KEY parents unlock first, then ENCRYPTED_BY_PARENT_KEY children + continue on the next pass (team-shared NSF sub-folders). + """ teams = teams or {} decrypted_keys: Dict[str, bytes] = {} keys_by_folder: Dict[str, List[nsf.NSFFolderKey]] = {} for fk in storage.folder_keys.get_all_links(): keys_by_folder.setdefault(fk.folder_uid, []).append(fk) folder_rows = list(storage.folders.get_all_entities()) + candidates = set(keys_by_folder.keys()) | {row.folder_uid for row in folder_rows} progress = True while progress: @@ -240,16 +253,17 @@ def decrypt_folder_keys( decrypted_keys[row.folder_uid] = key progress = True - # folderAccesses fallback (TEAM_KEY, PARENT without parent, USER_KEY miss, bare accesses) - candidates = set(keys_by_folder.keys()) | {row.folder_uid for row in folder_rows} - for folder_uid in candidates: - if folder_uid in decrypted_keys: - continue - if not _folder_needs_access_fallback(folder_uid, keys_by_folder, decrypted_keys): - continue - key = try_decrypt_from_folder_access(folder_uid, storage, auth_context, teams) - if key is not None: - decrypted_keys[folder_uid] = key + # folderAccesses inside the loop (TEAM_KEY / missing parent / USER miss). + # After a team parent unlocks here, the next iteration unwraps PARENT_KEY children. + for folder_uid in candidates: + if folder_uid in decrypted_keys: + continue + if not _folder_needs_access_fallback(folder_uid, keys_by_folder, decrypted_keys): + continue + key = try_decrypt_from_folder_access(folder_uid, storage, auth_context, teams) + if key is not None: + decrypted_keys[folder_uid] = key + progress = True return decrypted_keys diff --git a/keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py b/keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py index 324994d..d6a930a 100644 --- a/keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py +++ b/keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py @@ -193,6 +193,77 @@ def test_user_access_fallback(self): ) self.assertEqual(name, 'Access Shared') + def test_team_parent_then_parent_key_child_name(self): + """Team-shared root + PARENT_KEY child with no child folderAccesses key. + + Regression: access unwrap must run inside the progress loop so the child + can unwrap on the next pass after the parent team key is available. + """ + storage = memory_nsf_storage.InMemoryNSFStorage() + auth = _auth() + team_uid = utils.generate_uid() + team_aes = utils.generate_aes_key() + parent_key = utils.generate_aes_key() + child_key = utils.generate_aes_key() + teams = {team_uid: nsf_crypto.TeamKeyMaterial(team_key=team_aes)} + + parent_uid = _put_folder(storage, 'Team Root', parent_key) + child_uid = _put_folder(storage, 'Team Child', child_key, parent_uid=parent_uid) + + # Put child FolderKey first so iteration order would fail a one-shot access pass. + storage.folder_keys.put_links([ + nsf.NSFFolderKey( + folder_uid=child_uid, + parent_uid=parent_uid, + folder_key=utils.base64_url_encode( + crypto.encrypt_aes_v2(child_key, parent_key) + ), + encrypted_by=int(folder_pb2.ENCRYPTED_BY_PARENT_KEY), + ), + nsf.NSFFolderKey( + folder_uid=parent_uid, + parent_uid='', + folder_key='', + encrypted_by=int(folder_pb2.ENCRYPTED_BY_TEAM_KEY), + ), + ]) + storage.folder_accesses.put_links([ + nsf.NSFFolderAccess( + folder_uid=parent_uid, + access_type_uid=team_uid, + access_type=int(folder_pb2.AT_TEAM), + folder_key_encrypted=utils.base64_url_encode( + crypto.encrypt_aes_v2(parent_key, team_aes) + ), + folder_key_type=int(folder_pb2.encrypted_by_data_key_gcm), + ), + # Child has inherited/empty access — no folder_key_encrypted. + nsf.NSFFolderAccess( + folder_uid=child_uid, + access_type_uid=team_uid, + access_type=int(folder_pb2.AT_TEAM), + folder_key_encrypted='', + folder_key_type=0, + inherited=True, + ), + ]) + + keys = nsf_crypto.decrypt_folder_keys(storage, auth, teams=teams) + self.assertIn(parent_uid, keys) + self.assertIn(child_uid, keys) + self.assertEqual( + nsf_crypto.decrypt_folder_name( + storage.folders.get_entity(parent_uid).data, keys[parent_uid] + ), + 'Team Root', + ) + self.assertEqual( + nsf_crypto.decrypt_folder_name( + storage.folders.get_entity(child_uid).data, keys[child_uid] + ), + 'Team Child', + ) + if __name__ == '__main__': unittest.main() diff --git a/keepersdk-package/unit_tests/test_passphrase_generator.py b/keepersdk-package/unit_tests/test_passphrase_generator.py index c08305d..9d98d78 100644 --- a/keepersdk-package/unit_tests/test_passphrase_generator.py +++ b/keepersdk-package/unit_tests/test_passphrase_generator.py @@ -41,7 +41,9 @@ def test_create_from_policy_honors_passphrase_fields(self): self.assertEqual(result, 'Alpha4-Bravo-Charlie-Delta-Echo') def test_generated_words_are_unique(self): - gen = generator.KeeperPassphraseGenerator(word_count=9, capitalize=False, append_number=False) + # Use '_' so hyphenated EFF words (yo-yo, felt-tip, ...) do not inflate split(). + gen = generator.KeeperPassphraseGenerator( + word_count=9, separator='_', capitalize=False, append_number=False) for _ in range(100): words = gen.generate().split(gen.separator) self.assertEqual(len(words), 9)