Skip to content

Commit 099636b

Browse files
Fix NSF team-shared subfolder titles after parent unwrap. (#220)
* Fix NSF team-shared subfolder titles after parent unwrap. Run folderAccesses decrypt inside the progress loop so PARENT_KEY children unlock after a TEAM_KEY parent, matching Commander/Web Vault. * Fix flaky passphrase uniqueness test with hyphenated EFF words. Use underscore separator so words like yo-yo do not inflate split() counts.
1 parent 85c516e commit 099636b

3 files changed

Lines changed: 101 additions & 14 deletions

File tree

‎keepersdk-package/src/keepersdk/vault/nsf_crypto.py‎

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -196,29 +196,42 @@ def _folder_needs_access_fallback(
196196
"""True when FolderKey links require folderAccesses (TEAM_KEY or failed PARENT/USER)."""
197197
if folder_uid in decrypted_keys:
198198
return False
199-
for fk in keys_by_folder.get(folder_uid, []):
199+
folder_keys = keys_by_folder.get(folder_uid, [])
200+
if not folder_keys:
201+
# No FolderKey links — still try folderAccesses (bare sync rows).
202+
return True
203+
for fk in folder_keys:
200204
if fk.encrypted_by == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_TEAM_KEY):
201205
return True
202206
if fk.encrypted_by == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_PARENT_KEY):
203207
parent_uid = fk.parent_uid
204208
if not parent_uid or parent_uid not in decrypted_keys:
209+
# Parent missing or not yet unwrapped — try accesses (Vault fallback).
205210
return True
211+
# Parent key already available; PARENT_KEY path will handle this folder.
212+
continue
206213
if fk.encrypted_by == int(_FOLDER_KEY_ENCRYPTION.ENCRYPTED_BY_USER_KEY):
207214
return True # USER_KEY already tried; fall back to accesses
208-
return True
215+
return False
209216

210217

211218
def decrypt_folder_keys(
212219
storage: INSFStorage,
213220
auth_context: keeper_auth.AuthContext,
214221
teams: Optional[Mapping[str, TeamKeyMaterial]] = None) -> Dict[str, bytes]:
215-
"""Decrypt NSF folder keys. Pass *teams* for team-shared folder unwrap."""
222+
"""Decrypt NSF folder keys. Pass *teams* for team-shared folder unwrap.
223+
224+
Mirrors Commander / Web Vault: folderAccesses unwrap runs inside the progress
225+
loop so TEAM_KEY parents unlock first, then ENCRYPTED_BY_PARENT_KEY children
226+
continue on the next pass (team-shared NSF sub-folders).
227+
"""
216228
teams = teams or {}
217229
decrypted_keys: Dict[str, bytes] = {}
218230
keys_by_folder: Dict[str, List[nsf.NSFFolderKey]] = {}
219231
for fk in storage.folder_keys.get_all_links():
220232
keys_by_folder.setdefault(fk.folder_uid, []).append(fk)
221233
folder_rows = list(storage.folders.get_all_entities())
234+
candidates = set(keys_by_folder.keys()) | {row.folder_uid for row in folder_rows}
222235

223236
progress = True
224237
while progress:
@@ -240,16 +253,17 @@ def decrypt_folder_keys(
240253
decrypted_keys[row.folder_uid] = key
241254
progress = True
242255

243-
# folderAccesses fallback (TEAM_KEY, PARENT without parent, USER_KEY miss, bare accesses)
244-
candidates = set(keys_by_folder.keys()) | {row.folder_uid for row in folder_rows}
245-
for folder_uid in candidates:
246-
if folder_uid in decrypted_keys:
247-
continue
248-
if not _folder_needs_access_fallback(folder_uid, keys_by_folder, decrypted_keys):
249-
continue
250-
key = try_decrypt_from_folder_access(folder_uid, storage, auth_context, teams)
251-
if key is not None:
252-
decrypted_keys[folder_uid] = key
256+
# folderAccesses inside the loop (TEAM_KEY / missing parent / USER miss).
257+
# After a team parent unlocks here, the next iteration unwraps PARENT_KEY children.
258+
for folder_uid in candidates:
259+
if folder_uid in decrypted_keys:
260+
continue
261+
if not _folder_needs_access_fallback(folder_uid, keys_by_folder, decrypted_keys):
262+
continue
263+
key = try_decrypt_from_folder_access(folder_uid, storage, auth_context, teams)
264+
if key is not None:
265+
decrypted_keys[folder_uid] = key
266+
progress = True
253267

254268
return decrypted_keys
255269

‎keepersdk-package/unit_tests/test_nsf_folder_key_decrypt.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,77 @@ def test_user_access_fallback(self):
193193
)
194194
self.assertEqual(name, 'Access Shared')
195195

