Skip to content
Merged
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
40 changes: 27 additions & 13 deletions keepersdk-package/src/keepersdk/vault/nsf_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
71 changes: 71 additions & 0 deletions keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
4 changes: 3 additions & 1 deletion keepersdk-package/unit_tests/test_passphrase_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading