diff --git a/KeeperSdk/src/account/whoamiInfo.ts b/KeeperSdk/src/account/whoamiInfo.ts new file mode 100644 index 00000000..9ce5b64c --- /dev/null +++ b/KeeperSdk/src/account/whoamiInfo.ts @@ -0,0 +1,112 @@ +import type { AccountSummary } from '@keeper-security/keeperapi' +import type { VaultSummary } from '../vault/KeeperVault' +import { KEEPER_PUBLIC_HOSTS } from '../utils/constants' + +export type WhoamiInfo = { + user: string + server: string + dataCenter: string + admin: boolean + accountType: string + renewalDate: string + storageCapacity: string + storageUsage: string + storageRenewalDate: string + breachWatch: boolean + reportingAndAlerts: boolean + recordsCount?: number + sharedFoldersCount?: number + teamsCount?: number +} + +export type BuildWhoamiInfoInput = { + username: string + host: string + accountSummary: AccountSummary.IAccountSummaryElements + vaultSummary?: VaultSummary +} + +export function normalizeServerHost(host: string): string { + return host + .toLowerCase() + .trim() + .replace(/^(qa|dev|dev2|local)\./, '') +} + +export function resolveDataCenter(host: string): string { + const normalized = normalizeServerHost(host) + for (const [dataCenter, publicHost] of Object.entries(KEEPER_PUBLIC_HOSTS)) { + if (normalized === publicHost || normalized.endsWith(`.${publicHost}`)) { + return dataCenter + } + } + return 'US' +} + +export function buildWhoamiInfo(input: BuildWhoamiInfoInput): WhoamiInfo { + const license = input.accountSummary.license ?? input.accountSummary.personalLicense ?? {} + const server = normalizeServerHost(input.host) + + const info: WhoamiInfo = { + user: input.username, + server, + dataCenter: resolveDataCenter(input.host), + admin: !!input.accountSummary.isEnterpriseAdmin, + accountType: formatAccountType(license), + renewalDate: formatRenewalDate(license), + storageCapacity: formatStorageCapacity(license.bytesTotal), + storageUsage: formatStorageUsage(license.bytesUsed, license.bytesTotal), + storageRenewalDate: formatRenewalDateField(license.storageExpirationDate, license.storageExpiration), + breachWatch: isBreachWatchEnabled(license), + reportingAndAlerts: !!license.auditAndReportingEnabled, + } + + if (input.vaultSummary) { + info.recordsCount = input.vaultSummary.recordCount + info.sharedFoldersCount = input.vaultSummary.sharedFolderCount + info.teamsCount = input.vaultSummary.teamCount + } + + return info +} + +function formatAccountType(license: AccountSummary.ILicense): string { + const name = (license.productTypeName ?? '').trim() + if (name) return name + if (license.accountType != null) return String(license.accountType) + return 'Unknown' +} + +function formatRenewalDate(license: AccountSummary.ILicense): string { + return formatRenewalDateField(license.expirationDate, license.expiration) +} + +function formatRenewalDateField(dateString?: string | null, timestamp?: number | null): string { + const trimmed = (dateString ?? '').trim() + if (trimmed) return trimmed + if (timestamp && timestamp > 0) { + return new Date(timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + } + return '' +} + +function formatStorageCapacity(bytes?: number | null): string { + if (!bytes || bytes <= 0) return '0GB' + const gb = Math.round(bytes / 1024 ** 3) + return `${gb}GB` +} + +function formatStorageUsage(bytesUsed?: number | null, bytesTotal?: number | null): string { + if (!bytesTotal || bytesTotal <= 0) return '0%' + const pct = Math.round(((bytesUsed ?? 0) / bytesTotal) * 100) + return `${pct}%` +} + +function isBreachWatchEnabled(license: AccountSummary.ILicense): boolean { + if (license.breachWatchFeatureDisable) return false + return !!license.breachWatchEnabled +} diff --git a/KeeperSdk/src/folders/FolderManager.ts b/KeeperSdk/src/folders/FolderManager.ts index 54e07cbe..fe71d96c 100644 --- a/KeeperSdk/src/folders/FolderManager.ts +++ b/KeeperSdk/src/folders/FolderManager.ts @@ -61,7 +61,7 @@ export class FolderManager { } public getWorkingFolderDisplayName(): string { - return getWorkingFolderDisplayName(this.storage, this.session.currentFolderUid) + return getWorkingFolderDisplayName(this.storage, this.session) } private requireAuth(): Auth { diff --git a/KeeperSdk/src/folders/addFolder.ts b/KeeperSdk/src/folders/addFolder.ts index 80d40bee..ded0adbf 100644 --- a/KeeperSdk/src/folders/addFolder.ts +++ b/KeeperSdk/src/folders/addFolder.ts @@ -12,9 +12,15 @@ import { InMemoryStorage } from '../storage/InMemoryStorage' import { isBoolean, KeeperSdkError, extractErrorMessage } from '../utils' import { listFolder } from './listFolder' import { tryResolvePath, splitPathComponents, type VaultFolderSession } from './changeDirectory' -import { FolderKind, FolderResultStatus, ParentFolderKind, validateFolderName } from './folderHelpers' +import { + ClassicFolderKind, + FolderKind, + FolderResultStatus, + ParentFolderKind, + validateFolderName, +} from './folderHelpers' -type NewFolderKind = FolderKind +type NewFolderKind = ClassicFolderKind export type AddFolderInput = { folderName: string @@ -24,6 +30,7 @@ export type AddFolderInput = { manageRecords?: boolean canShare?: boolean canEdit?: boolean + color?: string | null } export type AddFolderResult = { @@ -40,6 +47,7 @@ export type MkdirOptions = { manageRecords?: boolean canShare?: boolean canEdit?: boolean + color?: string | null } type ParentContext = { @@ -152,11 +160,17 @@ export async function addFolder(auth: Auth, storage: InMemoryStorage, input: Add const encryptionKey = await getEncryptionKeyForNewFolder(auth, storage, folderType, sharedScope) + const folderData: Record = { name, title: name } + const color = input.color?.trim().toLowerCase() + if (color && color !== 'none') { + folderData.color = color + } + const request: FolderAddRequest = { folder_uid: folderUid, folder_type: folderType, key: await encryptForStorage(folderKey, encryptionKey), - data: await encryptObjectForStorage({ name, title: name }, folderKey), + data: await encryptObjectForStorage(folderData, folderKey), link: false, } @@ -190,6 +204,7 @@ export async function addFolder(auth: Auth, storage: InMemoryStorage, input: Add message: reason, } } + return { folderUid, success: true } } catch (err) { return { @@ -282,6 +297,7 @@ export async function mkdir( manageRecords: isLastSegment && createAsSharedFolder ? manageRecords : undefined, canShare: isLastSegment && createAsSharedFolder ? canShare : undefined, canEdit: isLastSegment && createAsSharedFolder ? canEdit : undefined, + color: isLastSegment ? options.color : undefined, }) if (!lastResult.success) { diff --git a/KeeperSdk/src/folders/changeDirectory.ts b/KeeperSdk/src/folders/changeDirectory.ts index 9137ee4a..a617d076 100644 --- a/KeeperSdk/src/folders/changeDirectory.ts +++ b/KeeperSdk/src/folders/changeDirectory.ts @@ -1,16 +1,18 @@ import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' import { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError } from '../utils' +import { getFolderDisplayName, getKeeperDriveFolder, isRootFolderUid } from '../nestedShareFolders/nsfHelpers' import { listFolder, listRootUserFolders } from './listFolder' import type { ListFolderFolderSimple } from './listFolder' import { FolderKind, VaultObjectKind, sharedFolderFolderName, sharedFolderName, userFolderName } from './folderHelpers' -const VAULT_ROOT_DISPLAY_NAME = 'My Vault' +export const VAULT_ROOT_DISPLAY_NAME = 'My Vault' const ESCAPED_SEPARATOR_PLACEHOLDER = '\x00' export type VaultFolderSession = { currentFolderUid: string | null + workingFolderDisplayPath?: string } export type ChangeDirectoryResult = { @@ -47,6 +49,14 @@ function getFolderEntryByUid(storage: InMemoryStorage, uid: string): ListFolderF folderKind: FolderKind.SharedFolderFolder, } } + const nestedShareFolder = getKeeperDriveFolder(storage, uid) + if (nestedShareFolder) { + return { + uid: nestedShareFolder.uid, + name: getFolderDisplayName(storage, nestedShareFolder.uid), + folderKind: FolderKind.KeeperDriveFolder, + } + } return undefined } @@ -54,6 +64,13 @@ export async function findParentFolderUid(storage: InMemoryStorage, folderUid: s const rootFolderUids = new Set((await listRootUserFolders(storage)).map((folder) => folder.uid)) if (rootFolderUids.has(folderUid)) return null + const nestedShareFolder = getKeeperDriveFolder(storage, folderUid) + if (nestedShareFolder) { + const parentUid = nestedShareFolder.parentUid?.trim() + if (!parentUid || isRootFolderUid(storage, parentUid)) return null + return parentUid + } + const parentKinds = [ FolderKind.UserFolder, FolderKind.SharedFolder, @@ -201,6 +218,25 @@ export async function resolveSingleFolder( return { folderUid: entry.uid, name: entry.name } } +export async function buildWorkingFolderDisplayPath( + storage: InMemoryStorage, + folderUid: string | null +): Promise { + if (folderUid === null) return VAULT_ROOT_DISPLAY_NAME + + const segments: string[] = [] + let current: string | null = folderUid + while (current) { + const entry = getFolderEntryByUid(storage, current) + if (!entry) break + segments.unshift(entry.name) + current = await findParentFolderUid(storage, current) + } + + if (segments.length === 0) return VAULT_ROOT_DISPLAY_NAME + return `${VAULT_ROOT_DISPLAY_NAME}/${segments.join('/')}` +} + export async function changeDirectory( storage: InMemoryStorage, session: VaultFolderSession, @@ -208,11 +244,16 @@ export async function changeDirectory( ): Promise { const resolved = await resolveSingleFolder(storage, session, path) session.currentFolderUid = resolved.folderUid - return resolved + session.workingFolderDisplayPath = await buildWorkingFolderDisplayPath(storage, resolved.folderUid) + return { + folderUid: resolved.folderUid, + name: session.workingFolderDisplayPath, + } } -export function getWorkingFolderDisplayName(storage: InMemoryStorage, currentFolderUid: string | null): string { - if (currentFolderUid === null) return VAULT_ROOT_DISPLAY_NAME - const entry = getFolderEntryByUid(storage, currentFolderUid) - return entry?.name || VAULT_ROOT_DISPLAY_NAME +export function getWorkingFolderDisplayName(storage: InMemoryStorage, session: VaultFolderSession): string { + if (session.workingFolderDisplayPath) return session.workingFolderDisplayPath + if (session.currentFolderUid === null) return VAULT_ROOT_DISPLAY_NAME + const entry = getFolderEntryByUid(storage, session.currentFolderUid) + return entry?.name ? `${VAULT_ROOT_DISPLAY_NAME}/${entry.name}` : VAULT_ROOT_DISPLAY_NAME } diff --git a/KeeperSdk/src/folders/deleteFolder.ts b/KeeperSdk/src/folders/deleteFolder.ts index 09731a54..7314d97a 100644 --- a/KeeperSdk/src/folders/deleteFolder.ts +++ b/KeeperSdk/src/folders/deleteFolder.ts @@ -1,6 +1,7 @@ import type { Auth, DeleteObject, KeeperPreDeleteResponse } from '@keeper-security/keeperapi' import { preDeleteCommand, recordDeleteCommand } from '@keeper-security/keeperapi' import type { DSharedFolder, DSharedFolderFolder, DUserFolder } from '@keeper-security/keeperapi' +import { getSdkPlatform } from '../platform' import { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError, extractErrorMessage, logger } from '../utils' import { listFolder } from './listFolder' @@ -9,6 +10,7 @@ import { tryResolvePath, findParentFolderUid, type VaultFolderSession } from './ import { DeleteResolution, FolderKind, + type ClassicFolderKind, globToRegex, sharedFolderFolderName, sharedFolderName, @@ -20,15 +22,44 @@ export type DeleteFolderResult = { success: boolean message?: string cancelled?: boolean + foldersPreview?: string } export type RmdirOptions = { force?: boolean quiet?: boolean confirm?: (summary: string) => boolean | Promise + ask?: (prompt: string) => Promise } -function folderKindOfUid(storage: InMemoryStorage, uid: string): FolderKind { +function isYesAnswer(answer: string): boolean { + const normalized = answer.trim().toLowerCase() + return normalized === 'y' || normalized === 'yes' +} + +async function defaultAsk(prompt: string): Promise { + const rl = getSdkPlatform().createReadline() + try { + return await rl.question(prompt) + } finally { + rl.close() + } +} + +function resolveRmdirConfirm(options: RmdirOptions): ((summary: string) => Promise) | undefined { + if (options.force) return undefined + if (options.confirm) { + return async (summary) => Promise.resolve(options.confirm!(summary)) + } + const ask = options.ask ?? defaultAsk + return async (summary) => { + logger.info(`\n${summary}\n`) + const answer = await ask('Do you want to proceed? (y/n) ') + return isYesAnswer(answer) + } +} + +function folderKindOfUid(storage: InMemoryStorage, uid: string): ClassicFolderKind { if (storage.getByUid(FolderKind.UserFolder, uid)) return FolderKind.UserFolder if (storage.getByUid(FolderKind.SharedFolder, uid)) return FolderKind.SharedFolder if (storage.getByUid(FolderKind.SharedFolderFolder, uid)) return FolderKind.SharedFolderFolder @@ -156,8 +187,8 @@ export async function deleteFolder( } const inner = preResp.pre_delete_response - const token = inner?.pre_delete_token - if (!token) { + let deleteToken = inner?.pre_delete_token + if (!deleteToken) { const reason = preResp.message || preResp.result_code || @@ -171,17 +202,35 @@ export async function deleteFolder( if (confirm) { const wouldDelete = inner?.would_delete const summaryItems = wouldDelete?.deletion_summary - if (Array.isArray(summaryItems) && summaryItems.length > 0) { - const summary = summaryItems.join('\n') - const confirmed = await confirm(summary) - if (!confirmed) { - return { success: false, cancelled: true, message: 'Cancelled.' } + const summary = Array.isArray(summaryItems) ? summaryItems.join('\n') : '' + const confirmed = await confirm(summary) + if (!confirmed) { + return { success: false, cancelled: true, message: 'Cancelled.' } + } + + try { + preResp = await auth.executeRestCommand(preDeleteCommand({ objects })) + } catch (err) { + return { + success: false, + message: `pre_delete refresh failed for [${targetUids}]: ${extractErrorMessage(err)}`, + } + } + deleteToken = preResp.pre_delete_response?.pre_delete_token + if (!deleteToken) { + const reason = + preResp.message || + preResp.result_code || + `pre_delete refresh failed for [${targetUids}]: server did not return a pre_delete_token` + return { + success: false, + message: reason, } } } try { - await auth.executeRestCommand(recordDeleteCommand({ pre_delete_token: token })) + await auth.executeRestCommand(recordDeleteCommand({ pre_delete_token: deleteToken })) } catch (err) { return { success: false, @@ -189,6 +238,17 @@ export async function deleteFolder( } } + for (const deleteObject of objects) { + try { + await storage.delete(deleteObject.object_type, deleteObject.object_uid) + } catch (err) { + logger.debug( + `Failed to purge ${deleteObject.object_type} ${deleteObject.object_uid}:`, + extractErrorMessage(err) + ) + } + } + return { success: true } } @@ -249,13 +309,7 @@ export async function rmdir( patterns: string[], options: RmdirOptions = {} ): Promise { - const { force = false, quiet = false, confirm } = options - if (!force && !confirm) { - throw new KeeperSdkError( - 'Confirmation is required: pass `confirm` or set `force: true`.', - 'rmdir_confirm_required' - ) - } + const { force = false, quiet = false } = options const folderUids = new Set() for (const pattern of patterns) { @@ -277,11 +331,11 @@ export async function rmdir( .map((uid) => folderDisplayName(storage, uid)) .sort((nameA, nameB) => nameA.localeCompare(nameB, undefined, { sensitivity: 'base' })) + const foldersPreview = `\nThe following folder(s) will be removed:\n${sortedNames.join(', ')}\n` if (!quiet || !force) { - logger.info(`\nThe following folder(s) will be removed:\n${sortedNames.join(', ')}\n`) + logger.info(foldersPreview) } - const confirmFn = force ? undefined : confirm - - return deleteFolder(auth, storage, [...folderUids], confirmFn) + const result = await deleteFolder(auth, storage, [...folderUids], resolveRmdirConfirm(options)) + return { ...result, foldersPreview } } diff --git a/KeeperSdk/src/folders/folderHelpers.ts b/KeeperSdk/src/folders/folderHelpers.ts index 618ad342..0ecd33da 100644 --- a/KeeperSdk/src/folders/folderHelpers.ts +++ b/KeeperSdk/src/folders/folderHelpers.ts @@ -14,8 +14,11 @@ export enum FolderKind { UserFolder = 'user_folder', SharedFolder = 'shared_folder', SharedFolderFolder = 'shared_folder_folder', + KeeperDriveFolder = 'keeper_drive_folder', } +export type ClassicFolderKind = FolderKind.UserFolder | FolderKind.SharedFolder | FolderKind.SharedFolderFolder + export enum ParentFolderKind { VirtualRoot = 'virtual_root', UserFolder = 'user_folder', @@ -67,16 +70,28 @@ export function folderKindFromString(value: string | undefined | null): FolderKi return FolderKind.SharedFolder case FolderKind.SharedFolderFolder: return FolderKind.SharedFolderFolder + case FolderKind.KeeperDriveFolder: + return FolderKind.KeeperDriveFolder default: return undefined } } +type UserFolderData = { title?: string; name?: string; color?: string } + export function userFolderName(folder: DUserFolder): string { - const data = folder.data as { title?: string; name?: string } | undefined + const data = folder.data as UserFolderData | undefined return (data?.title || data?.name || folder.uid).trim() || folder.uid } +export function userFolderColor(folder: DUserFolder): string | undefined { + const color = (folder.data as UserFolderData | undefined)?.color + if (typeof color !== 'string') return undefined + const trimmed = color.trim().toLowerCase() + if (!trimmed || trimmed === 'none') return undefined + return trimmed +} + export function sharedFolderFolderName(folder: DSharedFolderFolder): string { const data = folder.data as { title?: string; name?: string } | undefined return (data?.title || data?.name || folder.uid).trim() || folder.uid diff --git a/KeeperSdk/src/folders/folderTree.ts b/KeeperSdk/src/folders/folderTree.ts index d1594c72..022bc801 100644 --- a/KeeperSdk/src/folders/folderTree.ts +++ b/KeeperSdk/src/folders/folderTree.ts @@ -11,7 +11,7 @@ import { webSafe64FromBytes } from '@keeper-security/keeperapi' import { InMemoryStorage } from '../storage/InMemoryStorage' import { getRecordTitle } from '../records/RecordUtils' import { listFolder, listVaultRootFolders } from './listFolder' -import { resolveSingleFolder, type VaultFolderSession } from './changeDirectory' +import { resolveSingleFolder, VAULT_ROOT_DISPLAY_NAME, type VaultFolderSession } from './changeDirectory' import { FolderKind, VaultObjectKind, sharedFolderFolderName, sharedFolderName, userFolderName } from './folderHelpers' enum TreeItemKind { @@ -20,6 +20,12 @@ enum TreeItemKind { Folder = 'folder', } +const TREE_TAG = { + folder: '[folder]', + sharedFolder: '[shared folder]', + record: '[record]', +} as const + export type FolderTreeBuildOptions = { folderPath?: string | null verbose?: boolean @@ -130,6 +136,26 @@ async function collectSharedFolderPermissions( return rows.map((row) => ({ display: row.display })) } +function folderTreeTag( + userFolder: DUserFolder | undefined, + sharedFolder: DSharedFolder | undefined, + _sharedFolderFolder: DSharedFolderFolder | undefined +): string { + if (sharedFolder) return TREE_TAG.sharedFolder + if (userFolder) return TREE_TAG.folder + return TREE_TAG.folder +} + +function formatTreeNodeName(baseName: string, tag: string, verbose: boolean, uid?: string): string { + const name = verbose && uid ? `${baseName} (${uid})` : baseName + return `${name} ${tag}` +} + +function formatTreeRecordName(title: string, verbose: boolean, recordUid?: string): string { + const name = verbose && recordUid ? `${title} (${recordUid})` : title + return `${name} ${TREE_TAG.record}` +} + type BuildOpts = Required> & { promotedRootSharedUids?: Set accountUidEmailMap: Map @@ -152,13 +178,12 @@ async function buildFolderSubtree( else if (sharedFolder) baseName = sharedFolderName(sharedFolder) else baseName = sharedFolderFolderName(sharedFolderFolder!) - let displayName = baseName - if (opts.verbose) { - displayName = `${baseName} (${folderUid})` - } - if (sharedFolder) { - displayName += ' [Shared]' - } + let displayName = formatTreeNodeName( + baseName, + folderTreeTag(userFolder, sharedFolder, sharedFolderFolder), + opts.verbose, + folderUid + ) const node: FolderTreeNode = { displayName, children: [] } @@ -188,8 +213,9 @@ async function buildFolderSubtree( node.records = records.map((recordRow) => { const record = storage.getByUid(VaultObjectKind.Record, recordRow.uid) const title = record ? getRecordTitle(record) : recordRow.name - const display = opts.verbose && record ? `${title} (${recordRow.uid}) [Record]` : `${title} [Record]` - return { display } + return { + display: formatTreeRecordName(title, opts.verbose, recordRow.uid), + } }) } @@ -197,7 +223,10 @@ async function buildFolderSubtree( } async function buildVaultRootTree(storage: InMemoryStorage, opts: BuildOpts): Promise { - const node: FolderTreeNode = { displayName: '', children: [] } + const node: FolderTreeNode = { + displayName: VAULT_ROOT_DISPLAY_NAME, + children: [], + } const { rows, promotedRootSharedUids } = await listVaultRootFolders(storage) const optsWithPromoted: BuildOpts = { ...opts, promotedRootSharedUids } for (const folderRow of rows) { @@ -212,8 +241,9 @@ async function buildVaultRootTree(storage: InMemoryStorage, opts: BuildOpts): Pr node.records = listed.records.map((recordRow) => { const record = storage.getByUid(VaultObjectKind.Record, recordRow.uid) const title = record ? getRecordTitle(record) : recordRow.name - const display = opts.verbose && record ? `${title} (${recordRow.uid}) [Record]` : `${title} [Record]` - return { display } + return { + display: formatTreeRecordName(title, opts.verbose, recordRow.uid), + } }) } return node @@ -296,17 +326,17 @@ function renderNode(node: FolderTreeNode, lines: string[], isRoot: boolean, pref lines.push(node.displayName) } } else { - const connector = isLast ? '\\-- ' : '+-- ' + const connector = isLast ? '└── ' : '├── ' lines.push(prefix + connector + node.displayName) } - const childBase = isRoot ? ' ' : prefix + (isLast ? ' ' : '| ') + const childBase = isRoot ? '' : prefix + (isLast ? ' ' : '│ ') const items = gatherItems(node) for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { const isLastItem = itemIndex === items.length - 1 const item = items[itemIndex] if (item.kind === TreeItemKind.Permission || item.kind === TreeItemKind.Record) { - const connector = isLastItem ? '\\-- ' : '+-- ' + const connector = isLastItem ? '└── ' : '├── ' lines.push(childBase + connector + item.display) } else { renderNode(item.node, lines, false, childBase, isLastItem) diff --git a/KeeperSdk/src/folders/listFolder.ts b/KeeperSdk/src/folders/listFolder.ts index ca60f8a1..efca74b4 100644 --- a/KeeperSdk/src/folders/listFolder.ts +++ b/KeeperSdk/src/folders/listFolder.ts @@ -10,6 +10,14 @@ import type { import { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError } from '../utils' import { getRecordTitle, getRecordType } from '../records/RecordUtils' +import { + collectRecordsInFolder, + getFolderDisplayName, + getKeeperDriveFolder, + getKeeperDriveFolders, + isRootFolderUid, + normalizeParentUid, +} from '../nestedShareFolders/nsfHelpers' import { FolderKind, VaultObjectKind, @@ -17,6 +25,7 @@ import { globToRegex, sharedFolderFolderName, sharedFolderName, + userFolderColor, userFolderName, } from './folderHelpers' @@ -26,12 +35,15 @@ export type ListFolderOptions = { showFolders?: boolean showRecords?: boolean detail?: boolean + recursive?: boolean } export type ListFolderFolderSimple = { uid: string name: string folderKind: FolderKind + /** User-folder vault color when set (Commander `ls` colorization). */ + color?: string } export type ListFolderRecordSimple = { @@ -132,10 +144,12 @@ export async function listVaultRootFolders(storage: InMemoryStorage): Promise<{ for (const userFolder of await listRootUserFolders(storage)) { if (seen.has(userFolder.uid)) continue seen.add(userFolder.uid) + const color = userFolderColor(userFolder) rows.push({ uid: userFolder.uid, name: userFolderName(userFolder), folderKind: FolderKind.UserFolder, + ...(color ? { color } : {}), }) } @@ -181,6 +195,17 @@ export async function listVaultRootFolders(storage: InMemoryStorage): Promise<{ }) } + for (const nestedFolder of getKeeperDriveFolders(storage)) { + if (seen.has(nestedFolder.uid)) continue + if (!isRootFolderUid(storage, nestedFolder.parentUid)) continue + seen.add(nestedFolder.uid) + rows.push({ + uid: nestedFolder.uid, + name: getFolderDisplayName(storage, nestedFolder.uid), + folderKind: FolderKind.KeeperDriveFolder, + }) + } + rows.sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) return { rows, promotedRootSharedUids } @@ -196,6 +221,9 @@ function resolveFolderContainer(storage: InMemoryStorage, folderUid: string): { if (storage.getByUid(FolderKind.SharedFolderFolder, folderUid)) { return { kind: FolderKind.SharedFolderFolder, uid: folderUid } } + if (getKeeperDriveFolder(storage, folderUid)) { + return { kind: FolderKind.KeeperDriveFolder, uid: folderUid } + } throw new KeeperSdkError(`Folder "${folderUid}" not found`, 'folder_not_found') } @@ -210,7 +238,8 @@ export function findFolderUidByNameOrUid(storage: InMemoryStorage, nameOrUid: st if ( storage.getByUid(FolderKind.UserFolder, trimmedNameOrUid) || storage.getByUid(FolderKind.SharedFolder, trimmedNameOrUid) || - storage.getByUid(FolderKind.SharedFolderFolder, trimmedNameOrUid) + storage.getByUid(FolderKind.SharedFolderFolder, trimmedNameOrUid) || + getKeeperDriveFolder(storage, trimmedNameOrUid) ) { return trimmedNameOrUid } @@ -225,6 +254,9 @@ export function findFolderUidByNameOrUid(storage: InMemoryStorage, nameOrUid: st for (const sharedFolderFolder of storage.getAll(FolderKind.SharedFolderFolder)) { if (sharedFolderFolderName(sharedFolderFolder).toLowerCase() === lowerNameOrUid) return sharedFolderFolder.uid } + for (const nestedFolder of getKeeperDriveFolders(storage)) { + if (getFolderDisplayName(storage, nestedFolder.uid).toLowerCase() === lowerNameOrUid) return nestedFolder.uid + } return undefined } @@ -289,10 +321,12 @@ export async function listFolder(storage: InMemoryStorage, options: ListFolderOp if (!userFolder) continue const name = userFolderName(userFolder) if (!matches(name, userFolder.uid)) continue + const color = userFolderColor(userFolder) folderRows.push({ uid: userFolder.uid, name, folderKind: FolderKind.UserFolder, + ...(color ? { color } : {}), }) } else if (dependency.kind === FolderKind.SharedFolder && showFolders && parentKey !== null) { const sharedFolder = storage.getByUid(FolderKind.SharedFolder, dependency.uid) @@ -330,6 +364,62 @@ export async function listFolder(storage: InMemoryStorage, options: ListFolderOp } } + if (parentKey !== null && getKeeperDriveFolder(storage, parentKey)) { + if (showFolders) { + const parentNorm = normalizeParentUid(storage, parentKey) + for (const nestedFolder of getKeeperDriveFolders(storage)) { + if (normalizeParentUid(storage, nestedFolder.parentUid) !== parentNorm) continue + if (folderRows.some((row) => row.uid === nestedFolder.uid)) continue + const name = getFolderDisplayName(storage, nestedFolder.uid) + if (!matches(name, nestedFolder.uid)) continue + folderRows.push({ + uid: nestedFolder.uid, + name, + folderKind: FolderKind.KeeperDriveFolder, + }) + } + } + if (showRecords) { + for (const record of collectRecordsInFolder(storage, parentKey)) { + if (recordRows.some((row) => row.uid === record.uid)) continue + if (record.version !== 2 && record.version !== 3) continue + const title = getRecordTitle(record) + if (!matches(title, record.uid)) continue + recordRows.push({ + uid: record.uid, + name: title, + type: getRecordType(record), + }) + } + } + } + + if (options.recursive === true && folderRows.length > 0) { + const seenFolderUids = new Set(folderRows.map((row) => row.uid)) + const seenRecordUids = new Set(recordRows.map((row) => row.uid)) + for (const childUid of folderRows.map((row) => row.uid)) { + const sub = await listFolder(storage, { + folderUid: childUid, + showFolders, + showRecords, + detail: false, + pattern: null, + recursive: true, + }) + if (sub.detail) continue + for (const folder of sub.folders) { + if (seenFolderUids.has(folder.uid)) continue + seenFolderUids.add(folder.uid) + folderRows.push(folder) + } + for (const record of sub.records) { + if (seenRecordUids.has(record.uid)) continue + seenRecordUids.add(record.uid) + recordRows.push(record) + } + } + } + folderRows.sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) recordRows.sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 60f51157..12799180 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -50,6 +50,7 @@ export { RoleErrorCode, TeamErrorCode, UserErrorCode, + NodeErrorCode, AuditReportErrorCode, ActionReportErrorCode, PasswordReportErrorCode, @@ -62,7 +63,12 @@ export { anyIsBoolean, EMAIL_PATTERN, EMAIL_LIST_SEPARATOR_PATTERN, + TOKEN_SEPARATOR_PATTERN, + REGEX_ESCAPE_PATTERN, + TRAILING_EQUALS_PATTERN, + WHITESPACE_PATTERN, isValidEmail, + escapeRegExp, resolveSearchPattern, } from './utils' export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './utils' @@ -70,6 +76,7 @@ export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './util export { searchRecords, formatRecord, + formatRecordFields, getRecordTitle, getRecordType, getRecordFields, @@ -78,11 +85,17 @@ export { getRecordLogin, getRecordUrl, getRecordTotpUrl, + getRecordDescription, + getRecordCategory, RecordVersion, } from './records/RecordUtils' export type { RecordSummary } from './records/RecordUtils' +export { formatRecordsListTable, renderRecordsListAsciiTable, renderRecordsListTable } from './records/listRecordsTable' +export type { FormattedRecordsListTable } from './records/listRecordsTable' export { parseTotpUrl, getTotpCode } from './records/Totp' export type { TotpAlgorithm, TotpParams, TotpCode } from './records/Totp' +export { buildWhoamiInfo, normalizeServerHost, resolveDataCenter } from './account/whoamiInfo' +export type { WhoamiInfo, BuildWhoamiInfoInput } from './account/whoamiInfo' export { addRecord, updateRecord, deleteRecord, getRecordHistory, moveRecord } from './records/RecordOperations' export type { PasswordRecordData, @@ -131,7 +144,7 @@ export { DeleteObjectType, folderKindFromString, } from './folders/folderHelpers' -export type { FolderKindOrLiteral } from './folders/folderHelpers' +export type { FolderKindOrLiteral, ClassicFolderKind } from './folders/folderHelpers' export { listFolder, findFolderUidByNameOrUid, listRootUserFolders } from './folders/listFolder' export type { diff --git a/KeeperSdk/src/records/RecordOperations.ts b/KeeperSdk/src/records/RecordOperations.ts index 9d744712..bbbc9dd9 100644 --- a/KeeperSdk/src/records/RecordOperations.ts +++ b/KeeperSdk/src/records/RecordOperations.ts @@ -30,7 +30,7 @@ import type { import { extractErrorMessage, KeeperSdkError, logger } from '../utils' import { RecordVersion } from './RecordUtils' import { InMemoryStorage } from '../storage/InMemoryStorage' -import { DeleteResolution, FolderKind, VaultObjectKind } from '../folders/folderHelpers' +import { DeleteResolution, FolderKind, type ClassicFolderKind, VaultObjectKind } from '../folders/folderHelpers' enum ResultCode { Success = 'success', @@ -272,18 +272,21 @@ export async function updateRecord( } } -export async function deleteRecord(auth: Auth, recordUid: string): Promise { - const preDeleteRequest = { - objects: [ - { - object_uid: recordUid, - object_type: VaultObjectKind.Record, - from_uid: '', - from_type: FolderKind.UserFolder, - delete_resolution: DeleteResolution.Unlink, - } as RecordPreDeleteObject, - ], - } +export async function deleteRecord( + auth: Auth, + storage: InMemoryStorage, + recordUid: string +): Promise { + const folderLinks = await findAllRecordFolderLinks(recordUid, storage) + const objects: RecordPreDeleteObject[] = folderLinks.map((src) => ({ + object_uid: recordUid, + object_type: 'record', + from_uid: src.uid || '', + from_type: src.folderType, + delete_resolution: DeleteResolution.Unlink, + })) + + const preDeleteRequest = { objects } let preDeleteResponse: KeeperPreDeleteResponse try { @@ -309,6 +312,17 @@ export async function deleteRecord(auth: Auth, recordUid: string): Promise { +async function findAllRecordFolderLinks(recordUid: string, storage: InMemoryStorage): Promise { + const links: FolderInfo[] = [] + const seen = new Set() + const folderKinds = [FolderKind.UserFolder, FolderKind.SharedFolder, FolderKind.SharedFolderFolder] as const for (const kind of folderKinds) { @@ -436,7 +453,12 @@ async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorag (dependency) => dependency.kind === VaultObjectKind.Record && dependency.uid === recordUid ) ) { - return folder.uid + const info = resolveFolder(folder.uid, storage) + const key = `${info.folderType}:${info.uid}` + if (!seen.has(key)) { + seen.add(key) + links.push(info) + } } } } @@ -444,7 +466,25 @@ async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorag const sharedFolderRecord = storage .getAll(VaultObjectKind.SharedFolderRecord) .find((candidate) => candidate.recordUid === recordUid) - return sharedFolderRecord ? sharedFolderRecord.sharedFolderUid : '' + if (sharedFolderRecord) { + const info = resolveFolder(sharedFolderRecord.sharedFolderUid, storage) + const key = `${info.folderType}:${info.uid}` + if (!seen.has(key)) { + seen.add(key) + links.push(info) + } + } + + if (links.length === 0) { + links.push(resolveFolder('', storage)) + } + + return links +} + +async function findRecordSourceFolder(recordUid: string, storage: InMemoryStorage): Promise { + const links = await findAllRecordFolderLinks(recordUid, storage) + return links[0]?.uid ?? '' } export async function moveRecord( diff --git a/KeeperSdk/src/records/RecordUtils.ts b/KeeperSdk/src/records/RecordUtils.ts index 74630faa..0c40a1d1 100644 --- a/KeeperSdk/src/records/RecordUtils.ts +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -44,10 +44,54 @@ type LegacyExtraField = { } function toFieldValueArray(v: unknown): any[] { - if (v == null) return [] + if (v == null || v === '') return [] return Array.isArray(v) ? v : [v] } +function normalizeTypedFieldValue(value: unknown): any[] { + if (value == null || value === '') return [] + if (Array.isArray(value)) return value + return [value] +} + +function fieldHasValue(field: RecordField): boolean { + return field.value.some((v) => extractTotpUrlFromValue(v) != null || formatRawFieldValue(v).length > 0) +} + +function formatRawFieldValue(v: unknown): string { + if (v == null) return '' + if (typeof v === 'string') return v.trim() + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + return JSON.stringify(v) +} + +function extractTotpUrlFromValue(v: unknown): string | undefined { + if (v == null) return undefined + if (typeof v === 'string') { + const trimmed = v.trim() + return trimmed || undefined + } + if (typeof v === 'object' && !Array.isArray(v)) { + const obj = v as Record + for (const key of ['url', 'otpauth', 'otpAuth', 'totp', 'value', 'data', 'secret']) { + const val = obj[key] + if (typeof val === 'string' && val.trim()) return val.trim() + } + } + return undefined +} + +function getExtraTotpUrl(record: DRecord): string | undefined { + for (const field of getLegacyExtraFields(record)) { + if (field.type !== 'totp') continue + for (const v of field.value) { + const url = extractTotpUrlFromValue(v) + if (url) return url + } + } + return undefined +} + function getLegacyExtraFields(record: DRecord): RecordField[] { const raw = record.extra if (raw == null) return [] @@ -126,7 +170,7 @@ export function getRecordFields(record: DRecord): RecordField[] { for (const f of record.data.fields) { fields.push({ type: f.type || FieldType.Text, - value: Array.isArray(f.value) ? f.value : [f.value], + value: normalizeTypedFieldValue(f.value), label: f.label, required: f.required, privacyScreen: f.privacyScreen, @@ -139,7 +183,7 @@ export function getRecordFields(record: DRecord): RecordField[] { for (const f of record.data.custom) { fields.push({ type: f.type || FieldType.Text, - value: Array.isArray(f.value) ? f.value : [f.value], + value: normalizeTypedFieldValue(f.value), label: f.label, required: f.required, privacyScreen: f.privacyScreen, @@ -191,10 +235,11 @@ export function getRecordTotpUrl(record: DRecord): string | undefined { for (const field of getRecordFields(record)) { if (!TOTP_FIELD_TYPES.has(field.type)) continue for (const v of field.value) { - if (typeof v === 'string' && v.trim()) return v.trim() + const url = extractTotpUrlFromValue(v) + if (url) return url } } - return undefined + return getExtraTotpUrl(record) } export function getRecordPassword(record: DRecord): string | undefined { @@ -209,14 +254,37 @@ export function getRecordUrl(record: DRecord): string | undefined { return getRecordSummary(record).url } +export function getRecordDescription(record: DRecord): string { + if (record.version === 6) return 'PAM Configuration' + + const summary = getRecordSummary(record) + const parts: string[] = [] + if (summary.login) parts.push(summary.login) + if (summary.url) parts.push(summary.url) + return parts.length > 0 ? parts.join(' @ ') : '' +} + +export function getRecordCategory(record: DRecord): 'Classic' | 'Nested' { + return record.isKeeperDriveData ? 'Nested' : 'Classic' +} + const wordCache = new WeakMap() export function searchRecords(records: DRecord[], criteria: string): DRecord[] { - if (!criteria.trim()) return records + const trimmed = criteria.trim() + if (!trimmed) return records - const searchWords = criteria.toLowerCase().split(/\s+/) + const searchWords = trimmed + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 0) return records.filter((record) => { + const uidLower = record.uid?.toLowerCase() ?? '' + if (uidLower && searchWords.every((sw) => uidLower.includes(sw))) { + return true + } + let words = wordCache.get(record) if (!words) { words = collectRecordWords(record) @@ -246,11 +314,86 @@ function collectRecordWords(record: DRecord): string[] { } } - words.push(record.uid) + words.push(record.uid.toLowerCase()) return words } -export function formatRecord(record: DRecord, showDetails = false): string { +export type FormatRecordOptions = { + showDetails?: boolean + unmask?: boolean +} + +function resolveFormatRecordOptions(showDetailsOrOptions?: boolean | FormatRecordOptions): { + showDetails: boolean + unmask: boolean +} { + if (typeof showDetailsOrOptions === 'boolean') { + return { showDetails: showDetailsOrOptions, unmask: false } + } + return { + showDetails: showDetailsOrOptions?.showDetails ?? false, + unmask: showDetailsOrOptions?.unmask ?? false, + } +} + +function formatFieldValue(field: RecordField, unmask: boolean): string { + if (!fieldHasValue(field)) return '' + const isTotp = TOTP_FIELD_TYPES.has(field.type) + const isSensitive = field.type === FieldType.Password || isTotp || field.privacyScreen === true + if (!isSensitive || unmask) { + return field.value + .map((v) => formatRawFieldValue(v)) + .filter(Boolean) + .join(', ') + } + return MASKED_VALUE +} + +function appendTotpFields(fields: { name: string; value: unknown }[], record: DRecord, unmask: boolean): void { + const totpUrl = getRecordTotpUrl(record) + if (!totpUrl) return + fields.push({ name: 'TOTP URL', value: unmask ? totpUrl : MASKED_VALUE }) + const code = getTotpCode(totpUrl) + if (code) { + fields.push({ + name: 'Two Factor Code', + value: `${code.code} (valid for ${code.secondsRemaining} sec)`, + }) + } +} + +export function formatRecordFields(record: DRecord, unmask: boolean): { name: string; value: unknown }[] { + const summary = getRecordSummary(record) + const fields: { name: string; value: unknown }[] = [ + { name: 'title', value: getRecordTitle(record) }, + { name: 'record_uid', value: record.uid }, + { name: 'version', value: record.version }, + { name: 'record_type', value: getRecordType(record) }, + ] + if (summary.login) fields.push({ name: 'login', value: summary.login }) + if (summary.password) { + fields.push({ + name: 'password', + value: unmask ? summary.password : MASKED_VALUE, + }) + } + if (summary.url) fields.push({ name: 'login_url', value: summary.url }) + for (const field of summary.fields) { + if (field.type === FieldType.Login || field.type === FieldType.Url) continue + if (field.type === FieldType.Password) continue + if (TOTP_FIELD_TYPES.has(field.type)) continue + if (!fieldHasValue(field)) continue + const label = (field.label || field.type).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + fields.push({ name: label, value: formatFieldValue(field, unmask) }) + } + appendTotpFields(fields, record, unmask) + const notes = record.version <= RecordVersion.Legacy ? record.data?.notes : undefined + if (notes) fields.push({ name: 'Notes', value: notes }) + return fields +} + +export function formatRecord(record: DRecord, showDetailsOrOptions?: boolean | FormatRecordOptions): string { + const { showDetails } = resolveFormatRecordOptions(showDetailsOrOptions) const summary = getRecordSummary(record) const lines: string[] = [ RECORD_SEPARATOR, @@ -261,20 +404,28 @@ export function formatRecord(record: DRecord, showDetails = false): string { if (summary.login) lines.push(`Username: ${summary.login}`) if (summary.url) lines.push(`URL: ${summary.url}`) + if (summary.password) { + lines.push(`Password: ${MASKED_VALUE}`) + } if (showDetails) { for (const field of summary.fields) { if (field.type === FieldType.Login || field.type === FieldType.Url) continue - const isTotp = TOTP_FIELD_TYPES.has(field.type) - const isSensitive = field.type === FieldType.Password || isTotp - const label = isTotp ? 'TOTP URL' : field.label || field.type - lines.push(`${label}: ${isSensitive ? MASKED_VALUE : field.value.join(', ')}`) + if (field.type === FieldType.Password) continue + if (TOTP_FIELD_TYPES.has(field.type)) continue + if (!fieldHasValue(field)) continue + const label = field.label || field.type + const value = formatFieldValue(field, false) + if (value) lines.push(`${label}: ${value}`) } const totpUrl = getRecordTotpUrl(record) - const code = totpUrl ? getTotpCode(totpUrl) : null - if (code) { - lines.push(`Two Factor Code: ${code.code} valid for ${code.secondsRemaining} sec`) + if (totpUrl) { + lines.push(`TOTP URL: ${MASKED_VALUE}`) + const code = getTotpCode(totpUrl) + if (code) { + lines.push(`Two Factor Code: ${code.code} valid for ${code.secondsRemaining} sec`) + } } } diff --git a/KeeperSdk/src/records/Totp.ts b/KeeperSdk/src/records/Totp.ts index 1ba67d53..a381f45c 100644 --- a/KeeperSdk/src/records/Totp.ts +++ b/KeeperSdk/src/records/Totp.ts @@ -1,4 +1,5 @@ -import { createHmac } from 'crypto' +import { getSdkPlatform } from '../platform' +import { WHITESPACE_PATTERN } from '../utils/patterns' export type TotpAlgorithm = 'SHA1' | 'SHA256' | 'SHA512' @@ -22,7 +23,7 @@ const DEFAULT_ALGORITHM: TotpAlgorithm = 'SHA1' const UINT32_MAX = 0x100000000 function decodeBase32(input: string): Uint8Array { - const noWhitespace = input.replace(/\s+/g, '') + const noWhitespace = input.replace(WHITESPACE_PATTERN, '') let endIndex = noWhitespace.length while (endIndex > 0 && noWhitespace.charCodeAt(endIndex - 1) === 0x3d) endIndex-- const cleaned = noWhitespace.slice(0, endIndex).toUpperCase() @@ -76,10 +77,11 @@ export function parseTotpUrl(url: string): TotpParams | null { } } -function counterToBuffer(counter: number): Buffer { - const buf = Buffer.alloc(8) - buf.writeUInt32BE(Math.floor(counter / UINT32_MAX), 0) - buf.writeUInt32BE(counter % UINT32_MAX, 4) +function counterToBuffer(counter: number): Uint8Array { + const buf = new Uint8Array(8) + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) + view.setUint32(0, Math.floor(counter / UINT32_MAX), false) + view.setUint32(4, counter >>> 0, false) return buf } @@ -101,9 +103,8 @@ export function getTotpCode(urlOrParams: string | TotpParams, now: number = Date const counter = Math.floor(seconds / params.period) const secondsRemaining = params.period - (seconds % params.period) - const digest = createHmac(params.algorithm.toLowerCase(), Buffer.from(key)) - .update(counterToBuffer(counter)) - .digest() + const algo = params.algorithm.toLowerCase() as 'sha1' | 'sha256' | 'sha512' + const digest = getSdkPlatform().hmac(algo, key, counterToBuffer(counter)) if (digest.length === 0) return null const offset = digest[digest.length - 1] & 0x0f diff --git a/KeeperSdk/src/records/listRecordsTable.ts b/KeeperSdk/src/records/listRecordsTable.ts new file mode 100644 index 00000000..9aa52d00 --- /dev/null +++ b/KeeperSdk/src/records/listRecordsTable.ts @@ -0,0 +1,79 @@ +import type { DRecord } from '@keeper-security/keeperapi' +import { getRecordCategory, getRecordDescription, getRecordTitle, getRecordType } from './RecordUtils' + +const DEFAULT_COLUMN_WIDTH = 40 +const MIN_TRUNCATE_PREFIX = 3 + +export type FormattedRecordsListTable = { + headers: string[] + rows: string[][] +} + +function truncateText(text: string, maxLength: number | null): string { + if (!text) return '' + if (maxLength == null || text.length <= maxLength) return text + if (maxLength <= MIN_TRUNCATE_PREFIX) return text.slice(0, maxLength) + return `${text.slice(0, maxLength - MIN_TRUNCATE_PREFIX)}...` +} + +function compareByTitle(recordA: DRecord, recordB: DRecord): number { + const titleA = getRecordTitle(recordA) + const titleB = getRecordTitle(recordB) + return titleA.localeCompare(titleB, undefined, { sensitivity: 'base' }) +} + +export function formatRecordsListTable( + records: DRecord[], + options: { verbose?: boolean; columnWidth?: number } = {} +): FormattedRecordsListTable { + const { verbose = false, columnWidth = DEFAULT_COLUMN_WIDTH } = options + const maxWidth = verbose ? null : columnWidth + const sorted = [...records].sort(compareByTitle) + const headers = ['#', 'Record uid', 'Type', 'Title', 'Description', 'Shared', 'Record category'] + const rows = sorted.map((record, index) => { + const uid = truncateText(record.uid || '(unknown uid)', maxWidth) + const type = truncateText(getRecordType(record), maxWidth) + const title = truncateText(getRecordTitle(record), maxWidth) + const description = truncateText(getRecordDescription(record), maxWidth) + const shared = record.shared ? 'True' : 'False' + const category = getRecordCategory(record) + return [String(index + 1), uid, type, title, description, shared, category] + }) + return { headers, rows } +} + +export function renderRecordsListAsciiTable( + table: FormattedRecordsListTable, + options: { minColWidth?: number } = {} +): string { + const { minColWidth = 2 } = options + const { headers, rows } = table + const columnCount = headers.length + const columnWidths: number[] = new Array(columnCount).fill(0) + for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { + columnWidths[columnIndex] = Math.max(headers[columnIndex].length, minColWidth) + } + for (const row of rows) { + for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) { + const cell = row[columnIndex] || '' + columnWidths[columnIndex] = Math.max(columnWidths[columnIndex], cell.length, minColWidth) + } + } + const padCell = (cell: string, columnIndex: number) => cell + ' '.repeat(columnWidths[columnIndex] - cell.length) + const formatRow = (cells: string[]) => cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + const ruleRow = Array.from({ length: columnCount }, (_unused, columnIndex) => '-'.repeat(columnWidths[columnIndex])) + .map((dashes, columnIndex) => padCell(dashes, columnIndex)) + .join(' ') + const lines: string[] = [formatRow(headers), ruleRow] + for (const row of rows) { + lines.push(formatRow(row)) + } + return lines.join('\n') +} + +export function renderRecordsListTable( + records: DRecord[], + options: { verbose?: boolean; columnWidth?: number } = {} +): string { + return renderRecordsListAsciiTable(formatRecordsListTable(records, options)) +} diff --git a/KeeperSdk/src/sharedFolders/listSharedFolders.ts b/KeeperSdk/src/sharedFolders/listSharedFolders.ts index 3c0a7765..17d0a2ee 100644 --- a/KeeperSdk/src/sharedFolders/listSharedFolders.ts +++ b/KeeperSdk/src/sharedFolders/listSharedFolders.ts @@ -1,4 +1,6 @@ import type { + DRecord, + DRecordRotation, DSharedFolder, DSharedFolderRecord, DSharedFolderTeam, @@ -7,11 +9,13 @@ import type { import { InMemoryStorage } from '../storage/InMemoryStorage' import { TOKEN_SEPARATOR_PATTERN } from '../utils' import { FolderKind, VaultObjectKind } from '../folders/folderHelpers' +import { getRecordType } from '../records/RecordUtils' export type ListSharedFoldersOptions = { pattern?: string | null verbose?: boolean includeDetails?: boolean + roeEligible?: boolean } export type ListSharedFolderRow = { @@ -87,30 +91,52 @@ function countRecordsForFolder(storage: InMemoryStorage, sharedFolderUid: string ) } +function recordHasRotationConfigured(storage: InMemoryStorage, recordUid: string): boolean { + const rotation = storage.getByUid('record_rotation', recordUid) + return rotation != null && rotation.disabled !== true +} + +function sharedFolderHasPamUserWithRotation(storage: InMemoryStorage, sharedFolderUid: string): boolean { + for (const link of storage.getAll(VaultObjectKind.SharedFolderRecord)) { + if (link.sharedFolderUid !== sharedFolderUid) continue + const record = storage.getByUid(VaultObjectKind.Record, link.recordUid) + if (!record) continue + if (getRecordType(record).toLowerCase() !== 'pamuser') continue + if (recordHasRotationConfigured(storage, link.recordUid)) return true + } + return false +} + export function listSharedFolders( storage: InMemoryStorage, options: ListSharedFoldersOptions = {} ): ListSharedFolderRow[] { - const { pattern, includeDetails = false } = options - const sharedFolders: DSharedFolder[] = pattern + const { pattern, includeDetails = false, roeEligible = false } = options + let sharedFolders: DSharedFolder[] = pattern ? findSharedFolders(storage, pattern) : storage.getAll(FolderKind.SharedFolder) - return sharedFolders.map((sharedFolder) => { - const shared_folder_uid = sharedFolder.uid - const name = sharedFolderDisplayName(sharedFolder) - const row: ListSharedFolderRow = { shared_folder_uid, name } - if (includeDetails) { - row.record_count = countRecordsForFolder(storage, shared_folder_uid) - row.user_count = countUsersForFolder(storage, shared_folder_uid) - row.team_count = countTeamsForFolder(storage, shared_folder_uid) - row.default_manage_records = sharedFolder.defaultManageRecords - row.default_manage_users = sharedFolder.defaultManageUsers - row.default_can_edit = sharedFolder.defaultCanEdit - row.default_can_share = sharedFolder.defaultCanShare - } - return row - }) + if (roeEligible) { + sharedFolders = sharedFolders.filter((folder) => sharedFolderHasPamUserWithRotation(storage, folder.uid)) + } + + return sharedFolders + .map((sharedFolder) => { + const shared_folder_uid = sharedFolder.uid + const name = sharedFolderDisplayName(sharedFolder) + const row: ListSharedFolderRow = { shared_folder_uid, name } + if (includeDetails) { + row.record_count = countRecordsForFolder(storage, shared_folder_uid) + row.user_count = countUsersForFolder(storage, shared_folder_uid) + row.team_count = countTeamsForFolder(storage, shared_folder_uid) + row.default_manage_records = sharedFolder.defaultManageRecords + row.default_manage_users = sharedFolder.defaultManageUsers + row.default_can_edit = sharedFolder.defaultCanEdit + row.default_can_share = sharedFolder.defaultCanShare + } + return row + }) + .sort((rowA, rowB) => rowA.name.localeCompare(rowB.name, undefined, { sensitivity: 'base' })) } export type FormattedSharedFoldersTable = { diff --git a/KeeperSdk/src/sharing/Sharing.ts b/KeeperSdk/src/sharing/Sharing.ts index 0b7ba981..99496ff2 100644 --- a/KeeperSdk/src/sharing/Sharing.ts +++ b/KeeperSdk/src/sharing/Sharing.ts @@ -8,6 +8,9 @@ import { webSafe64FromBytes, recordsShareUpdateMessage, normal64Bytes, + sendShareInviteMessage, + record, + Folder, } from '@keeper-security/keeperapi' import { extractErrorMessage, KeeperSdkError } from '../utils/errors' @@ -47,14 +50,15 @@ export type RemoveShareResult = { message: string } -type UserKeys = { +export type UserShareKeys = { username: string + accountUid?: Uint8Array rsaPublicKey: Uint8Array | null eccPublicKey: Uint8Array | null errorCode: string | null } -async function loadUserPublicKey(auth: Auth, email: string): Promise { +export async function loadUserShareKeys(auth: Auth, email: string): Promise { const msg = getPublicKeysMessage({ usernames: [email] }) let response: Authentication.IGetPublicKeysResponse @@ -79,12 +83,122 @@ async function loadUserPublicKey(auth: Auth, email: string): Promise { return { username: entry.username || email, + accountUid: entry.accountUid?.length ? (entry.accountUid as Uint8Array) : undefined, rsaPublicKey: entry.publicKey && entry.publicKey.length > 0 ? (entry.publicKey as Uint8Array) : null, eccPublicKey: entry.publicEccKey && entry.publicEccKey.length > 0 ? (entry.publicEccKey as Uint8Array) : null, errorCode: entry.errorCode || null, } } +export async function encryptKeyForRecipient( + key: Uint8Array, + userKeys: UserShareKeys +): Promise<{ encryptedKey: Uint8Array; useEccKey: boolean }> { + if (userKeys.eccPublicKey) { + return { + encryptedKey: await platform.publicEncryptEC(key, userKeys.eccPublicKey), + useEccKey: true, + } + } + if (userKeys.rsaPublicKey) { + return { + encryptedKey: platform.publicEncrypt(key, platform.bytesToBase64(userKeys.rsaPublicKey)), + useEccKey: false, + } + } + throw new KeeperSdkError(`No usable public key available for ${userKeys.username}`, ShareStatus.MissingPublicKey) +} + +export async function sendShareInviteIfNeeded(auth: Auth, email: string): Promise { + await auth.executeRestAction(sendShareInviteMessage(Authentication.SendShareInviteRequest.create({ email }))) +} + +export async function loadUserShareKeysOrInvite( + auth: Auth, + email: string, + errorCode: string = ShareStatus.MissingPublicKey +): Promise { + const userKeys = await loadUserShareKeys(auth, email) + if (!userKeys.rsaPublicKey && !userKeys.eccPublicKey) { + await sendShareInviteIfNeeded(auth, email) + throw new KeeperSdkError(`User '${email}' has no public key. Share invitation sent.`, errorCode) + } + if (!userKeys.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode) + } + return { ...userKeys, accountUid: userKeys.accountUid } +} + +export function parseRecordSharingStatus(status: record.v3.sharing.IStatus | null | undefined): { + recordUid: string + success: boolean + message: string +} { + if (!status?.recordUid?.length) { + return { recordUid: '', success: false, message: 'No status returned' } + } + const recordUid = webSafe64FromBytes(status.recordUid) + const sharingStatus = status.status ?? record.v3.sharing.SharingStatus.SUCCESS + const statusName = record.v3.sharing.SharingStatus[sharingStatus] ?? String(sharingStatus) + const success = + sharingStatus === record.v3.sharing.SharingStatus.SUCCESS || + sharingStatus === record.v3.sharing.SharingStatus.PENDING_ACCEPT + return { recordUid, success, message: status.message || statusName } +} + +export async function buildNsfRecordSharePermission( + auth: Auth, + recordUid: string, + recordKey: Uint8Array, + email: string, + accessRoleType: Folder.AccessRoleType, + expirationTimestamp?: number, + errorCode: string = ShareStatus.MissingPublicKey +): Promise { + const userKeys = await loadUserShareKeysOrInvite(auth, email, errorCode) + const { encryptedKey, useEccKey } = await encryptKeyForRecipient(recordKey, userKeys) + const recordUidBytes = normal64Bytes(recordUid) + const rules: Folder.IRecordAccessData = { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + owner: false, + accessRoleType, + } + if (expirationTimestamp != null) { + rules.tlaProperties = { expiration: expirationTimestamp } + } + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + recordKey: encryptedKey, + useEccKey, + rules, + } +} + +export async function buildNsfRecordRevokePermission( + auth: Auth, + recordUid: string, + email: string, + errorCode: string = ShareStatus.MissingPublicKey +): Promise { + const userKeys = await loadUserShareKeys(auth, email) + if (!userKeys.accountUid?.length) { + throw new KeeperSdkError(`User ${email} not found`, errorCode) + } + const recordUidBytes = normal64Bytes(recordUid) + return { + recipientUid: userKeys.accountUid, + recordUid: recordUidBytes, + rules: { + accessTypeUid: userKeys.accountUid, + accessType: Folder.AccessType.AT_USER, + recordUid: recordUidBytes, + }, + } +} + export async function shareRecord( auth: Auth, recordKey: Uint8Array, @@ -92,7 +206,7 @@ export async function shareRecord( ): Promise { const { recordUid, email, canEdit = false, canShare = false } = input - const userKeys = await loadUserPublicKey(auth, email) + const userKeys = await loadUserShareKeys(auth, email) let encryptedRecordKey: Uint8Array let useEccKey = false diff --git a/KeeperSdk/src/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index 4ba42312..0672fe21 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -37,6 +37,9 @@ export class InMemoryStorage implements VaultStorage { public async put(item: VaultStorageData): Promise { const kind = item.kind + if (!kind) { + throw new Error('VaultStorageData missing kind') + } if (!this.store.has(kind)) { this.store.set(kind, new Map()) } diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 355a92d1..825e6cf8 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -32,6 +32,7 @@ export enum SessionErrorCode { NoCloneCode = 'no_clone_code', PersistentLoginFailed = 'persistent_login_failed', SessionTokenExpired = 'session_token_expired', + SyncFailed = 'sync_failed', } export enum ValidationErrorCode { @@ -188,6 +189,7 @@ export const ResultCodes = { NO_CLONE_CODE: SessionErrorCode.NoCloneCode, PERSISTENT_LOGIN_FAILED: SessionErrorCode.PersistentLoginFailed, SESSION_TOKEN_EXPIRED: SessionErrorCode.SessionTokenExpired, + SYNC_FAILED: SessionErrorCode.SyncFailed, INVALID_PATTERN: ValidationErrorCode.InvalidPattern, ROLE_REQUIRED: RoleErrorCode.RoleRequired, ROLE_NOT_FOUND: RoleErrorCode.RoleNotFound, diff --git a/KeeperSdk/src/utils/errors.ts b/KeeperSdk/src/utils/errors.ts index b8f6622c..12b635ca 100644 --- a/KeeperSdk/src/utils/errors.ts +++ b/KeeperSdk/src/utils/errors.ts @@ -53,33 +53,48 @@ export function extractResultCode(err: unknown): string | undefined { } export function extractErrorMessage(err: unknown): string { + let message: string if (isKeeperError(err)) { - return err.message || err.result_code || err.error || 'Unknown Keeper error' - } - if (err instanceof Error) { + message = err.message || err.result_code || err.error || 'Unknown Keeper error' + } else if (err instanceof Error) { const parsed = parseJsonObjectIfPresent(err.message) if (parsed) { - if (typeof parsed.message === 'string') return parsed.message - if (typeof parsed.result_code === 'string') return parsed.result_code - if (typeof parsed.error === 'string') return parsed.error + if (typeof parsed.message === 'string') message = parsed.message + else if (typeof parsed.result_code === 'string') message = parsed.result_code + else if (typeof parsed.error === 'string') message = parsed.error + else message = err.message + } else { + message = err.message } - return err.message - } - if (typeof err === 'string') { + } else if (typeof err === 'string') { const parsed = parseJsonObjectIfPresent(err) if (parsed) { - if (typeof parsed.message === 'string') return parsed.message - if (typeof parsed.result_code === 'string') return parsed.result_code - if (typeof parsed.error === 'string') return parsed.error + if (typeof parsed.message === 'string') message = parsed.message + else if (typeof parsed.result_code === 'string') message = parsed.result_code + else if (typeof parsed.error === 'string') message = parsed.error + else message = err + } else { + message = err } - return err - } - if (typeof err === 'object' && err !== null) { + } else if (typeof err === 'object' && err !== null) { const obj = err as Record - if (typeof obj.message === 'string') return obj.message - if (typeof obj.result_code === 'string') return obj.result_code + if (typeof obj.message === 'string') message = obj.message + else if (typeof obj.result_code === 'string') message = obj.result_code + else message = String(err) + } else { + message = String(err) + } + return sanitizeErrorMessage(message) +} + +function sanitizeErrorMessage(message: string): string { + const trimmed = message.trim() + if (/^missing:\s*\{/i.test(trimmed) && /session_token/i.test(trimmed)) { + return 'Request rejected: missing or invalid fields. Try again or re-login if the session expired.' } - return String(err) + return trimmed + .replace(/"session_token"\s*:\s*"[^"]*"/gi, '"session_token":"[REDACTED]"') + .replace(/session_token[=:]\s*[^\s"',}]+/gi, 'session_token=[REDACTED]') } export class KeeperSdkError extends Error { diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index 65ea9f2b..942a340f 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -8,6 +8,7 @@ export { RoleErrorCode, TeamErrorCode, UserErrorCode, + NodeErrorCode, AuditReportErrorCode, ActionReportErrorCode, PasswordReportErrorCode, @@ -24,6 +25,8 @@ export { EMAIL_LIST_SEPARATOR_PATTERN, TOKEN_SEPARATOR_PATTERN, REGEX_ESCAPE_PATTERN, + TRAILING_EQUALS_PATTERN, + WHITESPACE_PATTERN, isValidEmail, escapeRegExp, resolveSearchPattern, diff --git a/KeeperSdk/src/utils/patterns.ts b/KeeperSdk/src/utils/patterns.ts index d65cc00f..7063cb62 100644 --- a/KeeperSdk/src/utils/patterns.ts +++ b/KeeperSdk/src/utils/patterns.ts @@ -12,10 +12,12 @@ export const TOKEN_SEPARATOR_PATTERN = /[\s\-_.,;:!?@#$%^&*()[\]{}|\\/<>]+/ /** Characters that must be escaped when embedding user input into a RegExp. */ export const REGEX_ESCAPE_PATTERN = /[.+^${}()|[\]\\]/g -const MAX_EMAIL_LENGTH = 254 +export const TRAILING_EQUALS_PATTERN = /=+$/g + +export const WHITESPACE_PATTERN = /\s+/g export function isValidEmail(value: string): boolean { - return value.length <= MAX_EMAIL_LENGTH && EMAIL_PATTERN.test(value) + return EMAIL_PATTERN.test(value) } export function escapeRegExp(value: string): string { diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 2b7f4d80..a9dc6143 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -45,7 +45,8 @@ import type { } from '../sharing/Sharing' import type { ListFolderOptions, ListFolderResult } from '../folders/listFolder' import { FolderKind, VaultObjectKind } from '../folders/folderHelpers' -import type { ChangeDirectoryResult, VaultFolderSession } from '../folders/changeDirectory' +import type { ChangeDirectoryResult, TryResolvePathResult, VaultFolderSession } from '../folders/changeDirectory' +import { buildWhoamiInfo, type WhoamiInfo } from '../account/whoamiInfo' import type { AddFolderInput, AddFolderResult, MkdirOptions } from '../folders/addFolder' import type { UpdateFolderInput, UpdateFolderResult, RenameFolderResult } from '../folders/updateFolder' import type { DeleteFolderResult, RmdirOptions } from '../folders/deleteFolder' @@ -498,6 +499,10 @@ export class KeeperVault { return this.folderManager.listFolder(options ?? {}) } + public async tryResolvePath(path: string): Promise { + return this.folderManager.tryResolvePath(path) + } + public listSharedFolders(options?: ListSharedFoldersOptions): ListSharedFolderRow[] { return this.sharedFolderManager.listSharedFolders(options ?? {}) } @@ -703,6 +708,30 @@ export class KeeperVault { } } + public async getAccountUsername(): Promise { + return this.sessionManager.getLastUsername() ?? this.auth?.username ?? undefined + } + + public async getWhoamiInfo(options?: { includeVaultCounts?: boolean }): Promise { + const auth = this.getAuthOrThrow() + if (!auth.accountSummary) { + await auth.loadAccountSummary() + } + const summary = auth.accountSummary + if (!summary) { + throw new KeeperSdkError('Account summary is unavailable.', ResultCodes.SYNC_FAILED) + } + + const username = auth.username || (await this.getAccountUsername()) || '' + + return buildWhoamiInfo({ + username, + host: this.host, + accountSummary: summary, + vaultSummary: options?.includeVaultCounts ? this.getSummary() : undefined, + }) + } + public printRecords(showDetails = false): void { const records = this.getRecords() if (records.length === 0) { @@ -744,9 +773,17 @@ export class KeeperVault { return result } - public async deleteRecord(recordUid: string): Promise { + public async deleteRecord(uidOrTitle: string): Promise { const auth = this.getAuthOrThrow() - const result = await deleteRecordOp(auth, recordUid) + const record = this.getRecordByUid(uidOrTitle) || this.findRecord(uidOrTitle) + if (!record?.uid) { + return { + recordUid: uidOrTitle, + success: false, + message: `Record "${uidOrTitle}" not found`, + } + } + const result = await deleteRecordOp(auth, this.storage, record.uid) if (result.success) await this.syncIfNeeded() return result }