196+
def test_team_parent_then_parent_key_child_name(self):
197+
"""Team-shared root + PARENT_KEY child with no child folderAccesses key.
198+
199+
Regression: access unwrap must run inside the progress loop so the child
200+
can unwrap on the next pass after the parent team key is available.
201+
"""
202+
storage = memory_nsf_storage.InMemoryNSFStorage()
203+
auth = _auth()
204+
team_uid = utils.generate_uid()
205+
team_aes = utils.generate_aes_key()
206+
parent_key = utils.generate_aes_key()
207+
child_key = utils.generate_aes_key()
208+
teams = {team_uid: nsf_crypto.TeamKeyMaterial(team_key=team_aes)}
209+
210+
parent_uid = _put_folder(storage, 'Team Root', parent_key)
211+
child_uid = _put_folder(storage, 'Team Child', child_key, parent_uid=parent_uid)
212+
213+
# Put child FolderKey first so iteration order would fail a one-shot access pass.
214+
storage.folder_keys.put_links([
215+
nsf.NSFFolderKey(
216+
folder_uid=child_uid,
217+
parent_uid=parent_uid,
218+
folder_key=utils.base64_url_encode(
219+
crypto.encrypt_aes_v2(child_key, parent_key)
220+
),
221+
encrypted_by=int(folder_pb2.ENCRYPTED_BY_PARENT_KEY),
222+
),
223+
nsf.NSFFolderKey(
224+
folder_uid=parent_uid,
225+
parent_uid='',
226+
folder_key='',
227+
encrypted_by=int(folder_pb2.ENCRYPTED_BY_TEAM_KEY),
228+
),
229+
])
230+
storage.folder_accesses.put_links([
231+
nsf.NSFFolderAccess(
232+
folder_uid=parent_uid,
233+
access_type_uid=team_uid,
234+
access_type=int(folder_pb2.AT_TEAM),
235+
folder_key_encrypted=utils.base64_url_encode(
236+
crypto.encrypt_aes_v2(parent_key, team_aes)
237+
),
238+
folder_key_type=int(folder_pb2.encrypted_by_data_key_gcm),
239+
),
240+
# Child has inherited/empty access — no folder_key_encrypted.
241+
nsf.NSFFolderAccess(
242+
folder_uid=child_uid,
243+
access_type_uid=team_uid,
244+
access_type=int(folder_pb2.AT_TEAM),
245+
folder_key_encrypted='',
246+
folder_key_type=0,
247+
inherited=True,
248+
),
249+
])
250+
251+
keys = nsf_crypto.decrypt_folder_keys(storage, auth, teams=teams)
252+
self.assertIn(parent_uid, keys)
253+
self.assertIn(child_uid, keys)
254+
self.assertEqual(
255+
nsf_crypto.decrypt_folder_name(
256+
storage.folders.get_entity(parent_uid).data, keys[parent_uid]
257+
),
258+
'Team Root',
259+
)
260+
self.assertEqual(
261+
nsf_crypto.decrypt_folder_name(
262+
storage.folders.get_entity(child_uid).data, keys[child_uid]
263+
),
264+
'Team Child',
265+
)
266+
196267

197268
if __name__ == '__main__':
198269
unittest.main()

‎keepersdk-package/unit_tests/test_passphrase_generator.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ def test_create_from_policy_honors_passphrase_fields(self):
4141
self.assertEqual(result, 'Alpha4-Bravo-Charlie-Delta-Echo')
4242

4343
def test_generated_words_are_unique(self):
44-
gen = generator.KeeperPassphraseGenerator(word_count=9, capitalize=False, append_number=False)
44+
# Use '_' so hyphenated EFF words (yo-yo, felt-tip, ...) do not inflate split().
45+
gen = generator.KeeperPassphraseGenerator(
46+
word_count=9, separator='_', capitalize=False, append_number=False)
4547
for _ in range(100):
4648
words = gen.generate().split(gen.separator)
4749
self.assertEqual(len(words), 9)

0 commit comments

Comments
 (0)