From 9b8c17897d22db7691e4152d6d03488e74a38266 Mon Sep 17 00:00:00 2001 From: Hoseong Lee Date: Tue, 21 Apr 2026 14:53:34 -0500 Subject: [PATCH 1/6] Added isNil and toOptional --- keeperapi/src/utils.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/keeperapi/src/utils.ts b/keeperapi/src/utils.ts index 33f19c84..4aa1df3b 100644 --- a/keeperapi/src/utils.ts +++ b/keeperapi/src/utils.ts @@ -246,3 +246,11 @@ export function resolvablePromise(): { promise: Promise; resolve: () => vo resolve: resolver, } } + +export const isNil = (value: T | null | undefined): value is null | undefined => { + return value == null +} + +export const toOptional = (value: T | null | undefined): T | undefined => { + return value ?? undefined +} From b1eedb9b28bbc57189979794b2d6dce7b53a70e8 Mon Sep 17 00:00:00 2001 From: Hoseong Lee Date: Tue, 21 Apr 2026 14:54:01 -0500 Subject: [PATCH 2/6] Added syncDown module --- keeperapi/src/browser/index.ts | 1 + keeperapi/src/node/index.ts | 1 + keeperapi/src/syncDown/index.ts | 2 ++ keeperapi/src/syncDown/types.ts | 39 +++++++++++++++++++++++++++++++++ keeperapi/src/syncDown/utils.ts | 3 +++ 5 files changed, 46 insertions(+) create mode 100644 keeperapi/src/syncDown/index.ts create mode 100644 keeperapi/src/syncDown/types.ts create mode 100644 keeperapi/src/syncDown/utils.ts diff --git a/keeperapi/src/browser/index.ts b/keeperapi/src/browser/index.ts index d12f2c63..cdc42a14 100644 --- a/keeperapi/src/browser/index.ts +++ b/keeperapi/src/browser/index.ts @@ -12,6 +12,7 @@ export * from '../proto' export * from '../cryptoWorker' export * from '../qrc' export * from '../pam' +export * from '../syncDown' import { connectPlatform } from '../platform' import { browserPlatform } from './platform' diff --git a/keeperapi/src/node/index.ts b/keeperapi/src/node/index.ts index f9becc28..2406bbd8 100644 --- a/keeperapi/src/node/index.ts +++ b/keeperapi/src/node/index.ts @@ -12,6 +12,7 @@ export * from '../proto' export * from '../cryptoWorker' export * from '../qrc' export * from '../pam' +export * from '../syncDown' import { connectPlatform } from '../platform' import { nodePlatform } from './platform' diff --git a/keeperapi/src/syncDown/index.ts b/keeperapi/src/syncDown/index.ts new file mode 100644 index 00000000..ce4acb57 --- /dev/null +++ b/keeperapi/src/syncDown/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './utils' diff --git a/keeperapi/src/syncDown/types.ts b/keeperapi/src/syncDown/types.ts new file mode 100644 index 00000000..2682b407 --- /dev/null +++ b/keeperapi/src/syncDown/types.ts @@ -0,0 +1,39 @@ +import { Folder, common } from '../proto' + +export type DKdFolderRecord = { + kind: 'keeper_drive_folder_record' + folderUid: string + recordUid: string +} + +export type DKdRecordAccess = { + kind: 'keeper_drive_record_access' + accessUid: string + accessTypeUid: string + accessType: Folder.AccessType + recordUid: string + accessRoleType: Folder.AccessRoleType + owner?: boolean + inherited?: boolean + hidden?: boolean + deniedAccess?: boolean + canEdit?: boolean + canView?: boolean + canListAccess?: boolean + canUpdateAccess?: boolean + canDelete?: boolean + canChangeOwnership?: boolean + canRequestAccess?: boolean + canApproveAccess?: boolean + dateCreated?: number + lastModified?: number + tlaProperties?: common.tla.ITLAProperties +} + +export type DKdRecordSharingState = { + kind: 'keeper_drive_record_sharing_state' + recordUid: string + isDirectlyShared?: boolean + isIndirectlyShared?: boolean + isShared?: boolean +} diff --git a/keeperapi/src/syncDown/utils.ts b/keeperapi/src/syncDown/utils.ts new file mode 100644 index 00000000..cdfeb8e1 --- /dev/null +++ b/keeperapi/src/syncDown/utils.ts @@ -0,0 +1,3 @@ +export const createKdRecordAccessCompositeKey = (actorUid: string, recordUid: string) => `${actorUid}:${recordUid}` + +export const createKdFolderAccessCompositeKey = (actorUid: string, folderUid: string) => `${folderUid}:${actorUid}` From b7fa82c21e09fc96ba2f4be7fe7a1aaa3884b3d3 Mon Sep 17 00:00:00 2001 From: Hoseong Lee Date: Tue, 21 Apr 2026 14:54:32 -0500 Subject: [PATCH 3/6] Support ingesting Keeper Drive records --- keeperapi/src/vault.ts | 216 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 194 insertions(+), 22 deletions(-) diff --git a/keeperapi/src/vault.ts b/keeperapi/src/vault.ts index 7f164a1d..abedff14 100644 --- a/keeperapi/src/vault.ts +++ b/keeperapi/src/vault.ts @@ -1,8 +1,8 @@ import { Auth } from './auth' import { NN, syncDownMessage } from './restMessages' import { CryptoWorkerOptions, EncryptionType, KeyStorage, platform } from './platform' -import { Records, Tokens, Vault } from './proto' -import { formatTimeDiff, webSafe64FromBytes } from './utils' +import { Folder, record, Records, Tokens, Vault } from './proto' +import { formatTimeDiff, isNil, toOptional, webSafe64FromBytes } from './utils' import CacheStatus = Vault.CacheStatus import RecordKeyType = Records.RecordKeyType import IRecordMetaData = Vault.IRecordMetaData @@ -25,6 +25,8 @@ import IProfile = Vault.IProfile import IBreachWatchRecord = Vault.IBreachWatchRecord import IBreachWatchSecurityData = Vault.IBreachWatchSecurityData import type { UnwrapKeyMap } from './platform' +import { DKdFolderRecord, DKdRecordAccess, DKdRecordSharingState } from './syncDown/types' +import { createKdRecordAccessCompositeKey } from './syncDown' export type VaultStorage = KeyStorage & { put(data: VaultStorageData): Promise @@ -55,26 +57,11 @@ export type VaultStorageData = | DBWSecurityData | DSecurityScoreData | DUser + | DKdFolderRecord + | DKdRecordAccess + | DKdRecordSharingState -export type VaultStorageKind = - | 'profilePic' - | 'record' - | 'metadata' - | 'non_shared_data' - | 'team' - | 'shared_folder' - | 'shared_folder_user' - | 'shared_folder_team' - | 'shared_folder_record' - | 'shared_folder_folder' - | 'user_folder' - | 'profile' - | 'continuationToken' - | 'reused_passwords' - | 'bw_record' - | 'bw_security_data' - | 'security_score_data' - | 'user' +export type VaultStorageKind = VaultStorageData['kind'] export type VaultStorageResult = | (T extends 'continuationToken' ? DContinuationToken : T extends 'record' ? DRecord : never) @@ -112,6 +99,7 @@ export type DRecord = { clientModifiedTime: number extra?: any udata?: Udata + isKeeperDriveData?: boolean } export type DRecordMetadata = { @@ -306,26 +294,33 @@ const getDependencies = async (folderUid: string, storage: VaultStorage, results } } -const mapKeyType = (keyType: Records.RecordKeyType): { keyId: string; encryptionType: EncryptionType } | null => { +const mapKeyType = ( + keyType: Records.RecordKeyType | Folder.EncryptedKeyType +): { keyId: string; encryptionType: EncryptionType } | null => { let keyId: string let encryptionType: EncryptionType switch (keyType) { case RecordKeyType.NO_KEY: + case Folder.EncryptedKeyType.no_key: return null case RecordKeyType.ENCRYPTED_BY_DATA_KEY: + case Folder.EncryptedKeyType.encrypted_by_data_key: keyId = 'data' encryptionType = 'cbc' break case RecordKeyType.ENCRYPTED_BY_DATA_KEY_GCM: + case Folder.EncryptedKeyType.encrypted_by_data_key_gcm: keyId = 'data' encryptionType = 'gcm' break // RSA TAGGED - might have to fallback to ecc or force ecc - dont make a change here, rely on keeperapp to provide the correct keyType case RecordKeyType.ENCRYPTED_BY_PUBLIC_KEY: + case Folder.EncryptedKeyType.encrypted_by_public_key: keyId = 'pk_rsa' encryptionType = 'rsa' break case RecordKeyType.ENCRYPTED_BY_PUBLIC_KEY_ECC: + case Folder.EncryptedKeyType.encrypted_by_public_key_ecc: keyId = 'pk_ecc' encryptionType = 'ecc' break @@ -1033,6 +1028,171 @@ const processSecurityScoreData = async (securityScoreDataList: Vault.ISecuritySc } } +// Keeper Drive Processors Start + +const processKdRecordAccess = async (storage: VaultStorage, kdRecordAccesses?: Folder.IRecordAccessData[] | null) => { + if (!kdRecordAccesses) return + for (const recordAccess of kdRecordAccesses) { + if ( + !recordAccess.recordUid || + !recordAccess.accessTypeUid || + isNil(recordAccess.accessType) || + isNil(recordAccess.accessRoleType) + ) + continue + const recordUid = webSafe64FromBytes(recordAccess.recordUid) + const accessTypeUid = webSafe64FromBytes(recordAccess.accessTypeUid) + await storage.put({ + kind: 'keeper_drive_record_access', + accessUid: createKdRecordAccessCompositeKey(accessTypeUid, recordUid), + accessTypeUid, + accessType: recordAccess.accessType, + recordUid, + accessRoleType: recordAccess.accessRoleType, + owner: toOptional(recordAccess.owner), + inherited: toOptional(recordAccess.inherited), + hidden: toOptional(recordAccess.hidden), + deniedAccess: toOptional(recordAccess.deniedAccess), + canEdit: toOptional(recordAccess.canEdit), + canView: toOptional(recordAccess.canView), + canListAccess: toOptional(recordAccess.canListAccess), + canUpdateAccess: toOptional(recordAccess.canUpdateAccess), + canDelete: toOptional(recordAccess.canDelete), + canChangeOwnership: toOptional(recordAccess.canChangeOwnership), + canRequestAccess: toOptional(recordAccess.canRequestAccess), + canApproveAccess: toOptional(recordAccess.canApproveAccess), + dateCreated: toOptional(recordAccess.dateCreated), + lastModified: toOptional(recordAccess.lastModified), + tlaProperties: toOptional(recordAccess.tlaProperties), + }) + } +} + +const processKdFolderRecords = async ( + storage: VaultStorage, + dependencies: Dependencies, + kdFolderRecords?: Folder.IFolderRecord[] | null +) => { + if (!kdFolderRecords) return + const recordKeyMap: UnwrapKeyMap = {} + for (const folderRecord of kdFolderRecords) { + if ( + !folderRecord.folderUid || + !folderRecord.recordMetadata || + !folderRecord.recordMetadata.recordUid || + !folderRecord.recordMetadata.encryptedRecordKey || + isNil(folderRecord.recordMetadata.encryptedRecordKeyType) || + isNil(folderRecord.folderKeyEncryptionType) + ) + continue + const folderUid = webSafe64FromBytes(folderRecord.folderUid) + const recordUid = webSafe64FromBytes(folderRecord.recordMetadata.recordUid) + const keyInfo = mapKeyType(folderRecord.recordMetadata.encryptedRecordKeyType) + if (!keyInfo) continue + recordKeyMap[recordUid] = { + data: folderRecord.recordMetadata.encryptedRecordKey, + dataId: recordUid, + keyId: + folderRecord.folderKeyEncryptionType === Folder.FolderKeyEncryptionType.ENCRYPTED_BY_PARENT_KEY + ? folderUid + : keyInfo.keyId, + encryptionType: keyInfo?.encryptionType, + unwrappedType: 'aes', + } + if (folderUid) { + addDependencies(dependencies, folderUid, recordUid, 'record') + await storage.put({ + kind: 'keeper_drive_folder_record', + folderUid, + recordUid, + }) + } + } + await platform.unwrapKeys(recordKeyMap, storage) +} + +const processKdRecordSharingStates = async ( + storage: VaultStorage, + kdRecordSharingStates?: record.v3.sharing.IRecordSharingState[] | null +) => { + if (!kdRecordSharingStates) return + for (const sharingState of kdRecordSharingStates) { + if (!sharingState.recordUid) continue + await storage.put({ + kind: 'keeper_drive_record_sharing_state', + recordUid: webSafe64FromBytes(sharingState.recordUid), + isDirectlyShared: toOptional(sharingState.isDirectlyShared), + isIndirectlyShared: toOptional(sharingState.isIndirectlyShared), + isShared: toOptional(sharingState.isShared), + }) + } +} + +const processKdRecords = async ( + storage: VaultStorage, + kdRecordData?: Folder.IRecordData[] | null, + kdRecords?: Vault.IDriveRecord[] | null +) => { + if (!kdRecordData || !kdRecords) return + const kdRecordMap: { + [key in string]: Vault.IDriveRecord + } = {} + for (const record of kdRecords) { + if (!record.recordUid || !record.revision) continue + kdRecordMap[webSafe64FromBytes(record.recordUid)] = record + } + for (const record of kdRecordData) { + if (!record.recordUid || !record.data) continue + const recordUid = webSafe64FromBytes(record.recordUid) + const metadata = kdRecordMap[recordUid] || {} + if (!metadata.version) continue + try { + const decryptedData = await platform.decrypt(record.data, recordUid, 'gcm', storage) + const recordData = JSON.parse(platform.bytesToString(decryptedData)) + await storage.put({ + kind: 'record', + uid: recordUid, + data: recordData, + version: metadata.version, + revision: metadata.revision as number, + shared: !!metadata.shared, + clientModifiedTime: metadata.clientModifiedTime as number, + isKeeperDriveData: true, + }) + } catch (err: any) { + console.error(`[kd] record ${recordUid} cannot be decrypted: ${err.message}`) + } + } +} + +const processKdRemovedFolderRecords = ( + removedDependencies: RemovedDependencies, + keeperDriveRemovedFolderRecords?: Records.IFolderRecordKey[] | null +) => { + if (!keeperDriveRemovedFolderRecords) return + for (const folderRecord of keeperDriveRemovedFolderRecords) { + if (!folderRecord.recordUid || !folderRecord.folderUid) continue + const recordUid = webSafe64FromBytes(folderRecord.recordUid) + const folderUid = webSafe64FromBytes(folderRecord.folderUid) + addRemovedDependencies(removedDependencies, folderUid, recordUid) + } +} + +const processKdRevokedRecordAccesses = async ( + storage: VaultStorage, + kdRevokedAccesses?: record.v3.sharing.IRevokedAccess[] | null +) => { + if (!kdRevokedAccesses) return + for (const revokedAccess of kdRevokedAccesses) { + if (!revokedAccess.actorUid || !revokedAccess.recordUid) continue + const actorUid = webSafe64FromBytes(revokedAccess.actorUid) + const recordUid = webSafe64FromBytes(revokedAccess.recordUid) + await storage.delete('keeper_drive_record_access', createKdRecordAccessCompositeKey(actorUid, recordUid)) + } +} + +// Keeper Drive Processors End + export type SyncLogFormat = '!' | 'raw' | 'obj' | 'str' | 'cnt' | 'cnt_t' const logProtobuf = (data: any, format: SyncLogFormat, seqNo: number, counts: any) => { @@ -1212,6 +1372,14 @@ export const syncDown = async (options: SyncDownOptions): Promise => await processSecurityScoreData(resp.securityScoreData, storage) + await processKdRecordAccess(storage, resp.keeperDriveData.recordAccesses) + + await processKdFolderRecords(storage, dependencies, resp.keeperDriveData.folderRecords) + + await processKdRecords(storage, resp.keeperDriveData.recordData, resp.keeperDriveData.records) + + await processKdRecordSharingStates(storage, resp.keeperDriveData.recordSharingStates) + await storage.addDependencies(dependencies) const removedDependencies = {} @@ -1270,6 +1438,10 @@ export const syncDown = async (options: SyncDownOptions): Promise => await storage.delete('user', user) } + processKdRemovedFolderRecords(removedDependencies, resp.keeperDriveData.removedFolderRecords) + + processKdRevokedRecordAccesses(storage) + await storage.removeDependencies(removedDependencies) continuationToken = resp.continuationToken || undefined From de8165ce7d4f9143d0e6e1c952023d21917b37fa Mon Sep 17 00:00:00 2001 From: Hoseong Lee Date: Tue, 21 Apr 2026 15:16:09 -0500 Subject: [PATCH 4/6] fixed the broken unit tests --- keeperapi/src/__tests__/SyncDownResponseBuilder.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/keeperapi/src/__tests__/SyncDownResponseBuilder.ts b/keeperapi/src/__tests__/SyncDownResponseBuilder.ts index 82f59985..cf648f1d 100644 --- a/keeperapi/src/__tests__/SyncDownResponseBuilder.ts +++ b/keeperapi/src/__tests__/SyncDownResponseBuilder.ts @@ -77,6 +77,7 @@ export class SyncDownResponseBuilder { removedSharedFolderFolderRecords: [], removedSharedFolders: [], removedUsers: [], + keeperDriveData: {}, } } From c7de019ffb04e55a28942b17e03128db1d511b9b Mon Sep 17 00:00:00 2001 From: Hoseong Lee Date: Tue, 21 Apr 2026 15:19:42 -0500 Subject: [PATCH 5/6] added a safety check --- keeperapi/src/vault.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/keeperapi/src/vault.ts b/keeperapi/src/vault.ts index abedff14..7a7ffe6a 100644 --- a/keeperapi/src/vault.ts +++ b/keeperapi/src/vault.ts @@ -1328,6 +1328,8 @@ export const syncDown = async (options: SyncDownOptions): Promise => } result.pageCount += 1 networkTime += requestTime + // safety check in case keeperDriveData is undefined + resp.keeperDriveData = resp.keeperDriveData || {} const dependencies = {} await processUsers(resp.users, storage) @@ -1440,7 +1442,7 @@ export const syncDown = async (options: SyncDownOptions): Promise => processKdRemovedFolderRecords(removedDependencies, resp.keeperDriveData.removedFolderRecords) - processKdRevokedRecordAccesses(storage) + await processKdRevokedRecordAccesses(storage, resp.keeperDriveData.revokedRecordAccesses) await storage.removeDependencies(removedDependencies) From 12fc0a242e7bbc52210bf50ef70fd04c5e7c9959 Mon Sep 17 00:00:00 2001 From: Hoseong Lee Date: Tue, 21 Apr 2026 15:54:32 -0500 Subject: [PATCH 6/6] update --- keeperapi/src/vault.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/keeperapi/src/vault.ts b/keeperapi/src/vault.ts index 7a7ffe6a..eb6ae935 100644 --- a/keeperapi/src/vault.ts +++ b/keeperapi/src/vault.ts @@ -1328,8 +1328,7 @@ export const syncDown = async (options: SyncDownOptions): Promise => } result.pageCount += 1 networkTime += requestTime - // safety check in case keeperDriveData is undefined - resp.keeperDriveData = resp.keeperDriveData || {} + const keeperDriveData = resp.keeperDriveData ?? {} const dependencies = {} await processUsers(resp.users, storage) @@ -1374,13 +1373,13 @@ export const syncDown = async (options: SyncDownOptions): Promise => await processSecurityScoreData(resp.securityScoreData, storage) - await processKdRecordAccess(storage, resp.keeperDriveData.recordAccesses) + await processKdRecordAccess(storage, keeperDriveData.recordAccesses) - await processKdFolderRecords(storage, dependencies, resp.keeperDriveData.folderRecords) + await processKdFolderRecords(storage, dependencies, keeperDriveData.folderRecords) - await processKdRecords(storage, resp.keeperDriveData.recordData, resp.keeperDriveData.records) + await processKdRecords(storage, keeperDriveData.recordData, keeperDriveData.records) - await processKdRecordSharingStates(storage, resp.keeperDriveData.recordSharingStates) + await processKdRecordSharingStates(storage, keeperDriveData.recordSharingStates) await storage.addDependencies(dependencies) @@ -1440,9 +1439,9 @@ export const syncDown = async (options: SyncDownOptions): Promise => await storage.delete('user', user) } - processKdRemovedFolderRecords(removedDependencies, resp.keeperDriveData.removedFolderRecords) + processKdRemovedFolderRecords(removedDependencies, keeperDriveData.removedFolderRecords) - await processKdRevokedRecordAccesses(storage, resp.keeperDriveData.revokedRecordAccesses) + await processKdRevokedRecordAccesses(storage, keeperDriveData.revokedRecordAccesses) await storage.removeDependencies(removedDependencies)