diff --git a/KeeperSdk/package-lock.json b/KeeperSdk/package-lock.json index 783ba11e..1fabc359 100644 --- a/KeeperSdk/package-lock.json +++ b/KeeperSdk/package-lock.json @@ -9,7 +9,8 @@ "version": "1.1.1", "license": "ISC", "dependencies": { - "@keeper-security/keeperapi": "^18.0.4", + "@keeper-security/keeperapi": "18.0.6", + "asmcrypto.js": "^2.3.2", "ts-node": "^10.7.0", "typescript": "^4.6.3" }, @@ -56,9 +57,9 @@ } }, "node_modules/@keeper-security/keeperapi": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.5.tgz", - "integrity": "sha512-13P0mDMgaeUsbsQxlUqV1SMX0X9VAQ5aGa2antZDJWifqNqkZbTQEX/5evuZQN/tsTBn30/2DANpsCQyB1uQmw==", + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/@keeper-security/keeperapi/-/keeperapi-18.0.6.tgz", + "integrity": "sha512-7HE9mBBnXJ3K5obWQ77J2arjlCVNpUnwSW+2oSVJKCZdQD2BkqEthigB45VK0uSQ1LxPB7R8vnos7lMpvMsVHA==", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json index 755f8510..c0601ff2 100644 --- a/KeeperSdk/package.json +++ b/KeeperSdk/package.json @@ -8,10 +8,13 @@ "directory": "KeeperSdk" }, "license": "ISC", - "main": "src/index.ts", + "main": "dist/index.js", + "browser": "dist/browser.js", + "types": "dist/index.d.ts", "scripts": { "build": "tsc", - "clean": "rm -rf dist", + "clean": "rm -rf dist node_modules", + "rebuild": "npm run clean && npm install && npm run link-local && npm run build", "link-local": "npm link ../keeperapi", "format": "prettier --write .", "format:check": "prettier --check .", @@ -21,8 +24,9 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@keeper-security/keeperapi": "^18.0.4", + "@keeper-security/keeperapi": "18.0.6", "ts-node": "^10.7.0", + "asmcrypto.js": "^2.3.2", "typescript": "^4.6.3" }, "devDependencies": { 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/api.ts b/KeeperSdk/src/api.ts new file mode 100644 index 00000000..b38266de --- /dev/null +++ b/KeeperSdk/src/api.ts @@ -0,0 +1,400 @@ +export { SessionManager } from './auth/SessionManager' +export type { + KeeperJsonConfig, + ConfigLoader, + ConfigurationUser, + ConfigurationServerConfig, + ConfigurationDeviceConfig, +} from './auth/SessionManager' +export { connectSdkPlatform, getSdkPlatform, isSdkPlatformConnected } from './platform' +export type { SdkPlatform, SdkReadline, SdkRuntime } from './platform' + +export { InMemoryStorage } from './storage/InMemoryStorage' + +export { + Logger, + ConsoleLogger, + LogLevel, + logger, + setLogger, + getLogger, + resetLogger, + KeeperSdkError, + isKeeperError, + extractErrorMessage, + extractResultCode, + SdkDefaults, + AuthDefaults, + ResultCodes, + KEEPER_PUBLIC_HOSTS, + isBoolean, + isString, + isNonEmptyString, + isNumber, + isObject, + anyIsBoolean, + EMAIL_PATTERN, + EMAIL_LIST_SEPARATOR_PATTERN, + isValidEmail, + DEFAULT_PASSWORD_LENGTH, + PW_SPECIAL_CHARACTERS, + GEN_PASSWORD_ALGORITHMS, + KeeperPasswordGenerator, + generatePasswordFromOptions, + resolveGenPasswordAlgorithm, + generatePassword, + parseGenParametersFromValue, + isGenerateFieldValue, + parseGeneratePasswordFlag, +} from './utils' +export type { + ILogger, + Nullable, + Optional, + DeepPartial, + Immutable, + GenPasswordAlgorithm, + PasswordGenerationOptions, + PasswordComplexityPolicy, + PassphraseGenOptions, +} from './utils' + +export { + searchRecords, + formatRecord, + formatRecordFields, + getRecordTitle, + getRecordType, + getRecordFields, + getRecordSummary, + getRecordDescription, + getRecordCategory, + getRecordPassword, + getRecordLogin, + getRecordUrl, + getRecordTotpUrl, + RecordVersion, +} from './records/RecordUtils' +export type { RecordSummary } from './records/RecordUtils' +export { parseTotpUrl, getTotpCode } from './records/Totp' +export type { TotpAlgorithm, TotpParams, TotpCode } from './records/Totp' +export { addRecord, updateRecord, deleteRecord, getRecordHistory, moveRecord } from './records/RecordOperations' +export type { + PasswordRecordData, + TypedRecordData, + RecordFieldInput, + NewRecordInput, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + HistoryEntry, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from './records/RecordOperations' + +export { + shareRecord, + removeRecordShare, + getRecordShareInfo, + loadUserShareKeys, + encryptKeyForRecipient, + sendShareInviteIfNeeded, + loadUserShareKeysOrInvite, + parseRecordSharingStatus, + buildNsfRecordSharePermission, + buildNsfRecordRevokePermission, +} from './sharing/Sharing' +export type { + ShareRecordInput, + ShareRecordResult, + RemoveShareInput, + RemoveShareResult, + RecordShareInfo, + RecordUserPermission, + RecordSharedFolderPermission, + UserShareKeys, +} from './sharing/Sharing' + +export { KeeperVault } from './vault/KeeperVault' +export type { KeeperVaultConfig, VaultSummary } from './vault/KeeperVault' + +export { buildWhoamiInfo, normalizeServerHost, resolveDataCenter } from './account/whoamiInfo' +export type { WhoamiInfo, BuildWhoamiInfoInput } from './account/whoamiInfo' + +export type { SessionRestoreInput } from './auth/sessionRestore' +export { + toSessionParams, + validateSessionRestoreInput, + sessionRestoreFromJson, + resolveSessionRestorePayload, +} from './auth/sessionRestore' + +export { getFolder, findFolder, GetFolderFormat } from './folders/getFolder' +export type { + GetFolderOptions, + GetFolderResult, + GetFolderResultFolder, + GetFolderResultSharedFolder, + GetFolderFormatInput, + FoundFolder, +} from './folders/getFolder' + +export { + FolderKind, + ParentFolderKind, + FolderObjectType, + FolderResultStatus, + DeleteResolution, + DeleteObjectType, + folderKindFromString, +} from './folders/folderHelpers' +export type { FolderKindOrLiteral } from './folders/folderHelpers' + +export { listFolder, findFolderUidByNameOrUid, listRootUserFolders } from './folders/listFolder' +export type { + ListFolderOptions, + ListFolderResult, + ListFolderFolderSimple, + ListFolderRecordSimple, + ListFolderFolderDetail, + ListFolderRecordDetail, +} from './folders/listFolder' + +export { + listSharedFolders, + formatSharedFoldersTable, + renderSharedFoldersAsciiTable, +} from './sharedFolders/listSharedFolders' +export type { + ListSharedFoldersOptions, + ListSharedFolderRow, + FormattedSharedFoldersTable, +} from './sharedFolders/listSharedFolders' + +export { shareFolder, ShareFolderAction, ShareFolderUserResultStatus } from './sharedFolders/shareFolder' +export type { + ShareFolderActionInput, + ShareFolderInput, + ShareFolderResult, + ShareFolderUserStatus, +} from './sharedFolders/shareFolder' + +export { + changeDirectory, + createVaultFolderSession, + tryResolvePath, + resolveSingleFolder, + getWorkingFolderDisplayName, + findParentFolderUid, + splitPathComponents, +} from './folders/changeDirectory' +export type { VaultFolderSession, ChangeDirectoryResult, TryResolvePathResult } from './folders/changeDirectory' + +export { addFolder, mkdir } from './folders/addFolder' +export type { AddFolderInput, AddFolderResult, MkdirOptions } from './folders/addFolder' + +export { updateFolder, renameFolder, updateSharedFolderPermissions } from './folders/updateFolder' +export type { UpdateFolderInput, UpdateFolderResult, RenameFolderResult } from './folders/updateFolder' + +export { deleteFolder, rmdir, resolveRmdirPatternsToFolderUids, buildFolderDeleteObject } from './folders/deleteFolder' +export type { DeleteFolderResult, RmdirOptions } from './folders/deleteFolder' + +export { + buildFolderTree, + renderFolderTreeAscii, + folderTreeAscii, + userPermissionToText, + recordPermissionToText, +} from './folders/folderTree' +export type { FolderTreeBuildOptions, FolderTreeNode, FolderTreeResult } from './folders/folderTree' + +export { FolderManager } from './folders/FolderManager' +export type { AuthProvider, SharedFolderPermissionsInput } from './folders/FolderManager' + +export { SharedFolderManager } from './sharedFolders/SharedFolderManager' + +export { + listTeams, + formatTeamsTable, + renderTeamsAsciiTable, + formatTeamRestricts, + TeamColumn, + SUPPORTED_TEAM_COLUMNS, + DEFAULT_TEAM_COLUMNS, +} from './teams/listTeams' +export type { + ListTeamsOptions, + ListTeamRow, + TeamColumnInput, + FormattedTeamsTable, + FormatTeamsTableOptions, +} from './teams/listTeams' + +export { viewTeam, formatTeamView, teamViewTable } from './teams/viewTeam' +export type { + TeamView, + TeamRoleInfo, + TeamUserInfo, + FormatTeamViewOptions, + FormattedTeamViewTable, + TeamViewTableRow, +} from './teams/viewTeam' + +export { changeTeamRoles, TeamRoleStatus } from './teams/teamRole' +export type { ChangeTeamRolesInput, TeamRoleResult, TeamRoleItemResult } from './teams/teamRole' + +export { + addUsersToTeams, + removeUsersFromTeams, + formatTeamUserResult, + renderTeamUserAsciiTable, + TeamUserStatus, + TeamUserSkipReason, +} from './users/teamUser' +export type { + AddUsersToTeamsInput, + RemoveUsersFromTeamsInput, + TeamUserItemResult, + TeamUserResult, + FormattedTeamUserTable, +} from './users/userTypes' + +export { formatAddUserResult, renderAddUserAsciiTable, AddUserStatus } from './users/addUser' +export { formatUpdateUserResult, renderUpdateUserAsciiTable, UpdateUserStatus } from './users/updateUser' +export { formatDeleteUserResult, renderDeleteUserAsciiTable, DeleteUserStatus } from './users/deleteUser' +export { + formatUserActionResult, + renderUserActionAsciiTable, + UserAction, + UserActionStatus, + UserActionSkipReason, +} from './users/actionUser' + +export { + formatAddTeamResult, + renderAddTeamAsciiTable, + AddTeamStatus, + AddTeamSkipReason, + AddTeamSourceKind, +} from './teams/addTeam' +export { formatUpdateTeamResult, renderUpdateTeamAsciiTable, UpdateTeamStatus } from './teams/updateTeam' +export { formatDeleteTeamResult, renderDeleteTeamAsciiTable, DeleteTeamStatus } from './teams/deleteTeam' + +export { + listUsers, + formatUsersTable, + renderUsersAsciiTable, + UserColumn, + SUPPORTED_USER_COLUMNS, + DEFAULT_USER_COLUMNS, +} from './users/listUsers' +export type { + UserColumnInput, + ListUsersOptions, + ListUserRow, + FormattedUsersTable, + FormatUsersTableOptions, +} from './users/listUsers' + +export { viewUser, formatUserView, userViewTable } from './users/viewUser' +export type { UserView, FormatUserViewOptions, FormattedUserViewTable, UserViewTableRow } from './users/userTypes' + +export { + listRoles, + formatRolesTable, + renderRolesAsciiTable, + RoleColumn, + SUPPORTED_ROLE_COLUMNS, + DEFAULT_ROLE_COLUMNS, + ALL_COLUMNS_WILDCARD, + viewRole, + formatRoleView, + roleViewTable, + formatAddRoleResult, + renderAddRoleAsciiTable, + formatUpdateRoleResult, + renderUpdateRoleAsciiTable, + formatDeleteRoleResult, + renderDeleteRoleAsciiTable, + formatCopyRoleResult, + renderCopyRoleAsciiTable, + formatRoleUserResult, + renderRoleUserAsciiTable, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, +} from './roles' +export type { + ListRolesOptions, + ListRoleRow, + RoleColumnInput, + FormattedRolesTable, + FormatRolesTableOptions, + RoleView, + RoleTeamInfo, + RoleUserInfo, + RoleManagedNodeInfo, + RoleEnforcementInfo, + FormatRoleViewOptions, + FormattedRoleViewTable, + RoleViewTableRow, +} from './roles' + +export { + listNodes, + formatNodesTable, + renderNodesAsciiTable, + NodeColumn, + SUPPORTED_NODE_COLUMNS, + DEFAULT_NODE_COLUMNS, + viewNode, + formatNodeView, + nodeViewTable, + formatAddNodeResult, + renderAddNodeAsciiTable, + formatUpdateNodeResult, + renderUpdateNodeAsciiTable, + formatDeleteNodeResult, + renderDeleteNodeAsciiTable, +} from './nodes' +export type { + ListNodesOptions, + ListNodeRow, + NodeColumnInput, + FormattedNodesTable, + FormatNodesTableOptions, + NodeView, + FormatNodeViewOptions, + FormattedNodeViewTable, + NodeViewTableRow, + AddNodeInput, + AddNodeResult, + FormattedAddNodeTable, + UpdateNodeInput, + UpdateNodeResult, + FormattedUpdateNodeTable, + DeleteNodeInput, + DeleteNodeResult, + FormattedDeleteNodeTable, +} from './nodes' + +export { Auth, KeeperEnvironment, syncDown, Authentication } from '@keeper-security/keeperapi' + +export type { + DRecord, + DRecordMetadata, + DSharedFolder, + DTeam, + DUserFolder, + VaultStorage, + SyncResult, + SyncDownOptions, + ClientConfiguration, + DeviceConfig, + SessionStorage, + AuthUI3, + KeeperError, + LoginError, +} from '@keeper-security/keeperapi' diff --git a/KeeperSdk/src/auth/ConsoleAuthUI.ts b/KeeperSdk/src/auth/ConsoleAuthUI.ts index 69a53da1..e4f8cb89 100644 --- a/KeeperSdk/src/auth/ConsoleAuthUI.ts +++ b/KeeperSdk/src/auth/ConsoleAuthUI.ts @@ -1,7 +1,6 @@ -import readline from 'readline/promises' -import { setTimeout as delay } from 'timers/promises' import type { AuthUI3, DeviceApprovalChannel, TwoFactorChannelData } from '@keeper-security/keeperapi' import { Authentication } from '@keeper-security/keeperapi' +import { getSdkPlatform } from '../platform' import { logger, extractErrorMessage, KeeperSdkError, AuthDefaults, ResultCodes } from '../utils' export class ConsoleAuthUI implements AuthUI3 { @@ -54,18 +53,19 @@ export class ConsoleAuthUI implements AuthUI3 { } private static async waitWithCancel(timeoutMs: number, cancel?: Promise): Promise { + const platform = getSdkPlatform() if (!cancel) { - await delay(timeoutMs) + await platform.delay(timeoutMs) return } - await Promise.race([delay(timeoutMs), cancel]) + await Promise.race([platform.delay(timeoutMs), cancel]) } - public async waitForDeviceApproval(channels: DeviceApprovalChannel[], isCloud: boolean): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + public async waitForDeviceApproval(channels: DeviceApprovalChannel[], _isCloud: boolean): Promise { + const rl = getSdkPlatform().createReadline( + typeof process !== 'undefined' ? process.stdin : undefined, + typeof process !== 'undefined' ? process.stdout : undefined + ) try { logger.info('\n--- Device Approval Required ---') @@ -106,10 +106,10 @@ export class ConsoleAuthUI implements AuthUI3 { } public async waitForTwoFactorCode(channels: TwoFactorChannelData[], cancel: Promise): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + const rl = getSdkPlatform().createReadline( + typeof process !== 'undefined' ? process.stdin : undefined, + typeof process !== 'undefined' ? process.stdout : undefined + ) try { logger.info('\n--- Two-Factor Authentication Required ---') @@ -155,10 +155,10 @@ export class ConsoleAuthUI implements AuthUI3 { } public async getPassword(isAlternate: boolean): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + const rl = getSdkPlatform().createReadline( + typeof process !== 'undefined' ? process.stdin : undefined, + typeof process !== 'undefined' ? process.stdout : undefined + ) try { const label = isAlternate ? 'alternate master password' : 'master password' return (await rl.question(`Enter your ${label}: `)).trim() diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts index 545932f9..7818b016 100644 --- a/KeeperSdk/src/auth/ConsoleLogin.ts +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -1,6 +1,7 @@ -import readline from 'readline/promises' import type { AuthUI3 } from '@keeper-security/keeperapi' import { KeeperVault } from '../vault/KeeperVault' +import { getSdkPlatform } from '../platform' +import type { SdkReadline } from '../platform' import { logger, extractResultCode, @@ -12,8 +13,7 @@ import { KEEPER_PUBLIC_HOSTS, } from '../utils' import { ConsoleAuthUI } from './ConsoleAuthUI' -import { FileConfigLoader } from './SessionManager' -import type { KeeperJsonConfig } from './SessionManager' +import type { KeeperJsonConfig } from './config' const DEFAULT_REGION = 'US' const MASK_CHAR = '*' @@ -35,8 +35,6 @@ type ConsoleHandlers = { stderrWrite: typeof process.stderr.write } -const defaultConfigLoader = new FileConfigLoader() - let rlManager: ReadlineManager | null = null let suppressionDepth = 0 let originals: ConsoleHandlers | null = null @@ -69,14 +67,11 @@ function classifyInputChar(ch: string): CliCharAction { } class ReadlineManager { - private rl: readline.Interface | null = null + private rl: SdkReadline | null = null - private getOrCreate(): readline.Interface { + private getOrCreate(): SdkReadline { if (!this.rl) { - this.rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) + this.rl = getSdkPlatform().createReadline(process.stdin, process.stdout) } return this.rl } @@ -154,7 +149,7 @@ export function prompt(question: string, masked = false): Promise { export async function loadKeeperConfig(preloaded?: KeeperJsonConfig): Promise { if (preloaded) return preloaded - return defaultConfigLoader.load() + return getSdkPlatform().createFileConfigLoader().load() } export async function resolveServer(username?: string, preloadedConfig?: KeeperJsonConfig): Promise { diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts index 54d64d34..7d48e5f7 100644 --- a/KeeperSdk/src/auth/SessionManager.ts +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -1,6 +1,3 @@ -import fs from 'fs/promises' -import path from 'path' -import os from 'os' import { normal64Bytes, type DeviceConfig, @@ -8,37 +5,19 @@ import { type KeeperHost, type SessionParams, } from '@keeper-security/keeperapi' -import { logger, extractErrorMessage, SdkDefaults } from '../utils' +import { getSdkPlatform } from '../platform' +import { logger, extractErrorMessage } from '../utils' import type { Nullable } from '../utils' +import type { ConfigLoader, KeeperJsonConfig, ConfigurationServerConfig, ConfigurationUser } from './config' +import { isValidKeeperConfig } from './config' -export type ConfigurationUser = { - user?: string - server?: string - last_device?: { device_token?: string } -} - -export type ConfigurationServerConfig = { - server?: string - clone_code?: string -} - -export type ConfigurationDeviceConfig = { - device_token?: string - private_key?: string - server_info?: Array -} - -export type KeeperJsonConfig = { - last_login?: string - last_server?: string - user?: string - server?: string - device_token?: string - private_key?: string - clone_code?: string - users?: Array - devices?: Array -} +export type { + KeeperJsonConfig, + ConfigLoader, + ConfigurationUser, + ConfigurationServerConfig, + ConfigurationDeviceConfig, +} from './config' type ResolvedDevice = { deviceToken: Uint8Array @@ -51,41 +30,6 @@ type DeviceCacheEntry = { device: Nullable } -export interface ConfigLoader { - load(): Promise - save(config: KeeperJsonConfig): Promise - readonly configDir: string -} - -export class FileConfigLoader implements ConfigLoader { - public readonly configDir: string - - constructor(configDir?: string) { - this.configDir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) - } - - async load(): Promise { - const configPath = path.join(this.configDir, 'config.json') - try { - const content = await fs.readFile(configPath, 'utf-8') - const parsed: unknown = JSON.parse(content) - if (SessionManager.isValidKeeperConfig(parsed)) { - return parsed - } - } catch (err) { - logger.debug('Failed to load keeper config:', extractErrorMessage(err)) - } - return {} - } - - async save(config: KeeperJsonConfig): Promise { - const configPath = path.join(this.configDir, 'config.json') - await fs.writeFile(configPath, JSON.stringify(config, null, 2), { - mode: 0o600, - }) - } -} - export class SessionManager implements SessionStorage { private readonly configLoader: ConfigLoader private sessionParams: Nullable = null @@ -98,8 +42,10 @@ export class SessionManager implements SessionStorage { constructor(configDir?: string) constructor(loader: ConfigLoader) constructor(configDirOrLoader?: string | ConfigLoader) { - if (typeof configDirOrLoader === 'string' || configDirOrLoader === undefined) { - this.configLoader = new FileConfigLoader(configDirOrLoader as string | undefined) + if (typeof configDirOrLoader === 'string') { + this.configLoader = getSdkPlatform().createFileConfigLoader(configDirOrLoader) + } else if (configDirOrLoader === undefined) { + this.configLoader = getSdkPlatform().createFileConfigLoader() } else { this.configLoader = configDirOrLoader } @@ -187,7 +133,7 @@ export class SessionManager implements SessionStorage { ) if (user?.last_device?.device_token) { const device = (parsed.devices || []).find( - (configDevice) => configDevice.device_token === user.last_device.device_token + (configDevice) => configDevice.device_token === user.last_device!.device_token ) if (device?.server_info) { const serverInfo = device.server_info.find((entry) => entry.server === host) @@ -282,10 +228,6 @@ export class SessionManager implements SessionStorage { } public static isValidKeeperConfig(value: unknown): value is KeeperJsonConfig { - if (typeof value !== 'object' || value === null) return false - const obj = value as Record - if (obj.users !== undefined && !Array.isArray(obj.users)) return false - if (obj.devices !== undefined && !Array.isArray(obj.devices)) return false - return true + return isValidKeeperConfig(value) } } diff --git a/KeeperSdk/src/auth/UnavailableAuthUI.ts b/KeeperSdk/src/auth/UnavailableAuthUI.ts new file mode 100644 index 00000000..689d2e98 --- /dev/null +++ b/KeeperSdk/src/auth/UnavailableAuthUI.ts @@ -0,0 +1,19 @@ +import type { AuthUI3, DeviceApprovalChannel, TwoFactorChannelData } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' + +const MSG = + 'Console authentication is disabled (useConsoleAuth: false). Provide authUI or use a host that handles login (e.g. keeper-shell password prompt).' + +export class UnavailableAuthUI implements AuthUI3 { + public async waitForDeviceApproval(_channels: DeviceApprovalChannel[], _isCloud: boolean): Promise { + throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN) + } + + public async waitForTwoFactorCode(_channels: TwoFactorChannelData[], _cancel: Promise): Promise { + throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN) + } + + public async getPassword(_isAlternate: boolean): Promise { + throw new KeeperSdkError(MSG, ResultCodes.NOT_LOGGED_IN) + } +} diff --git a/KeeperSdk/src/auth/config.ts b/KeeperSdk/src/auth/config.ts new file mode 100644 index 00000000..af3d2dd7 --- /dev/null +++ b/KeeperSdk/src/auth/config.ts @@ -0,0 +1,42 @@ +export type ConfigurationUser = { + user?: string + server?: string + last_device?: { device_token?: string } +} + +export type ConfigurationServerConfig = { + server?: string + clone_code?: string +} + +export type ConfigurationDeviceConfig = { + device_token?: string + private_key?: string + server_info?: Array +} + +export type KeeperJsonConfig = { + last_login?: string + last_server?: string + user?: string + server?: string + device_token?: string + private_key?: string + clone_code?: string + users?: Array + devices?: Array +} + +export interface ConfigLoader { + load(): Promise + save(config: KeeperJsonConfig): Promise + readonly configDir: string +} + +export function isValidKeeperConfig(value: unknown): value is KeeperJsonConfig { + if (typeof value !== 'object' || value === null) return false + const obj = value as Record + if (obj.users !== undefined && !Array.isArray(obj.users)) return false + if (obj.devices !== undefined && !Array.isArray(obj.devices)) return false + return true +} diff --git a/KeeperSdk/src/auth/node/FileConfigLoader.ts b/KeeperSdk/src/auth/node/FileConfigLoader.ts new file mode 100644 index 00000000..328fa254 --- /dev/null +++ b/KeeperSdk/src/auth/node/FileConfigLoader.ts @@ -0,0 +1,36 @@ +import fs from 'fs/promises' +import path from 'path' +import os from 'os' +import type { ConfigLoader, KeeperJsonConfig } from '../config' +import { isValidKeeperConfig } from '../config' +import { logger, extractErrorMessage, SdkDefaults } from '../../utils' + +/** CAUTION: This is a Node-only class. */ +export class FileConfigLoader implements ConfigLoader { + public readonly configDir: string + + constructor(configDir?: string) { + this.configDir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) + } + + async load(): Promise { + const configPath = path.join(this.configDir, 'config.json') + try { + const content = await fs.readFile(configPath, 'utf-8') + const parsed: unknown = JSON.parse(content) + if (isValidKeeperConfig(parsed)) { + return parsed + } + } catch (err) { + logger.debug('Failed to load keeper config:', extractErrorMessage(err)) + } + return {} + } + + async save(config: KeeperJsonConfig): Promise { + const configPath = path.join(this.configDir, 'config.json') + await fs.writeFile(configPath, JSON.stringify(config, null, 2), { + mode: 0o600, + }) + } +} diff --git a/KeeperSdk/src/auth/sessionRestore.ts b/KeeperSdk/src/auth/sessionRestore.ts new file mode 100644 index 00000000..fcba2f45 --- /dev/null +++ b/KeeperSdk/src/auth/sessionRestore.ts @@ -0,0 +1,218 @@ +import { Authentication, normal64Bytes, type SessionParams } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' + +type UserType = SessionParams['userType'] + +const UserTypeValues = { + normal: 'normal' as UserType, + cloudSso: 'cloud_sso' as UserType, + onsiteSso: 'onsite_sso' as UserType, +} + +export type SessionRestoreInput = { + accountUid: string + clientKey: string + dataKey: string + eccPrivateKey: string + eccPublicKey: string + messageSessionUid: string + privateKey: string + sessionToken: string + sessionTokenType: number | string + username: string + userType: number | string + ssoLogoutUrl?: string + ssoSessionId?: string + enterprisePublicKey?: string + enterpriseEccPublicKey?: string +} + +const REQUIRED_KEYS: (keyof SessionRestoreInput)[] = [ + 'accountUid', + 'clientKey', + 'dataKey', + 'eccPrivateKey', + 'eccPublicKey', + 'messageSessionUid', + 'privateKey', + 'sessionToken', + 'sessionTokenType', + 'username', + 'userType', +] + +function decodeBytes(label: string, value: string | undefined, required: boolean): Uint8Array | undefined { + const trimmed = typeof value === 'string' ? value.trim() : '' + if (!trimmed) { + if (required) { + throw new KeeperSdkError(`restore-session: missing required field: ${label}`, ResultCodes.MISSING_USERNAME) + } + return undefined + } + try { + return normal64Bytes(trimmed) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + throw new KeeperSdkError( + `restore-session: invalid base64 for ${label}: ${msg}`, + ResultCodes.INVALID_CREDENTIALS + ) + } +} + +function parseUserType(value: number | string): UserType { + if (typeof value === 'number') { + switch (value) { + case 0: + return UserTypeValues.normal + case 1: + return UserTypeValues.cloudSso + case 2: + return UserTypeValues.onsiteSso + default: + break + } + } + const s = String(value).toLowerCase() + if (s === 'normal' || s === '0') return UserTypeValues.normal + if (s === 'cloud_sso' || s === 'cloudsso' || s === '1') return UserTypeValues.cloudSso + if (s === 'onsite_sso' || s === 'onsitesso' || s === '2') return UserTypeValues.onsiteSso + throw new KeeperSdkError(`restore-session: unknown userType: ${value}`, ResultCodes.INVALID_CREDENTIALS) +} + +function parseSessionTokenType(value: number | string): Authentication.SessionTokenType { + const n = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(n)) { + throw new KeeperSdkError(`restore-session: invalid sessionTokenType: ${value}`, ResultCodes.INVALID_CREDENTIALS) + } + return n as Authentication.SessionTokenType +} + +export function validateSessionRestoreInput(input: Partial): SessionRestoreInput { + const missing = REQUIRED_KEYS.filter((k) => { + const v = input[k] + return v === undefined || v === null || (typeof v === 'string' && !v.trim()) + }) + if (missing.length > 0) { + throw new KeeperSdkError( + `restore-session: missing required field(s): ${missing.join(', ')}`, + ResultCodes.MISSING_USERNAME + ) + } + return input as SessionRestoreInput +} + +/** Build keeperapi {@link SessionParams} from extension-style strings. */ +export function toSessionParams(input: SessionRestoreInput): SessionParams { + const enterprisePublicKey = decodeBytes('enterprisePublicKey', input.enterprisePublicKey, false) + const enterpriseEccPublicKey = decodeBytes('enterpriseEccPublicKey', input.enterpriseEccPublicKey, false) + + return { + accountUid: decodeBytes('accountUid', input.accountUid, true)!, + clientKey: decodeBytes('clientKey', input.clientKey, true)!, + dataKey: decodeBytes('dataKey', input.dataKey, true)!, + eccPrivateKey: decodeBytes('eccPrivateKey', input.eccPrivateKey, true)!, + eccPublicKey: decodeBytes('eccPublicKey', input.eccPublicKey, true)!, + messageSessionUid: decodeBytes('messageSessionUid', input.messageSessionUid, true)!, + privateKey: decodeBytes('privateKey', input.privateKey, true)!, + sessionToken: input.sessionToken.trim(), + sessionTokenType: parseSessionTokenType(input.sessionTokenType), + username: input.username.trim(), + userType: parseUserType(input.userType), + ssoLogoutUrl: input.ssoLogoutUrl?.trim() ?? '', + ssoSessionId: input.ssoSessionId?.trim() ?? '', + ...(enterprisePublicKey ? { enterprisePublicKey } : {}), + ...(enterpriseEccPublicKey ? { enterpriseEccPublicKey } : {}), + } +} + +function assertBodyIsJsonNotHtml(body: string, source: string): void { + const head = body.trimStart().slice(0, 32).toLowerCase() + if (head.startsWith(') +} + +export async function resolveSessionRestorePayload( + raw: string, + readFile?: (path: string) => Promise +): Promise { + const text = raw.trim() + if (readFile && looksLikeFilePath(text)) { + const body = await readFile(text) + assertBodyIsJsonNotHtml(body, text) + return sessionRestoreFromJson(body) + } + try { + return sessionRestoreFromJson(text) + } catch (e) { + if (looksLikeInlineJson(text) || !readFile) { + throw e + } + const body = await readFile(text) + assertBodyIsJsonNotHtml(body, text) + return sessionRestoreFromJson(body) + } +} diff --git a/KeeperSdk/src/browser.ts b/KeeperSdk/src/browser.ts new file mode 100644 index 00000000..01834916 --- /dev/null +++ b/KeeperSdk/src/browser.ts @@ -0,0 +1,8 @@ +import { connectSdkPlatform } from './platform' +import { browserSdkPlatform } from './platform/browser/platform' + +connectSdkPlatform(browserSdkPlatform) + +export * from './api' +/** Nested Share Folder / Keeper Drive APIs (also on Node `index.ts`). */ +export * from './nestedShareFolders' 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..d2902dad 100644 --- a/KeeperSdk/src/folders/addFolder.ts +++ b/KeeperSdk/src/folders/addFolder.ts @@ -24,6 +24,7 @@ export type AddFolderInput = { manageRecords?: boolean canShare?: boolean canEdit?: boolean + color?: string | null } export type AddFolderResult = { @@ -40,6 +41,7 @@ export type MkdirOptions = { manageRecords?: boolean canShare?: boolean canEdit?: boolean + color?: string | null } type ParentContext = { @@ -152,11 +154,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 +198,7 @@ export async function addFolder(auth: Auth, storage: InMemoryStorage, input: Add message: reason, } } + return { folderUid, success: true } } catch (err) { return { @@ -282,6 +291,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..dd028be4 100644 --- a/KeeperSdk/src/folders/changeDirectory.ts +++ b/KeeperSdk/src/folders/changeDirectory.ts @@ -5,12 +5,14 @@ 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 + /** Full display path e.g. `My Vault/Subfolder` (updated on cd). */ + workingFolderDisplayPath?: string } export type ChangeDirectoryResult = { @@ -201,6 +203,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 +229,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..50847a5f 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' @@ -20,12 +21,43 @@ export type DeleteFolderResult = { success: boolean message?: string cancelled?: boolean + /** Folder list shown before delete (for CLI output). */ + foldersPreview?: string } export type RmdirOptions = { force?: boolean quiet?: boolean confirm?: (summary: string) => boolean | Promise + /** Override prompt (e.g. browser shell UI). Defaults to SdkPlatform readline. */ + ask?: (prompt: string) => Promise +} + +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): FolderKind { @@ -156,8 +188,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 +203,36 @@ 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.' } + } + + // Interactive confirmation can outlive the pre_delete token; fetch a fresh one. + 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 +240,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 +311,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 +333,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..910f5209 100644 --- a/KeeperSdk/src/folders/folderHelpers.ts +++ b/KeeperSdk/src/folders/folderHelpers.ts @@ -72,11 +72,22 @@ export function folderKindFromString(value: string | undefined | null): FolderKi } } +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 } +/** Vault folder color from user-folder data (`none` and missing → undefined). */ +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..da6dd28a 100644 --- a/KeeperSdk/src/folders/listFolder.ts +++ b/KeeperSdk/src/folders/listFolder.ts @@ -17,6 +17,7 @@ import { globToRegex, sharedFolderFolderName, sharedFolderName, + userFolderColor, userFolderName, } from './folderHelpers' @@ -26,12 +27,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 +136,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 } : {}), }) } @@ -289,10 +295,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 +338,32 @@ export async function listFolder(storage: InMemoryStorage, options: ListFolderOp } } + 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 33ce69a1..135869cc 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -1,18 +1,18 @@ +import { connectSdkPlatform } from './platform' +import { nodeSdkPlatform } from './platform/node/platform' + +connectSdkPlatform(nodeSdkPlatform) + +export * from './api' + +/** Node.js Commander / CLI helpers (readline, ~/.keeper config). */ export { ConsoleAuthUI } from './auth/ConsoleAuthUI' -export { SessionManager, FileConfigLoader } from './auth/SessionManager' -export type { - KeeperJsonConfig, - ConfigLoader, - ConfigurationUser, - ConfigurationServerConfig, - ConfigurationDeviceConfig, -} from './auth/SessionManager' +export { FileConfigLoader } from './auth/node/FileConfigLoader' export { login, cleanup, prompt, suppressLogs, loadKeeperConfig, resolveServer } from './auth/ConsoleLogin' export { InMemoryStorage } from './storage/InMemoryStorage' export { - Logger, ConsoleLogger, LogLevel, logger, @@ -48,25 +48,29 @@ export { EMAIL_LIST_SEPARATOR_PATTERN, isValidEmail, resolveSearchPattern, + DEFAULT_PASSWORD_LENGTH, + PW_SPECIAL_CHARACTERS, + GEN_PASSWORD_ALGORITHMS, + KeeperPasswordGenerator, + generatePasswordFromOptions, + resolveGenPasswordAlgorithm, + generatePassword, + parseGenParametersFromValue, + isGenerateFieldValue, + parseGeneratePasswordFlag, +} from './utils' +export type { + ILogger, + Nullable, + Optional, + DeepPartial, + Immutable, + GenPasswordAlgorithm, + PasswordGenerationOptions, + PasswordComplexityPolicy, + PassphraseGenOptions, } from './utils' -export type { ILogger, Nullable, Optional, DeepPartial, Immutable } from './utils' -export { - searchRecords, - formatRecord, - getRecordTitle, - getRecordType, - getRecordFields, - getRecordSummary, - getRecordPassword, - getRecordLogin, - getRecordUrl, - getRecordTotpUrl, - RecordVersion, -} from './records/RecordUtils' -export type { RecordSummary } from './records/RecordUtils' -export { parseTotpUrl, getTotpCode } from './records/Totp' -export type { TotpAlgorithm, TotpParams, TotpCode } from './records/Totp' export { addRecord, updateRecord, deleteRecord, getRecordHistory, moveRecord } from './records/RecordOperations' export type { PasswordRecordData, @@ -138,14 +142,42 @@ export type { FormattedSharedFoldersTable, } from './sharedFolders/listSharedFolders' -export { shareFolder, ShareFolderAction, ShareFolderUserResultStatus } from './sharedFolders/shareFolder' +export { + shareFolder, + updateSharedFolderMembership, + ShareFolderAction, + ShareFolderUserResultStatus, +} from './sharedFolders/shareFolder' export type { ShareFolderActionInput, ShareFolderInput, ShareFolderResult, ShareFolderUserStatus, + SharedFolderMembershipUserGrant, + SharedFolderMembershipTeamGrant, + UpdateSharedFolderMembershipInput, + SharedFolderMembershipUpdateResult, } from './sharedFolders/shareFolder' +export { downloadMembership, buildAccountUidEmailMap, resolveUserEmail } from './sharedFolders/downloadMembership' +export type { + MembershipPermission, + MembershipSharedFolder, + MembershipTeam, + MembershipData, + DownloadMembershipOptions, +} from './sharedFolders/downloadMembership' + +export { applyMembership } from './sharedFolders/applyMembership' +export type { + ApplyMembershipInput, + ApplyMembershipOptions, + ApplyMembershipCounts, + ApplyMembershipFolderResult, + ApplyMembershipTeamMembershipResult, + ApplyMembershipResult, +} from './sharedFolders/applyMembership' + export { changeDirectory, createVaultFolderSession, @@ -173,6 +205,55 @@ export { userPermissionToText, recordPermissionToText, } from './folders/folderTree' + +export { + runAuditReport, + runActionReport, + runPasswordReport, + getPasswordStrength, + calculatePasswordScore, + parsePasswordPolicy, + isPasswordCompliant, + buildPasswordPolicySummary, + getAllowedActions, + getDefaultDaysSince, + AuditReportManager, + ActionReportManager, + EnterpriseReportManager, + AuditReportOrder, + AuditReportFormat, + AuditOutputFormat, + AuditAggregate, + SUMMARY_REPORT_TYPES, + CREATED_PRESETS, + TargetUserStatus, + AdminAction, + ActionReportColumn, + DEFAULT_ACTION_REPORT_COLUMNS, + SUPPORTED_ACTION_REPORT_COLUMNS, + DEFAULT_TRUNCATION_LENGTH, + SUPPORTED_RECORD_VERSIONS, +} from './enterpriseReport' +export type { + AuditReportOptions, + AuditReportResult, + AuditReportFilter, + CreatedFilterCriteria, + CreatedPreset, + AuditEventOverviewReportRow, + AuditReportType, + AuditSummaryReportType, + ActionReportEntry, + ActionReportOptions, + ActionReportResult, + ActionResult, + PasswordPolicy, + PasswordStrength, + PasswordReportRow, + PasswordReportOptions, + PasswordReportResult, +} from './enterpriseReport' + export type { FolderTreeBuildOptions, FolderTreeNode, FolderTreeResult } from './folders/folderTree' export { FolderManager } from './folders/FolderManager' @@ -245,6 +326,27 @@ export { formatDeleteRoleResult, renderDeleteRoleAsciiTable, DeleteRoleStatus, + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + RoleEnforcementStatus, + copyRoles, + formatCopyRoleResult, + renderCopyRoleAsciiTable, + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + RoleUserStatus, + RoleUserSkipReason, + manageRoleNodes, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + RoleManagedNodeStatus, + changeRolePrivileges, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, + RolePrivilegeStatus, } from './roles' export type { ListRolesOptions, @@ -278,56 +380,77 @@ export type { RoleToggleInput, RoleToggle, EnforcementPair, + SetRoleEnforcementsInput, + RoleEnforcementItemResult, + SetRoleEnforcementsResult, + FormattedRoleEnforcementTable, + CopyRoleInput, + CopyRoleResult, + FormattedCopyRoleTable, + AddUsersToRolesInput, + RemoveUsersFromRolesInput, + RoleUserItemResult, + RoleUserResult, + FormattedRoleUserTable, + ManageRoleNodesAction, + ManageRoleNodesInput, + RoleManagedNodeItemResult, + ManageRoleNodesResult, + FormattedManageRoleNodesTable, + ChangeRolePrivilegesInput, + RolePrivilegeItemResult, + ChangeRolePrivilegesResult, + FormattedRolePrivilegeTable, } from './roles' export { - runAuditReport, - runActionReport, - runPasswordReport, - getPasswordStrength, - calculatePasswordScore, - parsePasswordPolicy, - isPasswordCompliant, - buildPasswordPolicySummary, - getAllowedActions, - getDefaultDaysSince, - AuditReportManager, - ActionReportManager, - EnterpriseReportManager, - AuditReportOrder, - AuditReportFormat, - AuditOutputFormat, - AuditAggregate, - SUMMARY_REPORT_TYPES, - CREATED_PRESETS, - TargetUserStatus, - AdminAction, - ActionReportColumn, - DEFAULT_ACTION_REPORT_COLUMNS, - SUPPORTED_ACTION_REPORT_COLUMNS, - PW_SPECIAL_CHARACTERS, - DEFAULT_TRUNCATION_LENGTH, - SUPPORTED_RECORD_VERSIONS, -} from './enterpriseReport' + NodeManager, + listNodes, + formatNodesTable, + renderNodesAsciiTable, + NodeColumn, + SUPPORTED_NODE_COLUMNS, + DEFAULT_NODE_COLUMNS, + viewNode, + formatNodeView, + nodeViewTable, + addNodes, + formatAddNodeResult, + renderAddNodeAsciiTable, + AddNodeStatus, + AddNodeSkipReason, + updateNodes, + formatUpdateNodeResult, + renderUpdateNodeAsciiTable, + UpdateNodeStatus, + deleteNodes, + formatDeleteNodeResult, + renderDeleteNodeAsciiTable, + DeleteNodeStatus, +} from './nodes' export type { - AuditReportOptions, - AuditReportResult, - AuditReportFilter, - CreatedFilterCriteria, - CreatedPreset, - AuditEventOverviewReportRow, - AuditReportType, - AuditSummaryReportType, - ActionReportEntry, - ActionReportOptions, - ActionReportResult, - ActionResult, - PasswordPolicy, - PasswordStrength, - PasswordReportRow, - PasswordReportOptions, - PasswordReportResult, -} from './enterpriseReport' + ListNodesOptions, + ListNodeRow, + NodeColumnInput, + FormattedNodesTable, + FormatNodesTableOptions, + NodeView, + FormatNodeViewOptions, + FormattedNodeViewTable, + NodeViewTableRow, + AddNodeInput, + AddNodeResult, + AddNodeItemResult, + FormattedAddNodeTable, + UpdateNodeInput, + UpdateNodeResult, + UpdateNodeItemResult, + FormattedUpdateNodeTable, + DeleteNodeInput, + DeleteNodeResult, + DeleteNodeItemResult, + FormattedDeleteNodeTable, +} from './nodes' export { viewTeam, formatTeamView, teamViewTable } from './teams/viewTeam' export type { @@ -378,6 +501,9 @@ export type { FormattedDeleteTeamRow, } from './teams/deleteTeam' +export { changeTeamRoles, TeamRoleStatus } from './teams/teamRole' +export type { ChangeTeamRolesInput, TeamRoleResult, TeamRoleItemResult } from './teams/teamRole' + export { TeamManager } from './teams/TeamManager' export { @@ -464,6 +590,20 @@ export { TeamUserSkipReason, } from './users/teamUser' +export { + updateUsersOnTeams, + formatUpdateTeamUserResult, + renderUpdateTeamUserAsciiTable, + UpdateTeamUserStatus, + UpdateTeamUserSkipReason, +} from './users/updateTeamUser' +export type { + UpdateUsersOnTeamsInput, + UpdateTeamUserItemResult, + UpdateUsersOnTeamsResult, + FormattedUpdateTeamUserTable, +} from './users/updateTeamUser' + export { UserManager } from './users/UserManager' export { @@ -501,15 +641,18 @@ export { formatNsfDetail, formatNsfJson, formatNsfRecordJson, + formatNsfFolderJson, toNsfRecordJsonView, + toNsfFolderJsonView, + NSF_UNMASK_WARNING, linkNestedShareRecord, NsfRemoveOperation, + NsfRemoveFolderOperation, removeNestedShareRecords, formatRemoveNsfPreview, collectRemoveNsfWarnings, mkdirNestedShareFolder, NSF_FOLDER_COLORS, - NsfRemoveFolderOperation, removeNestedShareFolders, formatRemoveNsfFolderPreview, GetNsfRecordDetailsFormat, @@ -526,40 +669,30 @@ export { resolveNsfFieldValue, clearNsfRecordTypeCache, checkRecordEditPermission, + checkFolderEditPermission, + checkFolderSharePermission, + checkRecordSharePermission, + ROOT_FOLDER_UID, + NSF_RECORD_PERMISSION_ROLES, + NSF_MAX_FOLDER_UPDATES, + getFolderPermissionsForRole, updateNestedShareFolder, + updateNestedShareFolders, shareNestedShareFolder, shareNestedShareRecord, formatNsfRecordSharePlan, formatNsfRecordShareResults, formatNsfFolderShareResults, - parseShareExpiration, - parseShareExpirationValue, - validateShareExpirationTimestamp, - isShareExpirationNoop, - fetchNsfTeamPublicKeys, - encryptNsfFolderKeyForTeam, - resolveNsfShareRecipient, - MIN_SHARE_EXPIRATION_MS, - TeamGetKeysResponseKeyType, - getNsfRecordShortcuts, listNsfShortcuts, - keepNsfShortcut, + getNsfRecordShortcuts, formatNsfShortcutOutput, + keepNsfShortcut, formatKeepNsfShortcutPlan, transferNestedShareRecords, formatTransferNestedShareRecordResults, - NSF_RECORD_PERMISSION_ROLES, - NSFShareRoleName, - NsfShareCommandName, - NSF_SHARE_EXPIRATION_NEVER, + NsfRecordPermissionAction, NsfFolderShareAction, - NsfFolderShareActionTaken, NsfRecordShareAction, - NsfRecordShareActionTaken, - NsfRecordPermissionAction, - NsfResultStatus, - NsfTransferApiStatus, - NsfPermissionFailureCode, collectNsfRecordUidsInFolder, updateNestedShareRecordPermissions, buildNsfRecordPermissionPlan, @@ -569,7 +702,8 @@ export { resolveNsfRoleName, getNsfAccessRoleLabel, normalizeNsfRecordPermissionRole, - getFolderPermissionsForRole, + parseShareExpiration, + parseShareExpirationValue, NestedShareFolderManager, } from './nestedShareFolders' export type { @@ -622,16 +756,20 @@ export type { RecordFieldEntry, UpdateNsfFolderInput, UpdateNsfFolderResult, - NsfFolderShareActionInput, + UpdateNsfFolderBatchItem, + UpdateNsfFolderBatchResultItem, + UpdateNsfFoldersResult, ShareNestedShareFolderInput, ShareNestedShareFolderResult, - NsfFolderShareResultItem, - NsfRecordShareActionInput, ShareNestedShareRecordInput, ShareNestedShareRecordResult, + NsfFolderShareActionInput, + NsfFolderShareResultItem, + NsfRecordShareActionInput, NsfRecordSharePlanItem, NsfRecordShareResultItem, NsfShortcutRow, + NsfShortcutFolder, ListNsfShortcutsOptions, KeepNsfShortcutInput, KeepNsfShortcutPlanItem, @@ -640,16 +778,14 @@ export type { TransferNestedShareRecordInput, TransferNestedShareRecordResult, TransferNestedShareRecordResultItem, - NsfRecordPermissionRole, NsfRecordPermissionActionInput, UpdateNsfRecordPermissionInput, UpdateNsfRecordPermissionResult, NsfRecordPermissionPlan, NsfRecordPermissionPlanItem, NsfRecordPermissionFailure, - ParseShareExpirationInput, - NsfTeamPublicKeys, - NsfResolvedShareRecipient, + NsfRecordPermissionRole, + NsfRecordPermissionRoleInput, } from './nestedShareFolders' export type { diff --git a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts index 48032775..9b69dfcc 100644 --- a/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts +++ b/KeeperSdk/src/nestedShareFolders/NestedShareFolderManager.ts @@ -1,25 +1,40 @@ import type { Auth } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError, ResultCodes } from '../utils' -import { listNestedShareFolders } from './listNsf' -import { getNestedShareFolder } from './getNsf' +import { formatListNsfOutput, formatListNsfTable, listNestedShareFolders, renderListNsfAsciiTable } from './listNsf' +import { + formatNsfDetail as renderNsfDetail, + formatNsfFolderDetail as renderNsfFolderDetail, + formatNsfRecordDetail as renderNsfRecordDetail, + getNestedShareFolder, +} from './getNsf' import { linkNestedShareRecord } from './linkNsfRecord' -import { removeNestedShareRecords } from './removeNsfRecord' +import { formatRemoveNsfPreview, removeNestedShareRecords } from './removeNsfRecord' import { mkdirNestedShareFolder } from './mkdirNsf' -import { removeNestedShareFolders } from './removeNsfFolder' -import { getNestedShareRecordDetails } from './getNsfRecordDetails' +import { updateNestedShareFolder, updateNestedShareFolders } from './updateNsfFolder' +import { + shareNestedShareFolder, + shareNestedShareRecord, + formatNsfRecordSharePlan, + formatNsfRecordShareResults, +} from './nsfShare' +import { listNsfShortcuts, keepNsfShortcut, formatNsfShortcutOutput, formatKeepNsfShortcutPlan } from './nsfShortcut' +import { transferNestedShareRecords, formatTransferNestedShareRecordResults } from './nsfTransferRecord' +import { formatRemoveNsfFolderPreview, removeNestedShareFolders } from './removeNsfFolder' +import { getNestedShareRecordDetails, formatNsfRecordDetailsOutput } from './getNsfRecordDetails' import { updateNestedShareRecords, updateNestedShareRecord } from './updateNsfRecord' import { addNestedShareRecord, addNestedShareRecords } from './addNsfRecord' -import { shareNestedShareFolder, shareNestedShareRecord } from './nsfShare' -import { listNsfShortcuts, keepNsfShortcut } from './nsfShortcut' -import { transferNestedShareRecords } from './nsfTransferRecord' -import { updateNestedShareRecordPermissions } from './nsfRecordPermission' -import { updateNestedShareFolder } from './updateNsfFolder' +import { + updateNestedShareRecordPermissions, + formatNsfRecordPermissionPlan, + formatNsfRecordPermissionFailures, +} from './nsfRecordPermission' import type { AddNsfRecordInput, AddNsfRecordResult, AddNsfRecordsInput, AddNsfRecordsResult, + FormattedListNsfTable, GetNsfOptions, GetNsfResult, GetNsfRecordDetailsInput, @@ -27,11 +42,14 @@ import type { KeepNsfShortcutInput, KeepNsfShortcutResult, LinkNsfRecordResult, + ListNsfFormatInput, ListNsfOptions, ListNsfRow, ListNsfShortcutsOptions, MkdirNsfInput, MkdirNsfResult, + NsfFolderView, + NsfRecordView, NsfShortcutRow, RemoveNsfFolderInput, RemoveNsfFolderResult, @@ -43,8 +61,6 @@ import type { ShareNestedShareRecordResult, TransferNestedShareRecordInput, TransferNestedShareRecordResult, - UpdateNsfFolderInput, - UpdateNsfFolderResult, UpdateNsfRecordInput, UpdateNsfRecordItemInput, UpdateNsfRecordsInput, @@ -53,6 +69,12 @@ import type { UpdateNsfRecordResult, UpdateNsfRecordResultItem, } from './nsfTypes' +import type { + UpdateNsfFolderBatchItem, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + UpdateNsfFoldersResult, +} from './updateNsfFolder' export type AuthProvider = () => Auth @@ -77,55 +99,61 @@ export class NestedShareFolderManager { return listNestedShareFolders(this.storage, options) } - public async getNestedShareFolder(identifier: string, options: GetNsfOptions = {}): Promise { - return getNestedShareFolder(this.storage, this.requireAuth(), identifier, options) + public formatListNsfTable(rows: ListNsfRow[], options: { columnWidth?: number } = {}): FormattedListNsfTable { + return formatListNsfTable(rows, options) } - public async linkNestedShareRecord( - recordIdentifier: string, - folderIdentifier: string - ): Promise { - return linkNestedShareRecord(this.storage, this.requireAuth(), recordIdentifier, folderIdentifier) + public renderListNsfAsciiTable(table: FormattedListNsfTable, options: { minColWidth?: number } = {}): string { + return renderListNsfAsciiTable(table, options) } - public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { - return removeNestedShareRecords(this.storage, this.requireAuth(), input) + public formatListNsfOutput(rows: ListNsfRow[], format: ListNsfFormatInput = 'table'): string { + return formatListNsfOutput(rows, format) } - public async mkdirNestedShareFolder(input: MkdirNsfInput): Promise { - return mkdirNestedShareFolder(this.storage, this.requireAuth(), input) + public async getNestedShareFolder(identifier: string, options: GetNsfOptions = {}): Promise { + return getNestedShareFolder(this.storage, this.requireAuth(), identifier, options) } - public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { - return removeNestedShareFolders(this.storage, this.requireAuth(), input) + public formatNsfDetail(result: GetNsfResult, verbose = false): string { + return renderNsfDetail(result, verbose) } - public async getNestedShareRecordDetails(input: GetNsfRecordDetailsInput): Promise { - return getNestedShareRecordDetails(this.storage, this.requireAuth(), input) + public formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { + return renderNsfFolderDetail(view, verbose) } - public async updateNestedShareRecords( - input: UpdateNsfRecordInput | UpdateNsfRecordsInput - ): Promise { - return updateNestedShareRecords(this.storage, this.requireAuth(), input) + public formatNsfRecordDetail(view: NsfRecordView, verbose = false): string { + return renderNsfRecordDetail(view, verbose) } - public async updateNestedShareRecord(input: UpdateNsfRecordItemInput): Promise { - return updateNestedShareRecord(this.storage, this.requireAuth(), input) + public async linkNestedShareRecord( + recordIdentifier: string, + folderIdentifier: string + ): Promise { + return linkNestedShareRecord(this.storage, this.requireAuth(), recordIdentifier, folderIdentifier) } - public async addNestedShareRecords(input: AddNsfRecordsInput): Promise { - return addNestedShareRecords(this.storage, this.requireAuth(), input) + public async removeNestedShareRecords(input: RemoveNsfRecordInput): Promise { + return removeNestedShareRecords(this.storage, this.requireAuth(), input) } - public async addNestedShareRecord(input: AddNsfRecordInput): Promise { - return addNestedShareRecord(this.storage, this.requireAuth(), input) + public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { + return formatRemoveNsfPreview(preview) + } + + public async mkdirNestedShareFolder(input: MkdirNsfInput): Promise { + return mkdirNestedShareFolder(this.storage, this.requireAuth(), input) } public async updateNestedShareFolder(input: UpdateNsfFolderInput): Promise { return updateNestedShareFolder(this.storage, this.requireAuth(), input) } + public async updateNestedShareFolders(updates: UpdateNsfFolderBatchItem[]): Promise { + return updateNestedShareFolders(this.storage, this.requireAuth(), updates) + } + public async shareNestedShareFolder(input: ShareNestedShareFolderInput): Promise { return shareNestedShareFolder(this.storage, this.requireAuth(), input) } @@ -134,10 +162,22 @@ export class NestedShareFolderManager { return shareNestedShareRecord(this.storage, this.requireAuth(), input) } + public formatNsfRecordSharePlan(result: ShareNestedShareRecordResult): string { + return formatNsfRecordSharePlan(result.plan) + } + + public formatNsfRecordShareResults(results: ShareNestedShareRecordResult['results']): string { + return formatNsfRecordShareResults(results) + } + public listNsfShortcuts(options: ListNsfShortcutsOptions = {}): NsfShortcutRow[] { return listNsfShortcuts(this.storage, options) } + public formatNsfShortcutOutput(rows: NsfShortcutRow[], format?: ListNsfShortcutsOptions['format']): string { + return formatNsfShortcutOutput(rows, format) + } + public async keepNsfShortcut( input: KeepNsfShortcutInput, defaultFolderUid?: string @@ -145,15 +185,75 @@ export class NestedShareFolderManager { return keepNsfShortcut(this.storage, this.requireAuth(), input, defaultFolderUid) } + public formatKeepNsfShortcutPlan(result: KeepNsfShortcutResult): string { + return formatKeepNsfShortcutPlan(result.plan) + } + public async transferNestedShareRecords( input: TransferNestedShareRecordInput ): Promise { return transferNestedShareRecords(this.storage, this.requireAuth(), input) } + public formatTransferNestedShareRecordResults(results: TransferNestedShareRecordResult['results']): string { + return formatTransferNestedShareRecordResults(results) + } + + public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { + return removeNestedShareFolders(this.storage, this.requireAuth(), input) + } + + public formatRemoveNsfFolderPreview( + preview: RemoveNsfFolderResult['preview'], + operation: RemoveNsfFolderResult['operation'], + quiet?: boolean + ): string { + return formatRemoveNsfFolderPreview(preview, operation, quiet) + } + + public async getNestedShareRecordDetails(input: GetNsfRecordDetailsInput): Promise { + return getNestedShareRecordDetails(this.storage, this.requireAuth(), input) + } + + public formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format?: GetNsfRecordDetailsInput['format'] + ): string { + return formatNsfRecordDetailsOutput(result, format) + } + + public async updateNestedShareRecords( + input: UpdateNsfRecordInput | UpdateNsfRecordsInput + ): Promise { + return updateNestedShareRecords(this.storage, this.requireAuth(), input) + } + + public async updateNestedShareRecord(input: UpdateNsfRecordItemInput): Promise { + return updateNestedShareRecord(this.storage, this.requireAuth(), input) + } + + public async addNestedShareRecords(input: AddNsfRecordsInput): Promise { + return addNestedShareRecords(this.storage, this.requireAuth(), input) + } + + public async addNestedShareRecord(input: AddNsfRecordInput): Promise { + return addNestedShareRecord(this.storage, this.requireAuth(), input) + } + public async updateNestedShareRecordPermissions( input: UpdateNsfRecordPermissionInput ): Promise { return updateNestedShareRecordPermissions(this.storage, this.requireAuth(), input) } + + public formatNsfRecordPermissionPlan(result: UpdateNsfRecordPermissionResult): string { + return formatNsfRecordPermissionPlan(result.plan) + } + + public formatNsfRecordPermissionFailures( + failures: UpdateNsfRecordPermissionResult['grantFailures'], + kind: 'GRANT' | 'REVOKE' + ): string { + return formatNsfRecordPermissionFailures(failures, kind) + } } diff --git a/KeeperSdk/src/nestedShareFolders/getNsf.ts b/KeeperSdk/src/nestedShareFolders/getNsf.ts index fba51e7f..2bd3f466 100644 --- a/KeeperSdk/src/nestedShareFolders/getNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/getNsf.ts @@ -31,9 +31,9 @@ import { isFolderOwnerAccessor, isFolderShareAdministrator, isFolderUserPermission, + isRootFolderUid, isSensitiveFieldType, loadShareUserMap, - normalizeParentUid, recordAccessDisplayRole, resolveAccessUsername, resolveNsfFolderIdentifier, @@ -41,13 +41,16 @@ import { } from './nsfHelpers' import { NSF_FOLDER_LABEL_WIDTH, + NSF_FOLDER_PERMISSION_DISPLAY_ROWS, NSF_FOLDER_SHARE_ADMINS_HEADING, NSF_USER_PERMISSIONS_HEADING, NSF_MASKED_VALUE, NSF_RECORD_LABEL_WIDTH, + NSF_RECORD_PERMISSION_DISPLAY_ROWS, NSF_SHARE_ADMINS_PREVIEW_LIMIT, NSF_TOP_LEVEL_FIELD_TYPES, NSF_UNKNOWN_RECORD_TITLES, + getFolderPermissionFlagsForRoleLabel, } from './nsfConstants' import { GetNsfFormat, @@ -58,6 +61,7 @@ import { type GetNsfOptions, type GetNsfResult, type NsfFolderAccessRow, + type NsfFolderJsonView, type NsfFolderPermission, type NsfFolderView, type NsfRecordFieldView, @@ -68,6 +72,19 @@ import { type NsfRecordView, } from './nsfTypes' +export const NSF_UNMASK_WARNING = 'WARNING: Sensitive field values are displayed in plain text.' + +function formatAsciiTable(headers: string[], rows: string[][]): string[] { + if (rows.length === 0) return [] + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || '').length)) + ) + const pad = (cell: string, index: number) => cell + ' '.repeat(Math.max(0, widths[index] - cell.length)) + const formatRow = (cells: string[]) => cells.map((cell, index) => pad(cell, index)).join(' ') + const rule = widths.map((width, index) => pad('-'.repeat(width), index)).join(' ') + return [formatRow(headers), rule, ...rows.map(formatRow)] +} + function formatNsfFieldParts(values: unknown[]): string[] { return values .filter((value) => value != null && value !== '') @@ -255,8 +272,10 @@ function formatRecordFieldDetailLines(field: NsfRecordFieldView): string[] { return [recordDetailRow(label.charAt(0).toUpperCase() + label.slice(1), displayValue)] } -function jsonFieldValues(type: string, value: string[]): unknown[] { +function jsonFieldValues(type: string, value: string[], unmask = false): unknown[] { + const masked = !unmask && isSensitiveFieldType(type) return value.map((part) => { + if (masked && part.length > 0) return NSF_MASKED_VALUE if ((type === 'host' || type === 'address') && part.startsWith('{')) { try { return JSON.parse(part) as Record @@ -268,35 +287,122 @@ function jsonFieldValues(type: string, value: string[]): unknown[] { }) } -export function toNsfRecordJsonView(view: NsfRecordView): NsfRecordJsonView { +function yn(value: boolean | undefined): string { + return value ? 'Y' : 'N' +} + +function formatPermissionValueTable(rows: readonly { label: string; value: boolean | undefined }[]): string[] { + if (rows.length === 0) return [] + const tableRows = rows.map((row) => [row.label, yn(row.value)]) + return ['', ...formatAsciiTable(['Permission', 'Value'], tableRows)] +} + +function formatFolderRolePermissionTable(role: string): string[] { + const flags = getFolderPermissionFlagsForRoleLabel(role) + return formatPermissionValueTable( + NSF_FOLDER_PERMISSION_DISPLAY_ROWS.map(({ key, label }) => ({ label, value: !!flags[key] })) + ) +} + +function formatRecordCapabilityPermissionTable(entry: NsfRecordPermission): string[] { + const ownerDefaults = entry.owner + return formatPermissionValueTable( + NSF_RECORD_PERMISSION_DISPLAY_ROWS.map(({ key, label }) => { + const fromEntry = entry[key] + if (fromEntry !== undefined) return { label, value: fromEntry } + // Owner with no live flags: treat as full access except Request Access. + if (ownerDefaults) return { label, value: key !== 'canRequestAccess' } + return { label, value: false } + }) + ) +} + +function pickPrimaryFolderRole(view: NsfFolderView): string { + const owner = view.userPermissions.find((entry) => entry.role === NsfAccessRoleLabel.Owner) + return (owner ?? view.userPermissions[0])?.role ?? NsfAccessRoleLabel.Viewer +} + +function pickPrimaryRecordPermission(view: NsfRecordView): NsfRecordPermission | undefined { + return view.userPermissions.find((entry) => entry.owner) ?? view.userPermissions[0] +} + +export function toNsfRecordJsonView( + view: NsfRecordView, + options: { includeDag?: boolean; unmask?: boolean } = {} +): NsfRecordJsonView { + const unmask = options.unmask ?? false + const fields = view.fields + .filter((field) => !NSF_TOP_LEVEL_FIELD_TYPES.has(field.type)) + .map(({ type, value }) => ({ + type, + value: jsonFieldValues(type, value, unmask), + })) + return { record_uid: view.recordUid, title: view.title, type: view.type, version: view.version, revision: view.revision, - ...(view.folder ? { folder: view.folder } : {}), - fields: view.fields.map(({ type, value }) => ({ - type, - value: jsonFieldValues(type, value), - })), + folder: view.folder ?? { uid: '', path: view.folderLocation || '/' }, + fields, ...(view.notes ? { notes: view.notes } : {}), user_permissions: view.userPermissions.map((entry) => ({ username: entry.username, owner: entry.owner, shareable: entry.shareable, editable: entry.editable, - role: resolveRecordPermissionRole(entry), + role: entry.owner ? NsfAccessRoleLabel.FullManager : resolveRecordPermissionRole(entry), })), share_admins: view.shareAdmins, } } -export function formatNsfRecordJson(view: NsfRecordView): string { - return JSON.stringify(toNsfRecordJsonView(view), null, 2) +export function toNsfFolderJsonView(view: NsfFolderView, options: { includeDag?: boolean } = {}): NsfFolderJsonView { + const includeDag = options.includeDag ?? false + const parentUid = view.parentUid?.trim() || '' + const treatedAsRootParent = !parentUid || parentUid === 'root' + + const owner = + view.userPermissions.find((entry) => entry.role === NsfAccessRoleLabel.Owner)?.username || + view.shareAdmins.find((entry) => entry.role === NsfAccessRoleLabel.Owner)?.username + + return { + folder_uid: view.folderUid, + type: 'nested_share_folder', + name: view.name, + parent_uid: treatedAsRootParent ? null : parentUid, + folder: { + uid: treatedAsRootParent ? null : parentUid, + path: treatedAsRootParent ? '/' : view.path, + }, + ...(owner ? { owner } : {}), + path: view.path, + records: view.records, + user_permissions: view.userPermissions.map((entry) => ({ + accessor: entry.username, + access_type: 'AT_USER', + role: entry.role, + inherited: false, + })), + share_admins: view.shareAdmins.map((entry) => entry.username), + ...(includeDag || view.teamPermissions.length > 0 ? { team_permissions: view.teamPermissions } : {}), + } } -function formatRecordUserPermissionBlock(entry: NsfRecordPermission): string[] { +export function formatNsfRecordJson( + view: NsfRecordView, + options: { includeDag?: boolean; unmask?: boolean } = {} +): string { + return JSON.stringify(toNsfRecordJsonView(view, options), null, 2) +} + +export function formatNsfFolderJson(view: NsfFolderView, options: { includeDag?: boolean } = {}): string { + return JSON.stringify(toNsfFolderJsonView(view, options), null, 2) +} + +function formatRecordUserPermissionBlock(entry: NsfRecordPermission, verbose: boolean): string[] { + if (verbose) return [] const lines: string[] = [] if (entry.username) lines.push(` User: ${entry.username}`) else if (entry.accountUid) lines.push(` User UID: ${entry.accountUid}`) @@ -324,6 +430,15 @@ async function fetchRecordPermissions( editable: !!data.canEdit, awaitingApproval: false, role: owner ? undefined : recordAccessDisplayRole(data), + canViewTitle: data.canViewTitle ?? true, + canView: !!data.canView, + canEdit: !!data.canEdit, + canListAccess: !!data.canListAccess, + canUpdateAccess: !!data.canUpdateAccess, + canDelete: !!data.canDelete, + canChangeOwnership: !!data.canChangeOwnership, + canRequestAccess: !!data.canRequestAccess, + canApproveAccess: !!data.canApproveAccess, } }) } @@ -363,7 +478,7 @@ async function buildFolderView(auth: Auth, storage: InMemoryStorage, folderUid: objectType: NsfObjectKind.Folder, folderUid, name: folder.data.name || 'Unnamed', - parentUid: normalizeParentUid(storage, folder.parentUid), + parentUid: isRootFolderUid(storage, folder.parentUid) ? 'root' : (folder.parentUid ?? '').trim(), path: buildFolderPath(storage, folderUid), userPermissions, shareAdmins, @@ -465,32 +580,44 @@ export async function getNestedShareFolder( } export function formatNsfFolderDetail(view: NsfFolderView, verbose = false): string { - const lines = [ - folderDetailRow('Nested Share Folder UID', view.folderUid), - folderDetailRow('Name', view.name), + const lines = [folderDetailRow('Nested Share Folder UID', view.folderUid), folderDetailRow('Name', view.name)] + + if (verbose) { + lines.push( + '', + NSF_USER_PERMISSIONS_HEADING, + ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), + ...formatFolderRolePermissionTable(pickPrimaryFolderRole(view)), + '', + NSF_FOLDER_SHARE_ADMINS_HEADING, + ...view.shareAdmins.map((entry) => `${entry.username}: ${entry.role}`), + '', + folderDetailRow('Parent UID', view.parentUid), + folderDetailRow('Path', view.path) + ) + if (view.records.length > 0) { + lines.push('', 'Records:') + for (const record of view.records) { + lines.push(` ${record.uid} ${record.title} (${record.type})`) + } + } + if (view.teamPermissions.length > 0) { + lines.push('', 'Team Permissions:') + for (const entry of view.teamPermissions) { + lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`) + } + } + return lines.join('\n') + } + + lines.push( '', NSF_USER_PERMISSIONS_HEADING, ...view.userPermissions.map((entry) => `${entry.username}: ${entry.role}`), '', NSF_FOLDER_SHARE_ADMINS_HEADING, - ...view.shareAdmins.map((entry) => `${entry.username}: ${entry.role}`), - ] - - if (!verbose) return lines.join('\n') - - lines.push('', folderDetailRow('Parent UID', view.parentUid), folderDetailRow('Path', view.path)) - if (view.records.length > 0) { - lines.push('', 'Records:') - for (const record of view.records) { - lines.push(` ${record.uid} ${record.title} (${record.type})`) - } - } - if (view.teamPermissions.length > 0) { - lines.push('', 'Team Permissions:') - for (const entry of view.teamPermissions) { - lines.push(` ${entry.accessTypeUid} role=${entry.accessRoleType}`) - } - } + ...view.shareAdmins.map((entry) => `${entry.username}: ${entry.role}`) + ) return lines.join('\n') } @@ -513,7 +640,11 @@ export function formatNsfRecordDetail(view: NsfRecordView, verbose = false): str if (view.userPermissions.length > 0) { lines.push('', NSF_USER_PERMISSIONS_HEADING) for (const entry of view.userPermissions) { - lines.push('', ...formatRecordUserPermissionBlock(entry)) + lines.push('', ...formatRecordUserPermissionBlock(entry, false)) + } + if (verbose) { + const primary = pickPrimaryRecordPermission(view) + if (primary) lines.push(...formatRecordCapabilityPermissionTable(primary)) } } @@ -552,9 +683,9 @@ export function formatNsfDetail(result: GetNsfResult, verbose = false): string { : formatNsfRecordDetail(result.view, verbose) } -export function formatNsfJson(result: GetNsfResult): string { +export function formatNsfJson(result: GetNsfResult, options: { includeDag?: boolean; unmask?: boolean } = {}): string { if (result.kind === NsfObjectKind.Record) { - return formatNsfRecordJson(result.view) + return formatNsfRecordJson(result.view, options) } - return JSON.stringify(result.view, null, 2) + return formatNsfFolderJson(result.view, options) } diff --git a/KeeperSdk/src/nestedShareFolders/index.ts b/KeeperSdk/src/nestedShareFolders/index.ts index 468f92c3..33de2dc6 100644 --- a/KeeperSdk/src/nestedShareFolders/index.ts +++ b/KeeperSdk/src/nestedShareFolders/index.ts @@ -1,9 +1,14 @@ export { + ROOT_FOLDER_UID, KeeperDriveKind, NsfItemType, formatAccessRoleType, formatAccessType, normalizeParentUid, + displayNsfParentUid, + findRecordFolderParentUid, + nsfFolderHasPamUserWithRotation, + nsfRecordIsRoeEligible, isRootFolderUid, resolveKeeperDriveRootParentUid, getKeeperDriveFolders, @@ -25,10 +30,14 @@ export { checkRecordDeletePermission, checkRecordEditPermission, checkFolderDeletePermission, + checkFolderEditPermission, + checkFolderSharePermission, + checkRecordSharePermission, parseNsfPath, findExistingChildFolder, resolveNsfRoleName, getNsfAccessRoleLabel, + getNsfRecordPermissionRoleLabel, normalizeNsfRecordPermissionRole, parseShareExpiration, parseShareExpirationValue, @@ -36,6 +45,8 @@ export { isShareExpirationNoop, } from './nsfHelpers' +export type { NsfRecordAccessFlags } from './nsfHelpers' + export { listNestedShareFolders, formatListNsfTable, @@ -53,8 +64,11 @@ export { formatNsfRecordDetail, formatNsfDetail, formatNsfRecordJson, + formatNsfFolderJson, formatNsfJson, toNsfRecordJsonView, + toNsfFolderJsonView, + NSF_UNMASK_WARNING, } from './getNsf' export { @@ -91,6 +105,7 @@ export type { NsfRecordFieldView, NsfRecordFolderView, NsfRecordJsonView, + NsfFolderJsonView, NsfRecordJsonUserPermission, NsfFolderPermission, NsfFolderAccessRow, @@ -139,6 +154,7 @@ export type { NsfRecordPermissionPlanItem, NsfRecordPermissionFailure, NsfShortcutRow, + NsfShortcutFolder, ListNsfShortcutsOptions, KeepNsfShortcutInput, KeepNsfShortcutPlanItem, @@ -147,8 +163,6 @@ export type { TransferNestedShareRecordInput, TransferNestedShareRecordResult, TransferNestedShareRecordResultItem, - UpdateNsfFolderInput, - UpdateNsfFolderResult, ParseShareExpirationInput, NsfTeamPublicKeys, NsfResolvedShareRecipient, @@ -156,23 +170,36 @@ export type { export { fetchNsfTeamPublicKeys, encryptNsfFolderKeyForTeam, resolveNsfShareRecipient } from './nsfTeamShare' -export type { UserShareKeys } from './nsfShareKeys' - export { linkNestedShareRecord } from './linkNsfRecord' export { removeNestedShareRecords, formatRemoveNsfPreview, collectRemoveNsfWarnings } from './removeNsfRecord' export { mkdirNestedShareFolder } from './mkdirNsf' + +export { updateNestedShareFolder, updateNestedShareFolders } from './updateNsfFolder' +export type { + UpdateNsfFolderInput, + UpdateNsfFolderResult, + UpdateNsfFolderBatchItem, + UpdateNsfFolderBatchResultItem, + UpdateNsfFoldersResult, +} from './updateNsfFolder' + export { NSF_FOLDER_COLORS, NSF_MAX_RECORD_BATCH, + NSF_MAX_FOLDER_UPDATES, + NSF_MAX_REMOVALS, MIN_SHARE_EXPIRATION_MS, NSF_SHARE_EXPIRATION_NEVER, + NSF_SHARE_BATCH_SIZE, + NSF_RECORD_PERMISSION_ROLES, TeamGetKeysResponseKeyType, NSFShareRoleName, NsfShareCommandName, + getFolderPermissionsForRole, } from './nsfConstants' -export type { NsfFolderColor } from './nsfConstants' +export type { NsfFolderColor, NsfRecordPermissionRole, NsfRecordPermissionRoleInput } from './nsfConstants' export { removeNestedShareFolders, formatRemoveNsfFolderPreview } from './removeNsfFolder' @@ -225,8 +252,3 @@ export { } from './nsfShortcut' export { transferNestedShareRecords, formatTransferNestedShareRecordResults } from './nsfTransferRecord' - -export { updateNestedShareFolder } from './updateNsfFolder' - -export { NSF_RECORD_PERMISSION_ROLES, getFolderPermissionsForRole } from './nsfConstants' -export type { NsfRecordPermissionRole } from './nsfConstants' diff --git a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts index d00aea38..e277ffad 100644 --- a/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/linkNsfRecord.ts @@ -16,7 +16,6 @@ import { resolveNsfFolderIdentifier, resolveNsfRecordIdentifier, } from './nsfHelpers' -import type { LinkNsfRecordResult } from './nsfTypes' function resolveRecordKeyType( storage: InMemoryStorage, @@ -35,6 +34,14 @@ function resolveRecordKeyType( } } +export type LinkNsfRecordResult = { + success: boolean + recordUid: string + folderUid: string + status: string + message: string +} + async function buildRecordMetadata( storage: InMemoryStorage, folderUid: string, diff --git a/KeeperSdk/src/nestedShareFolders/listNsf.ts b/KeeperSdk/src/nestedShareFolders/listNsf.ts index a822fb86..4d24dffa 100644 --- a/KeeperSdk/src/nestedShareFolders/listNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/listNsf.ts @@ -1,12 +1,13 @@ import type { InMemoryStorage } from '../storage/InMemoryStorage' import { NsfItemType, - findRecordFolderLocation, + displayNsfParentUid, + findRecordFolderParentUid, getKeeperDriveFolders, getKeeperDriveRecords, getRecordDescription, - normalizeParentUid, - resolveKeeperDriveRootParentUid, + nsfFolderHasPamUserWithRotation, + nsfRecordIsRoeEligible, } from './nsfHelpers' import { getRecordTitle, getRecordType } from '../records/RecordUtils' import { @@ -15,24 +16,26 @@ import { NSF_LIST_MIN_TRUNCATE_PREFIX, NSF_LIST_TABLE_HEADERS, } from './nsfConstants' -import { - ListNsfFormat, - type FormattedListNsfTable, - type ListNsfFormatInput, - type ListNsfOptions, - type ListNsfRow, -} from './nsfTypes' +import type { ListNsfFormatInput, ListNsfOptions, ListNsfRow } from './nsfTypes' + +export enum ListNsfFormat { + Table = 'table', + CSV = 'csv', + JSON = 'json', +} + +export type { ListNsfFormatInput, ListNsfOptions, ListNsfRow } + +export type FormattedListNsfTable = { + headers: string[] + rows: string[][] +} function compareRows(a: ListNsfRow, b: ListNsfRow): number { const typeCompare = a.itemType.localeCompare(b.itemType) return typeCompare !== 0 ? typeCompare : a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }) } -function resolveListParentOrFolder(storage: InMemoryStorage, value: string): string { - if (value !== 'root') return value - return resolveKeeperDriveRootParentUid(storage) ?? value -} - function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { return getKeeperDriveFolders(storage).map((folder) => ({ itemType: NsfItemType.Folder, @@ -40,7 +43,7 @@ function collectFolderRows(storage: InMemoryStorage): ListNsfRow[] { title: folder.data.name || 'Unnamed', type: '', description: '', - parentOrFolder: resolveListParentOrFolder(storage, normalizeParentUid(storage, folder.parentUid)), + parentOrFolder: displayNsfParentUid(storage, folder.parentUid), })) } @@ -51,16 +54,27 @@ function collectRecordRows(storage: InMemoryStorage): ListNsfRow[] { title: getRecordTitle(record), type: getRecordType(record), description: getRecordDescription(record), - parentOrFolder: resolveListParentOrFolder(storage, findRecordFolderLocation(storage, record.uid) || 'root'), + parentOrFolder: findRecordFolderParentUid(storage, record.uid), })) } export function listNestedShareFolders(storage: InMemoryStorage, options: ListNsfOptions = {}): ListNsfRow[] { const showFolders = options.folders ?? options.records == null const showRecords = options.records ?? options.folders == null + const roeEligible = options.roeEligible ?? false const rows: ListNsfRow[] = [] - if (showFolders) rows.push(...collectFolderRows(storage)) - if (showRecords) rows.push(...collectRecordRows(storage)) + if (showFolders) { + const folderRows = collectFolderRows(storage) + rows.push( + ...(roeEligible + ? folderRows.filter((row) => nsfFolderHasPamUserWithRotation(storage, row.uid)) + : folderRows) + ) + } + if (showRecords) { + const recordRows = collectRecordRows(storage) + rows.push(...(roeEligible ? recordRows.filter((row) => nsfRecordIsRoeEligible(storage, row.uid)) : recordRows)) + } return rows.sort(compareRows) } @@ -117,20 +131,19 @@ export function formatListNsfCsv(rows: ListNsfRow[]): string { return lines.join('\n') } -function toListNsfJsonRow(row: ListNsfRow): Record { - const out: Record = { - item_type: row.itemType, - uid: row.uid, - title: row.title, - parent_or_folder: row.parentOrFolder, - } - if (row.type) out.type = row.type - if (row.description) out.description = row.description - return out -} - export function formatListNsfJson(rows: ListNsfRow[]): string { - return JSON.stringify(rows.map(toListNsfJsonRow), null, 2) + return JSON.stringify( + rows.map((row) => ({ + 'Item Type': row.itemType, + UID: row.uid, + Title: row.title, + Type: row.type, + Description: row.description, + 'Parent/Folder': row.parentOrFolder, + })), + null, + 2 + ) } export function formatListNsfOutput(rows: ListNsfRow[], format: ListNsfFormatInput = ListNsfFormat.Table): string { diff --git a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts index 83bf2297..4bf5d841 100644 --- a/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts +++ b/KeeperSdk/src/nestedShareFolders/mkdirNsf.ts @@ -28,6 +28,8 @@ import type { NsfFolderColorInput, } from './nsfTypes' +export type { NsfFolderColorInput } from './nsfTypes' + type NsfFolderMetadata = { name: string color?: string @@ -201,8 +203,7 @@ export async function mkdirNestedShareFolder( return { folderUid: createdUid, created: true, - message: - segments.length > 1 ? `Created folder path "${folderPath}".` : `Created folder "${segments[lastIdx]}".`, + message: createdUid, } } catch (err) { if (err instanceof KeeperSdkError) throw err diff --git a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts index e36b15c8..e8dfd31a 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfConstants.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfConstants.ts @@ -1,6 +1,8 @@ import { Folder } from '@keeper-security/keeperapi' import { KeeperSdkError, ResultCodes } from '../utils' +export const ROOT_FOLDER_UID = 'AAAAAAAAAAAAAAAAAPmtNA' + export const NSF_LEGACY_RECORD_MSG = "Record '{0}' is a legacy vault record. Nested Share Folder commands operate only on Nested Share Records." @@ -28,7 +30,9 @@ export const NSF_MASKED_VALUE = '********' export const NSF_FOLDER_LABEL_WIDTH = 22 export const NSF_RECORD_LABEL_WIDTH = 17 export const NSF_USER_PERMISSIONS_HEADING = 'User Permissions:' +export const NSF_FOLDER_USER_PERMISSIONS_HEADING = NSF_USER_PERMISSIONS_HEADING export const NSF_FOLDER_SHARE_ADMINS_HEADING = 'Share Administrators:' +export const NSF_RECORD_USER_PERMISSIONS_HEADING = NSF_USER_PERMISSIONS_HEADING export const NSF_SHARE_ADMINS_PREVIEW_LIMIT = 10 export const NSF_LIST_TABLE_HEADERS = ['#', 'Item Type', 'UID', 'Title', 'Type', 'Description'] as const @@ -37,7 +41,10 @@ export const NSF_LIST_DEFAULT_COLUMN_WIDTH = 40 export const NSF_LIST_MIN_TRUNCATE_PREFIX = 3 export const NSF_MAX_RECORD_BATCH = 500 +export const NSF_MAX_REMOVALS = 500 export const NSF_MAX_FOLDER_REMOVALS = 100 +/** Max folder updates per `vault/folders/v3/update` request (FolderUpdateRequest.folderData). */ +export const NSF_MAX_FOLDER_UPDATES = 100 export const NSF_PATH_SENTINEL = '\x00' @@ -96,6 +103,7 @@ export const NSF_RECORD_PERMISSION_ROLES = [ NSFShareRoleName.FullManager, ] as const export type NsfRecordPermissionRole = (typeof NSF_RECORD_PERMISSION_ROLES)[number] +export type NsfRecordPermissionRoleInput = NsfRecordPermissionRole | `${NsfRecordPermissionRole}` export const NSF_RECORD_PERMISSION_ROLE_LABELS: Record = { [Folder.AccessRoleType.NAVIGATOR]: NSFShareRoleName.Contributor, @@ -249,3 +257,61 @@ export function getFolderPermissionsForRole(roleType: Folder.AccessRoleType): Fo } return Folder.FolderPermissions.create(flags) } + +export const NSF_FOLDER_PERMISSION_DISPLAY_ROWS: readonly { key: keyof FolderRolePermissionFlags; label: string }[] = [ + { key: 'canListFolders', label: 'List Folders' }, + { key: 'canListRecords', label: 'List Records' }, + { key: 'canViewRecords', label: 'View Records' }, + { key: 'canEditRecords', label: 'Edit Records' }, + { key: 'canAdd', label: 'Add (folders/records)' }, + { key: 'canRemove', label: 'Remove (folders/records)' }, + { key: 'canDelete', label: 'Delete' }, + { key: 'canListAccess', label: 'List Access' }, + { key: 'canUpdateAccess', label: 'Update Access' }, + { key: 'canApproveAccess', label: 'Approve Access' }, + { key: 'canRequestAccess', label: 'Request Access' }, + { key: 'canUpdateSetting', label: 'Update Setting' }, + { key: 'canChangeOwnership', label: 'Change Ownership' }, +] + +export const NSF_RECORD_PERMISSION_DISPLAY_ROWS: readonly { key: keyof NsfRecordPermissionFlags; label: string }[] = [ + { key: 'canViewTitle', label: 'View Title' }, + { key: 'canView', label: 'View Content' }, + { key: 'canEdit', label: 'Edit' }, + { key: 'canListAccess', label: 'List Access' }, + { key: 'canUpdateAccess', label: 'Update Access' }, + { key: 'canDelete', label: 'Delete' }, + { key: 'canChangeOwnership', label: 'Change Ownership' }, + { key: 'canRequestAccess', label: 'Request Access' }, + { key: 'canApproveAccess', label: 'Approve Access' }, +] + +export type NsfRecordPermissionFlags = { + canViewTitle: boolean + canView: boolean + canEdit: boolean + canListAccess: boolean + canUpdateAccess: boolean + canDelete: boolean + canChangeOwnership: boolean + canRequestAccess: boolean + canApproveAccess: boolean +} + +const NSF_FOLDER_ROLE_BY_LABEL: Record = { + owner: Folder.AccessRoleType.MANAGER, + navigator: Folder.AccessRoleType.NAVIGATOR, + requestor: Folder.AccessRoleType.REQUESTOR, + viewer: Folder.AccessRoleType.VIEWER, + 'shared-manager': Folder.AccessRoleType.SHARED_MANAGER, + 'share-manager': Folder.AccessRoleType.SHARED_MANAGER, + 'content-manager': Folder.AccessRoleType.CONTENT_MANAGER, + 'content-share-manager': Folder.AccessRoleType.CONTENT_SHARE_MANAGER, + 'full-manager': Folder.AccessRoleType.MANAGER, + manager: Folder.AccessRoleType.MANAGER, +} + +export function getFolderPermissionFlagsForRoleLabel(role: string): FolderRolePermissionFlags { + const roleType = NSF_FOLDER_ROLE_BY_LABEL[role.trim().toLowerCase()] ?? Folder.AccessRoleType.VIEWER + return NSF_FOLDER_ROLE_PERMISSIONS[roleType] ?? NSF_FOLDER_ROLE_PERMISSIONS[Folder.AccessRoleType.VIEWER]! +} diff --git a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts index 36151cbc..2010a42e 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfHelpers.ts @@ -1,6 +1,7 @@ import type { Auth, DRecord, + DRecordRotation, DUser, DKdFolder, DKdFolderAccess, @@ -20,7 +21,7 @@ import { import type { InMemoryStorage } from '../storage/InMemoryStorage' import { VaultObjectKind } from '../folders/folderHelpers' import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { getRecordTitle } from '../records/RecordUtils' +import { getRecordTitle, getRecordType } from '../records/RecordUtils' import { NSF_ACCESS_TYPE_LABELS, NSF_LEGACY_FOLDER_MSG, @@ -42,6 +43,8 @@ import { } from './nsfConstants' import { NsfAccessRoleLabel, NSF_ACCESS_ROLE_LABELS, type ParseShareExpirationInput } from './nsfTypes' +export { ROOT_FOLDER_UID } from './nsfConstants' + export enum KeeperDriveKind { Folder = 'keeper_drive_folder', FolderAccess = 'keeper_drive_folder_access', @@ -147,6 +150,13 @@ export function isRootFolderUid(storage: InMemoryStorage, folderUid: string | un return !!driveRoot && value === driveRoot } +export function displayNsfParentUid(storage: InMemoryStorage, parentUid: string | undefined | null): string { + if (isRootFolderUid(storage, parentUid)) { + return resolveKeeperDriveRootParentUid(storage) ?? '' + } + return (parentUid ?? '').trim() +} + export function normalizeParentUid(storage: InMemoryStorage, parentUid: string | undefined | null): string { return isRootFolderUid(storage, parentUid) ? 'root' : (parentUid ?? '').trim() } @@ -404,6 +414,45 @@ export function findNestedShareFoldersForRecord(storage: InMemoryStorage, record .map((entry) => entry.folderUid) } +export function findRecordFolderParentUid(storage: InMemoryStorage, recordUid: string): string { + const folderUids = findNestedShareFoldersForRecord(storage, recordUid) + if (folderUids.length === 0) { + return resolveKeeperDriveRootParentUid(storage) ?? '' + } + return folderUids[0] +} + +function recordHasRotationConfigured(storage: InMemoryStorage, recordUid: string): boolean { + const rotation = storage.getByUid('record_rotation', recordUid) + return rotation != null && rotation.disabled !== true +} + +export function nsfFolderHasPamUserWithRotation(storage: InMemoryStorage, folderUid: string): boolean { + const targetUid = isRootFolderUid(storage, folderUid) ? resolveKeeperDriveRootParentUid(storage) : folderUid + if (!targetUid) return false + + for (const entry of storage.getAll(KeeperDriveKind.FolderRecord)) { + const linkFolder = entry.folderUid?.trim() + if (!linkFolder) continue + const normalizedLink = isRootFolderUid(storage, linkFolder) + ? resolveKeeperDriveRootParentUid(storage) + : linkFolder + if (normalizedLink !== targetUid) continue + const record = getKeeperDriveRecord(storage, entry.recordUid) + if (!record) continue + if (getRecordType(record).toLowerCase() !== 'pamuser') continue + if (recordHasRotationConfigured(storage, entry.recordUid)) return true + } + return false +} + +export function nsfRecordIsRoeEligible(storage: InMemoryStorage, recordUid: string): boolean { + const record = getKeeperDriveRecord(storage, recordUid) + if (!record) return false + if (getRecordType(record).toLowerCase() !== 'pamuser') return false + return recordHasRotationConfigured(storage, recordUid) +} + function toRequiredAccountUidStr(accountUid: Uint8Array): string { if (!accountUid.length) { throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) @@ -899,6 +948,22 @@ export async function patchNsfFolderMetadata( }) } +export async function patchNsfFolderInheritPermissions( + storage: InMemoryStorage, + folderUid: string, + inheritPermissions: boolean +): Promise { + const folder = getKeeperDriveFolder(storage, folderUid) + if (!folder) return + + await storage.put({ + ...folder, + inheritUserPermissions: inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE, + }) +} + export function checkFolderEditPermission( storage: InMemoryStorage, folderUid: string, diff --git a/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts index 62914dce..96977e3c 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfShortcut.ts @@ -33,6 +33,11 @@ function isShortcutFolder(storage: InMemoryStorage, folderUid: string): boolean return isRootFolderUid(storage, folderUid) || isNestedShareFolder(storage, folderUid) } +function folderDisplayName(storage: InMemoryStorage, folderUid: string): string { + if (isRootFolderUid(storage, folderUid)) return ROOT_FOLDER_LABEL + return getFolderDisplayName(storage, folderUid) +} + function formatFolderLabel(storage: InMemoryStorage, folderUid: string): string { if (isRootFolderUid(storage, folderUid)) { return `root (${ROOT_FOLDER_LABEL})` @@ -122,8 +127,11 @@ function collectShortcutRows( recordUid, title: recordTitle(storage, recordUid), folders: [...(shortcuts.get(recordUid) ?? [])] - .sort((a, b) => formatFolderLabel(storage, a).localeCompare(formatFolderLabel(storage, b))) - .map((folderUid) => formatFolderLabel(storage, folderUid)), + .sort((a, b) => folderDisplayName(storage, a).localeCompare(folderDisplayName(storage, b))) + .map((folderUid) => ({ + folderUid, + name: folderDisplayName(storage, folderUid), + })), })) } @@ -135,7 +143,11 @@ function escapeCsvCell(value: string): string { export function formatNsfShortcutTable(rows: NsfShortcutRow[]): string { if (rows.length === 0) return 'No multi-folder records found.' const headers = ['Record UID', 'Title', 'Folders'] - const tableRows = rows.map((row) => [row.recordUid, row.title, row.folders.join('; ')]) + const tableRows = rows.map((row) => [ + row.recordUid, + row.title, + row.folders.map((folder) => `${folder.name} (${folder.folderUid})`).join('; '), + ]) const columnWidths = headers.map((header, index) => Math.max(header.length, ...tableRows.map((cells) => (cells[index] || '').length)) ) @@ -146,9 +158,10 @@ export function formatNsfShortcutTable(rows: NsfShortcutRow[]): string { } export function formatNsfShortcutCsv(rows: NsfShortcutRow[]): string { - const lines = ['record_uid,title,folders'] + const lines = ['Record UID,Record Title,Folders'] for (const row of rows) { - lines.push([row.recordUid, row.title, row.folders.join('; ')].map(escapeCsvCell).join(',')) + const folders = row.folders.map((folder) => `${folder.name} (${folder.folderUid})`).join('\n') + lines.push([row.recordUid, row.title, folders].map(escapeCsvCell).join(',')) } return lines.join('\n') } @@ -157,8 +170,11 @@ export function formatNsfShortcutJson(rows: NsfShortcutRow[]): string { return JSON.stringify( rows.map((row) => ({ record_uid: row.recordUid, - title: row.title, - folders: row.folders, + record_title: row.title, + folders: row.folders.map((folder) => ({ + folder_uid: folder.folderUid, + name: folder.name, + })), })), null, 2 diff --git a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts index aa5d4f55..f1ea9c9e 100644 --- a/KeeperSdk/src/nestedShareFolders/nsfTypes.ts +++ b/KeeperSdk/src/nestedShareFolders/nsfTypes.ts @@ -44,6 +44,7 @@ export type GetNsfOptions = { format?: GetNsfFormatInput verbose?: boolean unmask?: boolean + includeDag?: boolean } export type NsfFolderAccessRow = { @@ -69,6 +70,15 @@ export type NsfRecordPermission = { awaitingApproval: boolean expiration?: number role?: NsfAccessRoleLabel + canViewTitle?: boolean + canView?: boolean + canEdit?: boolean + canListAccess?: boolean + canUpdateAccess?: boolean + canDelete?: boolean + canChangeOwnership?: boolean + canRequestAccess?: boolean + canApproveAccess?: boolean } export type NsfFolderSummary = { @@ -112,8 +122,8 @@ export type NsfRecordJsonView = { record_uid: string title: string type: string - version: number - revision: number + version?: number + revision?: number folder?: NsfRecordFolderView fields: { type: string; value: unknown[] }[] notes?: string @@ -121,6 +131,25 @@ export type NsfRecordJsonView = { share_admins: string[] } +export type NsfFolderJsonView = { + folder_uid: string + type: 'nested_share_folder' + name: string + parent_uid: string | null + folder: { uid: string | null; path: string } + owner?: string + path: string + records: NsfFolderSummary[] + user_permissions: { + accessor: string + access_type: string + role: NsfAccessRoleLabel + inherited: boolean + }[] + share_admins: string[] + team_permissions?: NsfFolderPermission[] +} + export type NsfRecordView = { objectType: NsfObjectKind.Record recordUid: string @@ -255,6 +284,7 @@ export type ListNsfOptions = { folders?: boolean records?: boolean format?: ListNsfFormatInput + roeEligible?: boolean } export type ListNsfRow = { @@ -580,10 +610,15 @@ export type UpdateNsfRecordPermissionResult = { message?: string } +export type NsfShortcutFolder = { + folderUid: string + name: string +} + export type NsfShortcutRow = { recordUid: string title: string - folders: string[] + folders: NsfShortcutFolder[] } export type ListNsfShortcutsOptions = { @@ -639,6 +674,8 @@ export type UpdateNsfFolderInput = { folder: string name?: string color?: NsfFolderColorInput + /** When set, updates whether child folders inherit this folder's user permissions. */ + inheritPermissions?: boolean quiet?: boolean } diff --git a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts index 74cd0a53..8d243f40 100644 --- a/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts +++ b/KeeperSdk/src/nestedShareFolders/removeNsfRecord.ts @@ -12,17 +12,47 @@ import { resolveNsfRecordIdentifier, } from './nsfHelpers' import { NSF_MAX_RECORD_BATCH } from './nsfConstants' -import { - NsfRemoveOperation, - type NsfRemoveOperationInput, - type NsfRemovePreviewItem, - type RemoveNsfRecordInput, - type RemoveNsfRecordResult, -} from './nsfTypes' const { RemoveAction, RecordOperationType, RemoveStatus } = folder.v3.remove const REMOVE_SUCCESS_STATUS = RemoveStatus[RemoveStatus.REMOVE_STATUS_SUCCESS] +export enum NsfRemoveOperation { + OwnerTrash = 'owner-trash', + FolderTrash = 'folder-trash', + Unlink = 'unlink', +} + +export type NsfRemoveOperationInput = NsfRemoveOperation | `${NsfRemoveOperation}` + +export type RemoveNsfRecordInput = { + records: string[] + folder?: string + operation?: NsfRemoveOperationInput + force?: boolean + dryRun?: boolean +} + +export type NsfRemovePreviewItem = { + recordUid: string + folderUid: string + status: string + impact?: { + foldersCount: number + recordsCount: number + affectedUsersCount: number + affectedTeamsCount: number + warnings: string[] + } + error?: { code: number; message: string } +} + +export type RemoveNsfRecordResult = { + confirmed: boolean + dryRun: boolean + preview: NsfRemovePreviewItem[] + message?: string +} + const OPERATION_MAP: Record = { [NsfRemoveOperation.Unlink]: RecordOperationType.UNLINK_FROM_FOLDER, [NsfRemoveOperation.FolderTrash]: RecordOperationType.MOVE_TO_FOLDER_TRASH, @@ -176,13 +206,14 @@ export function formatRemoveNsfPreview(preview: NsfRemovePreviewItem[]): string for (const item of preview) { lines.push(`Record: ${item.recordUid}`) if (item.folderUid) lines.push(` Folder: ${item.folderUid}`) - if (item.status !== REMOVE_SUCCESS_STATUS) { - lines.push(` Status: ${item.status}`) - } + lines.push(` Status: ${item.status}`) if (item.impact) { lines.push( ` Impact: folders=${item.impact.foldersCount}, records=${item.impact.recordsCount}, users=${item.impact.affectedUsersCount}, teams=${item.impact.affectedTeamsCount}` ) + for (const warning of item.impact.warnings) { + lines.push(` Warning: ${warning}`) + } } if (item.error?.message) { lines.push(` Error: ${item.error.message}`) diff --git a/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts index ccbc94a5..3aee8925 100644 --- a/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts +++ b/KeeperSdk/src/nestedShareFolders/updateNsfFolder.ts @@ -2,24 +2,73 @@ import type { Auth } from '@keeper-security/keeperapi' import { Folder, folderUpdateMessage, normal64Bytes, platform } from '@keeper-security/keeperapi' import type { InMemoryStorage } from '../storage/InMemoryStorage' import { KeeperSdkError, ResultCodes, extractErrorMessage } from '../utils' -import { NSF_FOLDER_COLORS, type NsfFolderColor } from './nsfConstants' +import { NSF_FOLDER_COLORS, NSF_MAX_FOLDER_UPDATES, type NsfFolderColor } from './nsfConstants' import { checkFolderEditPermission, + checkFolderSharePermission, ensureNestedShareFolder, getFolderDisplayName, getKeeperDriveFolder, parseFolderModifyStatus, + patchNsfFolderInheritPermissions, patchNsfFolderMetadata, requireAuthAccountUid, + resolveKeeperDriveParentUid, resolveNsfFolderIdentifier, } from './nsfHelpers' -import type { NsfFolderColorInput, UpdateNsfFolderInput, UpdateNsfFolderResult } from './nsfTypes' +import type { NsfFolderColorInput } from './mkdirNsf' type NsfFolderMetadata = { name: string color?: string } +export type UpdateNsfFolderInput = { + folder: string + name?: string + color?: NsfFolderColorInput + /** When set, updates whether child folders inherit this folder's user permissions. */ + inheritPermissions?: boolean + quiet?: boolean +} + +export type UpdateNsfFolderResult = { + folderUid: string + updated: boolean + message?: string +} + +export type UpdateNsfFolderBatchItem = { + folder: string + name?: string + color?: NsfFolderColorInput + inheritPermissions?: boolean +} + +export type UpdateNsfFolderBatchResultItem = { + folder: string + folderUid: string + updated: boolean + status?: Folder.FolderModifyStatus + message?: string +} + +export type UpdateNsfFoldersResult = { + results: UpdateNsfFolderBatchResultItem[] + anyUpdated: boolean +} + +type PreparedFolderUpdate = { + identifier: string + folderUid: string + displayName: string + newName?: string + color?: NsfFolderColor + inheritPermissions?: boolean + metadata: NsfFolderMetadata + folderData: Folder.IFolderData +} + function normalizeColor(color: NsfFolderColorInput): NsfFolderColor { if (!(NSF_FOLDER_COLORS as readonly string[]).includes(color)) { throw new KeeperSdkError( @@ -58,10 +107,33 @@ function mergeFolderMetadata( return metadata } +function normalizeUpdateName(name: string | undefined): string | undefined { + if (name === undefined) return undefined + const trimmed = name.trim() + if (!trimmed) { + throw new KeeperSdkError('Folder name cannot be empty', ResultCodes.NSF_UPDATE_FAILED) + } + return trimmed +} + +function assertHasUpdateFields(input: { + name?: string + color?: NsfFolderColorInput + inheritPermissions?: boolean +}): void { + if (input.name === undefined && input.color === undefined && input.inheritPermissions === undefined) { + throw new KeeperSdkError( + 'New folder name, color, and/or inheritPermissions parameters are required.', + ResultCodes.NSF_UPDATE_FAILED + ) + } +} + async function buildUpdateFolderData( storage: InMemoryStorage, folderUid: string, - metadata: NsfFolderMetadata + metadata: NsfFolderMetadata, + inheritPermissions?: boolean ): Promise { const folderKey = await storage.getKeyBytes(folderUid) if (!folderKey) { @@ -73,16 +145,29 @@ async function buildUpdateFolderData( const encryptedData = await platform.aesGcmEncrypt(platform.stringToBytes(JSON.stringify(metadata)), folderKey) - return Folder.FolderData.create({ + const stored = getKeeperDriveFolder(storage, folderUid) + const resolvedParentUid = resolveKeeperDriveParentUid(storage, stored?.parentUid) + + const folderData = Folder.FolderData.create({ folderUid: normal64Bytes(folderUid), + parentUid: resolvedParentUid ? normal64Bytes(resolvedParentUid) : undefined, data: encryptedData, }) + + if (inheritPermissions !== undefined) { + folderData.inheritUserPermissions = inheritPermissions + ? Folder.SetBooleanValue.BOOLEAN_TRUE + : Folder.SetBooleanValue.BOOLEAN_FALSE + } + + return folderData } function buildSuccessMessage( folderDisplayName: string, newName: string | undefined, color: NsfFolderColor | undefined, + inheritPermissions: boolean | undefined, quiet?: boolean ): string | undefined { if (quiet) return undefined @@ -92,31 +177,31 @@ function buildSuccessMessage( if (color !== undefined) { return `Folder "${folderDisplayName}" color has been updated.` } + if (inheritPermissions !== undefined) { + return `Folder "${folderDisplayName}" inherit permissions has been updated.` + } return `Folder "${folderDisplayName}" has been updated.` } -export async function updateNestedShareFolder( +async function prepareFolderUpdate( storage: InMemoryStorage, auth: Auth, - input: UpdateNsfFolderInput -): Promise { + input: { + folder: string + name?: string + color?: NsfFolderColorInput + inheritPermissions?: boolean + } +): Promise { const folderArg = input.folder?.trim() if (!folderArg) { throw new KeeperSdkError('Enter the path or UID of existing folder.', ResultCodes.NSF_UPDATE_FAILED) } - let newName = input.name - if (newName !== undefined) { - newName = newName.trim() - if (!newName) { - throw new KeeperSdkError('Folder name cannot be empty', ResultCodes.NSF_UPDATE_FAILED) - } - } - + assertHasUpdateFields(input) + const newName = normalizeUpdateName(input.name) const color = input.color !== undefined ? normalizeColor(input.color) : undefined - if (newName === undefined && color === undefined) { - throw new KeeperSdkError('New folder name and/or color parameters are required.', ResultCodes.NSF_UPDATE_FAILED) - } + const inheritPermissions = input.inheritPermissions const folderUid = resolveNsfFolderIdentifier(storage, folderArg) if (!folderUid) { @@ -125,24 +210,61 @@ export async function updateNestedShareFolder( ensureNestedShareFolder(storage, folderUid, folderArg) const accountUid = requireAuthAccountUid(auth) - checkFolderEditPermission(storage, folderUid, auth.username, accountUid) - const folderDisplayName = getFolderDisplayName(storage, folderUid) + if (newName !== undefined || color !== undefined) { + checkFolderEditPermission(storage, folderUid, auth.username, accountUid) + } + if (inheritPermissions !== undefined) { + checkFolderSharePermission(storage, folderUid, auth.username, accountUid) + } - try { - const existing = readExistingMetadata(folderUid, storage) - const metadata = mergeFolderMetadata(existing, newName, color) - const folderData = await buildUpdateFolderData(storage, folderUid, metadata) + const displayName = getFolderDisplayName(storage, folderUid) + const existing = readExistingMetadata(folderUid, storage) + const metadata = mergeFolderMetadata(existing, newName, color) + const folderData = await buildUpdateFolderData(storage, folderUid, metadata, inheritPermissions) + + return { + identifier: folderArg, + folderUid, + displayName, + newName, + color, + inheritPermissions, + metadata, + folderData, + } +} + +async function persistFolderUpdate(storage: InMemoryStorage, prepared: PreparedFolderUpdate): Promise { + // Always refresh local folder data to match the encrypted payload we sent. + await patchNsfFolderMetadata(storage, prepared.folderUid, prepared.metadata) + if (prepared.inheritPermissions !== undefined) { + await patchNsfFolderInheritPermissions(storage, prepared.folderUid, prepared.inheritPermissions) + } +} - const response = await auth.executeRest(folderUpdateMessage({ folderData: [folderData] })) +export async function updateNestedShareFolder( + storage: InMemoryStorage, + auth: Auth, + input: UpdateNsfFolderInput +): Promise { + try { + const prepared = await prepareFolderUpdate(storage, auth, input) + const response = await auth.executeRest(folderUpdateMessage({ folderData: [prepared.folderData] })) parseFolderModifyStatus(response.folderUpdateResults?.[0], ResultCodes.NSF_UPDATE_FAILED) - await patchNsfFolderMetadata(storage, folderUid, metadata) + await persistFolderUpdate(storage, prepared) return { - folderUid, + folderUid: prepared.folderUid, updated: true, - message: buildSuccessMessage(folderDisplayName, newName, color, input.quiet), + message: buildSuccessMessage( + prepared.displayName, + prepared.newName, + prepared.color, + prepared.inheritPermissions, + input.quiet + ), } } catch (err) { if (err instanceof KeeperSdkError) throw err @@ -152,3 +274,78 @@ export async function updateNestedShareFolder( ) } } + +/** + * Batch-update Nested Share Folders via `vault/folders/v3/update`. + * Chunks into requests of at most {@link NSF_MAX_FOLDER_UPDATES} (100) folders. + * Each item must include folderUid and either encrypted data and/or inheritUserPermissions; + * parentUid is included from local storage as required by the API. + */ +export async function updateNestedShareFolders( + storage: InMemoryStorage, + auth: Auth, + updates: UpdateNsfFolderBatchItem[] +): Promise { + if (!updates?.length) { + throw new KeeperSdkError('At least one folder update is required.', ResultCodes.NSF_UPDATE_FAILED) + } + + try { + const preparedAll: PreparedFolderUpdate[] = [] + for (const item of updates) { + preparedAll.push(await prepareFolderUpdate(storage, auth, item)) + } + + const results: UpdateNsfFolderBatchResultItem[] = [] + let anyUpdated = false + + for (let i = 0; i < preparedAll.length; i += NSF_MAX_FOLDER_UPDATES) { + const batch = preparedAll.slice(i, i + NSF_MAX_FOLDER_UPDATES) + const response = await auth.executeRest( + folderUpdateMessage({ + folderData: batch.map((item) => item.folderData), + }) + ) + const remoteResults = response.folderUpdateResults ?? [] + + for (let j = 0; j < batch.length; j++) { + const prepared = batch[j] + const modifyResult = remoteResults[j] + const status = modifyResult?.status ?? Folder.FolderModifyStatus.SUCCESS + const success = status === Folder.FolderModifyStatus.SUCCESS + const statusName = Folder.FolderModifyStatus[status] ?? String(status) + const message = + modifyResult?.message || + (success + ? buildSuccessMessage( + prepared.displayName, + prepared.newName, + prepared.color, + prepared.inheritPermissions + ) + : `Folder operation failed (${statusName}).`) + + if (success) { + anyUpdated = true + await persistFolderUpdate(storage, prepared) + } + + results.push({ + folder: prepared.identifier, + folderUid: prepared.folderUid, + updated: success, + status, + message: message || undefined, + }) + } + } + + return { results, anyUpdated } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to update nested share folders: ${extractErrorMessage(err)}`, + ResultCodes.NSF_UPDATE_FAILED + ) + } +} diff --git a/KeeperSdk/src/nodes/NodeManager.ts b/KeeperSdk/src/nodes/NodeManager.ts new file mode 100644 index 00000000..c28c70f9 --- /dev/null +++ b/KeeperSdk/src/nodes/NodeManager.ts @@ -0,0 +1,121 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { + formatNodesTable, + listNodes, + renderNodesAsciiTable, + type FormatNodesTableOptions, + type FormattedNodesTable, + type ListNodeRow, + type ListNodesOptions, +} from './listNodes' +import { + formatNodeView, + nodeViewTable, + viewNode, + type FormatNodeViewOptions, + type FormattedNodeViewTable, + type NodeView, +} from './viewNode' +import { + addNodes, + formatAddNodeResult, + renderAddNodeAsciiTable, + type AddNodeInput, + type AddNodeResult, + type FormattedAddNodeTable, +} from './addNode' +import { + formatUpdateNodeResult, + renderUpdateNodeAsciiTable, + updateNodes, + type FormattedUpdateNodeTable, + type UpdateNodeInput, + type UpdateNodeResult, +} from './updateNode' +import { + deleteNodes, + formatDeleteNodeResult, + renderDeleteNodeAsciiTable, + type DeleteNodeInput, + type DeleteNodeResult, + type FormattedDeleteNodeTable, +} from './deleteNode' + +export type AuthProvider = () => Auth + +export class NodeManager { + private readonly authProvider: AuthProvider + + constructor(authProvider: AuthProvider) { + this.authProvider = authProvider + } + + public async listNodes(options: ListNodesOptions = {}): Promise { + return listNodes(this.requireAuth(), options) + } + + public formatNodesTable(rows: ListNodeRow[], options: FormatNodesTableOptions = {}): FormattedNodesTable { + return formatNodesTable(rows, options) + } + + public renderNodesAsciiTable(table: FormattedNodesTable, options: { minColWidth?: number } = {}): string { + return renderNodesAsciiTable(table, options) + } + + public async viewNode(identifier: string): Promise { + return viewNode(this.requireAuth(), identifier) + } + + public formatNodeView(view: NodeView, options: FormatNodeViewOptions = {}): FormattedNodeViewTable { + return formatNodeView(view, options) + } + + public nodeViewTable(table: FormattedNodeViewTable): string { + return nodeViewTable(table) + } + + public async addNodes(input: AddNodeInput): Promise { + return addNodes(this.requireAuth(), input) + } + + public formatAddNodeResult(result: AddNodeResult): FormattedAddNodeTable { + return formatAddNodeResult(result) + } + + public renderAddNodeAsciiTable(table: FormattedAddNodeTable): string { + return renderAddNodeAsciiTable(table) + } + + public async updateNodes(input: UpdateNodeInput): Promise { + return updateNodes(this.requireAuth(), input) + } + + public formatUpdateNodeResult(result: UpdateNodeResult): FormattedUpdateNodeTable { + return formatUpdateNodeResult(result) + } + + public renderUpdateNodeAsciiTable(table: FormattedUpdateNodeTable): string { + return renderUpdateNodeAsciiTable(table) + } + + public async deleteNodes(input: DeleteNodeInput): Promise { + return deleteNodes(this.requireAuth(), input) + } + + public formatDeleteNodeResult(result: DeleteNodeResult): FormattedDeleteNodeTable { + return formatDeleteNodeResult(result) + } + + public renderDeleteNodeAsciiTable(table: FormattedDeleteNodeTable): string { + return renderDeleteNodeAsciiTable(table) + } + + private requireAuth(): Auth { + const auth = this.authProvider() + if (!auth) { + throw new KeeperSdkError('You are not logged in. Please log in first.', ResultCodes.NOT_LOGGED_IN) + } + return auth + } +} diff --git a/KeeperSdk/src/nodes/addNode.ts b/KeeperSdk/src/nodes/addNode.ts new file mode 100644 index 00000000..d3886848 --- /dev/null +++ b/KeeperSdk/src/nodes/addNode.ts @@ -0,0 +1,208 @@ +import { + encryptObjectForStorage, + enterpriseAllocateIdsCommand, + type Auth, + type KeeperResponse, + type RestCommand, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from '../teams/teamUtils' +import { + assertCommandSucceeded, + buildNodesByLowerName, + NODE_TABLE_HEADERS, + nodeDisplayName, + nodePathOrFallback, + normalizeIdentifiers, + renderNodeResultTable, + validateNodeName, +} from './nodeUtils' + +const NODE_ADD_COMMAND = 'node_add' + +type NodeEditRequest = { + node_id: number + parent_id?: number + encrypted_data: string +} + +const ADD_NODE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Nodes] + +export enum AddNodeStatus { + Created = 'created', + Skipped = 'skipped', + Failed = 'failed', +} + +export enum AddNodeSkipReason { + AlreadyExistsInParent = 'already_exists_in_parent', +} + +export type AddNodeInput = { + nodes: string[] + parent?: string | number | null +} + +export type AddNodeItemResult = { + nodeId: number + nodeName: string + parentId: number + status: AddNodeStatus + skipReason?: AddNodeSkipReason + message?: string +} + +export type AddNodeResult = { + success: boolean + parentNodeId: number + parentNodeName: string + items: AddNodeItemResult[] + created: number + skipped: number + failed: number +} + +export type FormattedAddNodeTable = { + headers: string[] + rows: string[][] + parentNodeName: string + summary: string +} + +export async function addNodes(auth: Auth, input: AddNodeInput): Promise { + const names = normalizeIdentifiers(input.nodes) + if (names.length === 0) { + throw new KeeperSdkError('No nodes to add.', ResultCodes.NO_NODES_TO_ADD) + } + for (const name of names) validateNodeName(name) + + const needsNameLookup = parentNeedsNameLookup(input.parent ?? null) + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(ADD_NODE_INCLUDES) + const nodes = response.nodes || [] + if (needsNameLookup) { + const displayNames = await enterpriseData.getDisplayNames() + applyDecryptedNodeNames(nodes, displayNames.nodes) + await enterpriseData.decryptNodeNames(nodes) + } + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const parentNode = resolveParentNode(nodes, input.parent ?? null) + const parentNodeId = parentNode.node_id + const parentNodeName = nodePathOrFallback(nodes, parentNode) + const byLowerName = buildNodesByLowerName(nodes) + + const treeKey = await enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer nodes.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + + const items: AddNodeItemResult[] = [] + const seen = new Set() + + for (const raw of names) { + const lower = raw.toLowerCase() + if (seen.has(lower)) continue + seen.add(lower) + + const conflicts = (byLowerName.get(lower) || []).filter((n) => n.parent_id === parentNodeId) + if (conflicts.length > 0) { + items.push({ + nodeId: conflicts[0]!.node_id, + nodeName: nodeDisplayName(conflicts[0]!), + parentId: parentNodeId, + status: AddNodeStatus.Skipped, + skipReason: AddNodeSkipReason.AlreadyExistsInParent, + message: `Node "${raw}" already exists under parent ${parentNodeId}.`, + }) + continue + } + + try { + const nodeId = await allocateNodeId(auth) + const encryptedData = await encryptObjectForStorage({ displayname: raw }, treeKey) + await sendNodeAdd(auth, { + node_id: nodeId, + parent_id: parentNodeId, + encrypted_data: encryptedData, + }) + items.push({ + nodeId, + nodeName: raw, + parentId: parentNodeId, + status: AddNodeStatus.Created, + }) + } catch (err) { + items.push({ + nodeId: 0, + nodeName: raw, + parentId: parentNodeId, + status: AddNodeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + const created = items.filter((i) => i.status === AddNodeStatus.Created).length + const skipped = items.filter((i) => i.status === AddNodeStatus.Skipped).length + const failed = items.filter((i) => i.status === AddNodeStatus.Failed).length + return { + success: failed === 0 && created > 0, + parentNodeId, + parentNodeName, + items, + created, + skipped, + failed, + } +} + +async function allocateNodeId(auth: Auth): Promise { + const response = await auth.executeRestCommand(enterpriseAllocateIdsCommand({ number_requested: 1 })) + assertCommandSucceeded(response, 'enterprise_allocate_ids failed', ResultCodes.NODE_ADD_FAILED) + const baseId = Number((response as { base_id?: number }).base_id) + if (!Number.isFinite(baseId) || baseId <= 0) { + throw new KeeperSdkError('Failed to allocate node ID.', ResultCodes.NODE_ADD_FAILED) + } + return baseId +} + +async function sendNodeAdd(auth: Auth, payload: NodeEditRequest): Promise { + const command: RestCommand = { + baseRequest: { command: NODE_ADD_COMMAND }, + request: payload, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + assertCommandSucceeded(response, `node_add failed for node_id=${payload.node_id}`, ResultCodes.NODE_ADD_FAILED) +} + +export function formatAddNodeResult(result: AddNodeResult): FormattedAddNodeTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.nodeName, + item.nodeId ? String(item.nodeId) : '', + String(item.parentId), + item.message || item.skipReason || '', + ]) + return { + headers: [...NODE_TABLE_HEADERS], + rows, + parentNodeName: result.parentNodeName, + summary: `Created: ${result.created} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderAddNodeAsciiTable(table: FormattedAddNodeTable): string { + return `Parent: ${table.parentNodeName}\n${renderNodeResultTable(table.headers, table.rows, table.summary)}` +} diff --git a/KeeperSdk/src/nodes/deleteNode.ts b/KeeperSdk/src/nodes/deleteNode.ts new file mode 100644 index 00000000..be827b42 --- /dev/null +++ b/KeeperSdk/src/nodes/deleteNode.ts @@ -0,0 +1,151 @@ +import type { Auth, KeeperResponse, RestCommand } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot } from '../teams/teamUtils' +import { + assertCommandSucceeded, + NODE_TABLE_HEADERS, + nodeDisplayName, + normalizeIdentifiers, + renderNodeResultTable, + resolveExistingNodes, +} from './nodeUtils' + +const NODE_DELETE_COMMAND = 'node_delete' +const DELETE_NODE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Nodes] + +type NodeDeleteRequest = { + node_id: number +} + +export enum DeleteNodeStatus { + Deleted = 'deleted', + Failed = 'failed', +} + +export type DeleteNodeInput = { + nodes: string[] +} + +export type DeleteNodeItemResult = { + nodeId: number + nodeName: string + parentId: number + status: DeleteNodeStatus + message?: string +} + +export type DeleteNodeResult = { + success: boolean + items: DeleteNodeItemResult[] + deleted: number + failed: number +} + +export type FormattedDeleteNodeTable = { + headers: string[] + rows: string[][] + summary: string +} + +export async function deleteNodes(auth: Auth, input: DeleteNodeInput): Promise { + const identifiers = normalizeIdentifiers(input.nodes) + if (identifiers.length === 0) { + throw new KeeperSdkError('No nodes to delete.', ResultCodes.NO_NODES_TO_DELETE) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(DELETE_NODE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const resolved = resolveExistingNodes(nodes, identifiers) + const depth = (nodeId: number): number => { + let d = 0 + let current = nodes.find((n) => n.node_id === nodeId) + const seen = new Set() + while (current?.parent_id) { + if (seen.has(current.parent_id)) break + seen.add(current.parent_id) + d++ + current = nodes.find((n) => n.node_id === current!.parent_id) + } + return d + } + resolved.sort((a, b) => depth(b.node_id) - depth(a.node_id)) + + const items: DeleteNodeItemResult[] = [] + for (const node of resolved) { + const name = nodeDisplayName(node) + const parentId = node.parent_id ?? 0 + if (!node.parent_id) { + items.push({ + nodeId: node.node_id, + nodeName: name, + parentId, + status: DeleteNodeStatus.Failed, + message: 'Cannot delete the root node.', + }) + continue + } + try { + const command: RestCommand = { + baseRequest: { command: NODE_DELETE_COMMAND }, + request: { node_id: node.node_id }, + authorization: {}, + } + const responseDelete = await auth.executeRestCommand(command) + assertCommandSucceeded( + responseDelete, + `node_delete failed for node_id=${node.node_id}`, + ResultCodes.NODE_DELETE_FAILED + ) + items.push({ + nodeId: node.node_id, + nodeName: name, + parentId, + status: DeleteNodeStatus.Deleted, + }) + } catch (err) { + const message = extractErrorMessage(err) + items.push({ + nodeId: node.node_id, + nodeName: name, + parentId, + status: DeleteNodeStatus.Failed, + message: + message.includes('objects on this node') || message.includes('first delete') + ? `${message} Note: remove users, teams, roles, and child nodes first.` + : message, + }) + } + } + + const deleted = items.filter((i) => i.status === DeleteNodeStatus.Deleted).length + const failed = items.filter((i) => i.status === DeleteNodeStatus.Failed).length + return { success: failed === 0 && deleted > 0, items, deleted, failed } +} + +export function formatDeleteNodeResult(result: DeleteNodeResult): FormattedDeleteNodeTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.nodeName, + String(item.nodeId), + String(item.parentId), + item.message || '', + ]) + return { + headers: [...NODE_TABLE_HEADERS], + rows, + summary: `Deleted: ${result.deleted} Failed: ${result.failed}`, + } +} + +export function renderDeleteNodeAsciiTable(table: FormattedDeleteNodeTable): string { + return renderNodeResultTable(table.headers, table.rows, table.summary) +} diff --git a/KeeperSdk/src/nodes/index.ts b/KeeperSdk/src/nodes/index.ts new file mode 100644 index 00000000..b4d190c8 --- /dev/null +++ b/KeeperSdk/src/nodes/index.ts @@ -0,0 +1,30 @@ +export { NodeManager } from './NodeManager' +export type { AuthProvider } from './NodeManager' + +export { + listNodes, + formatNodesTable, + renderNodesAsciiTable, + NodeColumn, + SUPPORTED_NODE_COLUMNS, + DEFAULT_NODE_COLUMNS, +} from './listNodes' +export type { + ListNodesOptions, + ListNodeRow, + NodeColumnInput, + FormattedNodesTable, + FormatNodesTableOptions, +} from './listNodes' + +export { viewNode, formatNodeView, nodeViewTable } from './viewNode' +export type { NodeView, FormatNodeViewOptions, FormattedNodeViewTable, NodeViewTableRow } from './viewNode' + +export { addNodes, formatAddNodeResult, renderAddNodeAsciiTable, AddNodeStatus, AddNodeSkipReason } from './addNode' +export type { AddNodeInput, AddNodeResult, AddNodeItemResult, FormattedAddNodeTable } from './addNode' + +export { updateNodes, formatUpdateNodeResult, renderUpdateNodeAsciiTable, UpdateNodeStatus } from './updateNode' +export type { UpdateNodeInput, UpdateNodeResult, UpdateNodeItemResult, FormattedUpdateNodeTable } from './updateNode' + +export { deleteNodes, formatDeleteNodeResult, renderDeleteNodeAsciiTable, DeleteNodeStatus } from './deleteNode' +export type { DeleteNodeInput, DeleteNodeResult, DeleteNodeItemResult, FormattedDeleteNodeTable } from './deleteNode' diff --git a/KeeperSdk/src/nodes/listNodes.ts b/KeeperSdk/src/nodes/listNodes.ts new file mode 100644 index 00000000..d6a49ceb --- /dev/null +++ b/KeeperSdk/src/nodes/listNodes.ts @@ -0,0 +1,164 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { isNumber, resolveSearchPattern } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseNode } from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, NODE_PATH_SEPARATOR } from '../teams/teamUtils' +import { nodeDisplayName } from './nodeUtils' + +export enum NodeColumn { + NodeId = 'node_id', + Name = 'name', + ParentId = 'parent_id', + Parent = 'parent', + Path = 'path', + Isolated = 'isolated', +} + +export const SUPPORTED_NODE_COLUMNS: NodeColumn[] = [ + NodeColumn.NodeId, + NodeColumn.Name, + NodeColumn.ParentId, + NodeColumn.Parent, + NodeColumn.Path, + NodeColumn.Isolated, +] + +export const DEFAULT_NODE_COLUMNS: NodeColumn[] = [ + NodeColumn.NodeId, + NodeColumn.Name, + NodeColumn.Parent, + NodeColumn.Path, +] + +export type NodeColumnInput = NodeColumn | `${NodeColumn}` + +export type ListNodesOptions = { + pattern?: string | null + columns?: NodeColumnInput[] | '*' | string | null +} + +export type ListNodeRow = { + node_id: number + name: string + parent_id?: number + parent?: string + path?: string + isolated?: boolean +} + +export type FormattedNodesTable = { + headers: string[] + rows: string[][] +} + +export type FormatNodesTableOptions = { + columns?: ListNodesOptions['columns'] +} + +const HEADER_BY_COLUMN: Record = { + [NodeColumn.NodeId]: 'Node ID', + [NodeColumn.Name]: 'Name', + [NodeColumn.ParentId]: 'Parent ID', + [NodeColumn.Parent]: 'Parent', + [NodeColumn.Path]: 'Path', + [NodeColumn.Isolated]: 'Isolated', +} + +function resolveColumns(raw: ListNodesOptions['columns']): NodeColumn[] { + if (!raw) return [...DEFAULT_NODE_COLUMNS] + if (raw === '*') return [...SUPPORTED_NODE_COLUMNS] + if (typeof raw === 'string') { + return raw + .split(',') + .map((p) => p.trim().toLowerCase()) + .filter(Boolean) + .map((p) => p as NodeColumn) + .filter((p) => SUPPORTED_NODE_COLUMNS.includes(p)) + } + return raw + .map((p) => String(p).trim().toLowerCase() as NodeColumn) + .filter((p) => SUPPORTED_NODE_COLUMNS.includes(p)) +} + +export async function listNodes(auth: Auth, options: ListNodesOptions = {}): Promise { + const columns = resolveColumns(options.columns) + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData([EnterpriseDataInclude.Nodes]), + enterpriseData.getDisplayNames(), + ]) + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const byId = new Map(nodes.map((n) => [n.node_id, n] as const)) + const pattern = resolveSearchPattern(options.pattern)?.toLowerCase() ?? null + + const rows: ListNodeRow[] = [] + for (const node of nodes) { + const name = nodeDisplayName(node) + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) + if (pattern) { + const haystack = `${name} ${node.node_id} ${path}`.toLowerCase() + if (!haystack.includes(pattern)) continue + } + + const parent = node.parent_id ? byId.get(node.parent_id) : undefined + const row: ListNodeRow = { node_id: node.node_id, name } + if (columns.includes(NodeColumn.ParentId) && node.parent_id != null) row.parent_id = node.parent_id + if (columns.includes(NodeColumn.Parent)) { + row.parent = parent ? nodeDisplayName(parent) : node.parent_id ? String(node.parent_id) : '' + } + if (columns.includes(NodeColumn.Path)) row.path = path || name + if (columns.includes(NodeColumn.Isolated)) row.isolated = !!node.restrict_visibility + rows.push(row) + } + + rows.sort((a, b) => (a.path || a.name).localeCompare(b.path || b.name, undefined, { sensitivity: 'base' })) + return rows +} + +export function formatNodesTable(rows: ListNodeRow[], options: FormatNodesTableOptions = {}): FormattedNodesTable { + const columns = resolveColumns(options.columns) + const headers = columns.map((c) => HEADER_BY_COLUMN[c]) + const tableRows = rows.map((row) => + columns.map((column) => { + switch (column) { + case NodeColumn.NodeId: + return String(row.node_id) + case NodeColumn.Name: + return row.name + case NodeColumn.ParentId: + return row.parent_id != null ? String(row.parent_id) : '' + case NodeColumn.Parent: + return row.parent ?? '' + case NodeColumn.Path: + return row.path ?? '' + case NodeColumn.Isolated: + return row.isolated ? 'yes' : 'no' + default: + return '' + } + }) + ) + return { headers, rows: tableRows } +} + +export function renderNodesAsciiTable(table: FormattedNodesTable, options: { minColWidth?: number } = {}): string { + const min = options.minColWidth ?? 2 + const { headers, rows } = table + const widths = headers.map((header, index) => + Math.max(min, header.length, ...rows.map((row) => (row[index] || '').length)) + ) + const pad = (cell: string, i: number) => cell.padEnd(widths[i]!) + const formatRow = (cells: string[]) => cells.map((cell, i) => pad(cell, i)).join(' ') + return [formatRow(headers), formatRow(widths.map((w) => '-'.repeat(w))), ...rows.map(formatRow)].join('\n') +} + +export function isNumberLike(value: unknown): value is number { + return isNumber(value) +} + +export type { EnterpriseNode } diff --git a/KeeperSdk/src/nodes/nodeUtils.ts b/KeeperSdk/src/nodes/nodeUtils.ts new file mode 100644 index 00000000..de833e2b --- /dev/null +++ b/KeeperSdk/src/nodes/nodeUtils.ts @@ -0,0 +1,121 @@ +import type { KeeperResponse } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataManager, type EnterpriseNode } from '../teams/enterpriseData' +import { NODE_PATH_SEPARATOR } from '../teams/teamUtils' + +export const NODE_TABLE_HEADERS = ['#', 'Status', 'Node Name', 'Node ID', 'Parent ID', 'Detail'] as const +export const MAX_NODE_NAME_LENGTH = 100 + +export function normalizeIdentifiers(values: ReadonlyArray | null | undefined): string[] { + return (values || []) + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0) +} + +export function validateNodeName(name: string): void { + const trimmed = name.trim() + if (!trimmed) { + throw new KeeperSdkError('Node name cannot be empty.', ResultCodes.NODE_NAME_EMPTY) + } + if (trimmed.length > MAX_NODE_NAME_LENGTH) { + throw new KeeperSdkError( + `Node name exceeds ${MAX_NODE_NAME_LENGTH} characters: "${trimmed.substring(0, 30)}..."`, + ResultCodes.NODE_NAME_TOO_LONG + ) + } +} + +export function nodeDisplayName(node: EnterpriseNode): string { + return (node.displayName || '').trim() || String(node.node_id) +} + +export function nodePathOrFallback(nodes: EnterpriseNode[], node: EnterpriseNode): string { + const path = EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) + return path || nodeDisplayName(node) +} + +export function buildNodesByLowerName(nodes: EnterpriseNode[]): Map { + const map = new Map() + for (const node of nodes) { + const key = (node.displayName || '').trim().toLowerCase() + if (!key) continue + const bucket = map.get(key) + if (bucket) bucket.push(node) + else map.set(key, [node]) + } + return map +} + +export function resolveExistingNodes(nodes: EnterpriseNode[], identifiers: string[]): EnterpriseNode[] { + const byId = new Map() + for (const node of nodes) byId.set(node.node_id, node) + const byLowerName = buildNodesByLowerName(nodes) + + const found = new Map() + const missing: string[] = [] + + for (const identifier of identifiers) { + const numericId = Number(identifier) + if (Number.isInteger(numericId) && byId.has(numericId)) { + found.set(numericId, byId.get(numericId)!) + continue + } + const matches = byLowerName.get(identifier.toLowerCase()) + if (!matches || matches.length === 0) { + missing.push(identifier) + continue + } + if (matches.length > 1) { + throw new KeeperSdkError( + `Node name "${identifier}" is not unique. Use Node ID.`, + ResultCodes.MULTIPLE_NODE_MATCHES + ) + } + found.set(matches[0]!.node_id, matches[0]!) + } + + if (missing.length > 0) { + throw new KeeperSdkError( + `Node name(s) "${missing.join(', ')}" could not be resolved.`, + ResultCodes.NODE_NOT_FOUND + ) + } + return Array.from(found.values()) +} + +export function isAncestorOf(nodes: EnterpriseNode[], ancestorId: number, descendantId: number): boolean { + if (ancestorId === descendantId) return true + const byId = new Map(nodes.map((n) => [n.node_id, n] as const)) + let current = byId.get(descendantId) + const seen = new Set() + while (current?.parent_id) { + if (current.parent_id === ancestorId) return true + if (seen.has(current.parent_id)) break + seen.add(current.parent_id) + current = byId.get(current.parent_id) + } + return false +} + +export function assertCommandSucceeded(response: KeeperResponse, fallbackMessage: string, fallbackCode: string): void { + if ((response.result || '').toLowerCase() === 'fail') { + throw new KeeperSdkError( + response.message || response.result_code || fallbackMessage, + response.result_code || fallbackCode + ) + } +} + +export function renderNodeResultTable(headers: readonly string[], rows: string[][], summary: string): string { + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map((row) => (row[index] || '').length), 2) + ) + const pad = (cell: string, i: number) => cell.padEnd(widths[i]!) + const formatRow = (cells: string[]) => cells.map((cell, i) => pad(cell, i)).join(' ') + return [formatRow([...headers]), formatRow(widths.map((w) => '-'.repeat(w))), ...rows.map(formatRow), summary].join( + '\n' + ) +} diff --git a/KeeperSdk/src/nodes/updateNode.ts b/KeeperSdk/src/nodes/updateNode.ts new file mode 100644 index 00000000..18579a9d --- /dev/null +++ b/KeeperSdk/src/nodes/updateNode.ts @@ -0,0 +1,184 @@ +import { encryptObjectForStorage, type Auth, type KeeperResponse, type RestCommand } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseNode } from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, resolveParentNode } from '../teams/teamUtils' +import { + assertCommandSucceeded, + isAncestorOf, + NODE_TABLE_HEADERS, + nodeDisplayName, + normalizeIdentifiers, + renderNodeResultTable, + resolveExistingNodes, + validateNodeName, +} from './nodeUtils' + +const NODE_UPDATE_COMMAND = 'node_update' +const UPDATE_NODE_INCLUDES: EnterpriseDataInclude[] = [EnterpriseDataInclude.Nodes] + +type NodeEditRequest = { + node_id: number + parent_id?: number + encrypted_data: string +} + +export enum UpdateNodeStatus { + Updated = 'updated', + Failed = 'failed', + Skipped = 'skipped', +} + +export type UpdateNodeInput = { + nodes: string[] + name?: string + parent?: string | number | null +} + +export type UpdateNodeItemResult = { + nodeId: number + nodeName: string + parentId: number + status: UpdateNodeStatus + message?: string +} + +export type UpdateNodeResult = { + success: boolean + items: UpdateNodeItemResult[] + updated: number + failed: number +} + +export type FormattedUpdateNodeTable = { + headers: string[] + rows: string[][] + summary: string +} + +export async function updateNodes(auth: Auth, input: UpdateNodeInput): Promise { + const identifiers = normalizeIdentifiers(input.nodes) + if (identifiers.length === 0) { + throw new KeeperSdkError('No nodes to update.', ResultCodes.NO_NODES_TO_UPDATE) + } + + const newName = input.name?.trim() + if (newName) validateNodeName(newName) + if (newName && identifiers.length > 1) { + throw new KeeperSdkError( + 'Cannot assign the same name to multiple nodes.', + ResultCodes.MULTIPLE_NODE_RENAME_NOT_ALLOWED + ) + } + const hasParent = input.parent !== undefined && input.parent !== null && String(input.parent).trim() !== '' + if (!newName && !hasParent) { + throw new KeeperSdkError('Specify --name and/or --parent to update a node.', ResultCodes.NO_NODES_TO_UPDATE) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(UPDATE_NODE_INCLUDES) + const nodes = response.nodes || [] + const displayNames = await enterpriseData.getDisplayNames() + applyDecryptedNodeNames(nodes, displayNames.nodes) + await enterpriseData.decryptNodeNames(nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const resolved = resolveExistingNodes(nodes, identifiers) + let parentNode: EnterpriseNode | undefined + if (hasParent) { + parentNode = resolveParentNode(nodes, input.parent!) + } + + const treeKey = newName || resolved.some((n) => !n.encrypted_data) ? await enterpriseData.getTreeKey() : null + if (newName && !treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer nodes.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + + const items: UpdateNodeItemResult[] = [] + for (const node of resolved) { + const currentName = nodeDisplayName(node) + const parentId = parentNode?.node_id ?? node.parent_id ?? 0 + try { + if (parentNode && isAncestorOf(nodes, node.node_id, parentNode.node_id)) { + items.push({ + nodeId: node.node_id, + nodeName: currentName, + parentId, + status: UpdateNodeStatus.Failed, + message: 'Cannot move a node into itself or its children.', + }) + continue + } + + let encryptedData = node.encrypted_data || '' + if (newName) { + encryptedData = await encryptObjectForStorage({ displayname: newName }, treeKey!) + } else if (!encryptedData && treeKey) { + encryptedData = await encryptObjectForStorage({ displayname: currentName }, treeKey) + } + if (!encryptedData) { + throw new KeeperSdkError('Encrypted node data is unavailable.', ResultCodes.NODE_UPDATE_FAILED) + } + + await sendNodeUpdate(auth, { + node_id: node.node_id, + parent_id: parentId || undefined, + encrypted_data: encryptedData, + }) + items.push({ + nodeId: node.node_id, + nodeName: newName || currentName, + parentId, + status: UpdateNodeStatus.Updated, + }) + } catch (err) { + items.push({ + nodeId: node.node_id, + nodeName: currentName, + parentId, + status: UpdateNodeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + const updated = items.filter((i) => i.status === UpdateNodeStatus.Updated).length + const failed = items.filter((i) => i.status === UpdateNodeStatus.Failed).length + return { success: failed === 0 && updated > 0, items, updated, failed } +} + +async function sendNodeUpdate(auth: Auth, payload: NodeEditRequest): Promise { + const command: RestCommand = { + baseRequest: { command: NODE_UPDATE_COMMAND }, + request: payload, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + assertCommandSucceeded( + response, + `node_update failed for node_id=${payload.node_id}`, + ResultCodes.NODE_UPDATE_FAILED + ) +} + +export function formatUpdateNodeResult(result: UpdateNodeResult): FormattedUpdateNodeTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.nodeName, + String(item.nodeId), + String(item.parentId), + item.message || '', + ]) + return { + headers: [...NODE_TABLE_HEADERS], + rows, + summary: `Updated: ${result.updated} Failed: ${result.failed}`, + } +} + +export function renderUpdateNodeAsciiTable(table: FormattedUpdateNodeTable): string { + return renderNodeResultTable(table.headers, table.rows, table.summary) +} diff --git a/KeeperSdk/src/nodes/viewNode.ts b/KeeperSdk/src/nodes/viewNode.ts new file mode 100644 index 00000000..cf606951 --- /dev/null +++ b/KeeperSdk/src/nodes/viewNode.ts @@ -0,0 +1,113 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager, type EnterpriseNode } from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, NODE_PATH_SEPARATOR } from '../teams/teamUtils' +import { nodeDisplayName, resolveExistingNodes } from './nodeUtils' + +const VIEW_NODE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.Roles, +] + +export type NodeView = { + node_id: number + name: string + parent_id: number | null + parent_name: string + path: string + isolated: boolean + child_count: number + user_count: number + team_count: number + role_count: number +} + +export type FormatNodeViewOptions = { + verbose?: boolean +} + +export type NodeViewTableRow = { + label: string + value: string + id?: string | number +} + +export type FormattedNodeViewTable = { + rows: NodeViewTableRow[] +} + +export async function viewNode(auth: Auth, identifier: string): Promise { + const trimmed = identifier.trim() + if (!trimmed) { + throw new KeeperSdkError('Node name or ID is required.', ResultCodes.NODE_NOT_FOUND) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(VIEW_NODE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + + const node = resolveExistingNodes(nodes, [trimmed])[0]! + const byId = new Map(nodes.map((n) => [n.node_id, n] as const)) + const parent = node.parent_id ? byId.get(node.parent_id) : undefined + const path = + EnterpriseDataManager.getNodePath(nodes, node.node_id, { + omitRoot: false, + separator: NODE_PATH_SEPARATOR, + }) || nodeDisplayName(node) + + const child_count = nodes.filter((n) => n.parent_id === node.node_id).length + const user_count = (response.users || []).filter((u) => u.node_id === node.node_id).length + const team_count = (response.teams || []).filter((t) => t.node_id === node.node_id).length + const role_count = (response.roles || []).filter((r) => r.node_id === node.node_id).length + + return { + node_id: node.node_id, + name: nodeDisplayName(node), + parent_id: node.parent_id ?? null, + parent_name: parent ? nodeDisplayName(parent) : '', + path, + isolated: !!node.restrict_visibility, + child_count, + user_count, + team_count, + role_count, + } +} + +export function formatNodeView(view: NodeView, options: FormatNodeViewOptions = {}): FormattedNodeViewTable { + const rows: NodeViewTableRow[] = [ + { label: 'Node ID', value: String(view.node_id), id: view.node_id }, + { label: 'Name', value: view.name }, + { + label: 'Parent', + value: view.parent_name || (view.parent_id != null ? String(view.parent_id) : '(root)'), + id: options.verbose && view.parent_id != null ? view.parent_id : undefined, + }, + { label: 'Path', value: view.path }, + { label: 'Isolated', value: view.isolated ? 'yes' : 'no' }, + { label: 'Child Nodes', value: String(view.child_count) }, + { label: 'Users', value: String(view.user_count) }, + { label: 'Teams', value: String(view.team_count) }, + { label: 'Roles', value: String(view.role_count) }, + ] + return { rows } +} + +export function nodeViewTable(table: FormattedNodeViewTable): string { + const labelWidth = Math.max(...table.rows.map((r) => r.label.length), 8) + return table.rows + .map((row) => { + const idPart = row.id != null ? ` [${row.id}]` : '' + return `${row.label.padEnd(labelWidth)} ${row.value}${idPart}` + }) + .join('\n') +} + +export type { EnterpriseNode } diff --git a/KeeperSdk/src/platform/browser/platform.ts b/KeeperSdk/src/platform/browser/platform.ts new file mode 100644 index 00000000..95938970 --- /dev/null +++ b/KeeperSdk/src/platform/browser/platform.ts @@ -0,0 +1,76 @@ +import type { AuthUI3 } from '@keeper-security/keeperapi' +import { UnavailableAuthUI } from '../../auth/UnavailableAuthUI' +import { KeeperSdkError, ResultCodes } from '../../utils' +import type { ConfigLoader } from '../../auth/config' +import * as asmCrypto from 'asmcrypto.js' +import type { SdkPlatform, SdkReadline } from '../types' + +type HmacCtor = new (key: Uint8Array) => { + process(data: Uint8Array): void + finish(): void + result: Uint8Array +} + +const asm = asmCrypto as typeof asmCrypto & { + HmacSha1: HmacCtor + HmacSha256: HmacCtor + HmacSha512: HmacCtor +} + +const HMAC_IMPL: Record<'sha1' | 'sha256' | 'sha512', HmacCtor> = { + sha1: asm.HmacSha1, + sha256: asm.HmacSha256, + sha512: asm.HmacSha512, +} + +const BROWSER_READLINE_MSG = + 'Interactive readline is not available in the browser. Use keeper-shell password transport or a custom authUI.' + +const BROWSER_FILE_CONFIG_MSG = + 'File-based Keeper config (~/.keeper) is not available in the browser. Pass an in-memory ConfigLoader to SessionManager or KeeperVault.sessionStorage.' + +class BrowserReadline implements SdkReadline { + question(_prompt: string): Promise { + return Promise.reject(new KeeperSdkError(BROWSER_READLINE_MSG, ResultCodes.USER_CANCELLED)) + } + close(): void { + /* noop */ + } +} + +export const browserSdkPlatform: SdkPlatform = { + runtime: 'browser', + + delay(ms: number): Promise { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)) + }, + + createReadline(): SdkReadline { + return new BrowserReadline() + }, + + hmac(algorithm, key, data) { + const Ctor = HMAC_IMPL[algorithm] + if (!Ctor) { + throw new KeeperSdkError(`Unsupported HMAC algorithm: ${algorithm}`, ResultCodes.UNSUPPORTED_2FA_CHANNEL) + } + const h = new Ctor(key) + h.process(data) + h.finish() + return h.result + }, + + createFileConfigLoader(): ConfigLoader { + throw new KeeperSdkError(BROWSER_FILE_CONFIG_MSG, ResultCodes.NOT_LOGGED_IN) + }, + + createAuthUI(useConsoleAuth: boolean): AuthUI3 { + if (useConsoleAuth) { + throw new KeeperSdkError( + 'ConsoleAuthUI (readline) is not available in the browser. Set useConsoleAuth: false and provide authUI, or use keeper-shell.', + ResultCodes.USER_CANCELLED + ) + } + return new UnavailableAuthUI() + }, +} diff --git a/KeeperSdk/src/platform/index.ts b/KeeperSdk/src/platform/index.ts new file mode 100644 index 00000000..45f1e6d3 --- /dev/null +++ b/KeeperSdk/src/platform/index.ts @@ -0,0 +1,22 @@ +import type { SdkPlatform } from './types' + +let active: SdkPlatform | undefined + +export type { SdkPlatform, SdkReadline, SdkRuntime } from './types' + +export function connectSdkPlatform(platform: SdkPlatform): void { + active = platform +} + +export function getSdkPlatform(): SdkPlatform { + if (!active) { + throw new Error( + 'Keeper SDK platform is not initialized. Import @keeper-security/keeper-sdk-javascript or /browser entry first.' + ) + } + return active +} + +export function isSdkPlatformConnected(): boolean { + return active !== undefined +} diff --git a/KeeperSdk/src/platform/node/platform.ts b/KeeperSdk/src/platform/node/platform.ts new file mode 100644 index 00000000..9a3a9b60 --- /dev/null +++ b/KeeperSdk/src/platform/node/platform.ts @@ -0,0 +1,42 @@ +import { createHmac } from 'crypto' +import readline from 'readline/promises' +import type { AuthUI3 } from '@keeper-security/keeperapi' +import { ConsoleAuthUI } from '../../auth/ConsoleAuthUI' +import { UnavailableAuthUI } from '../../auth/UnavailableAuthUI' +import { FileConfigLoader } from '../../auth/node/FileConfigLoader' +import type { ConfigLoader } from '../../auth/config' +import type { SdkPlatform, SdkReadline } from '../types' + +function nodeReadline(input?: unknown, output?: unknown): SdkReadline { + const rl = readline.createInterface({ + input: (input ?? process.stdin) as NodeJS.ReadableStream, + output: (output ?? process.stdout) as NodeJS.WritableStream, + }) + return { + question: (prompt) => rl.question(prompt), + close: () => rl.close(), + } +} + +export const nodeSdkPlatform: SdkPlatform = { + runtime: 'node', + + delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + }, + + createReadline: nodeReadline, + + hmac(algorithm, key, data) { + const algo = algorithm.toLowerCase() + return new Uint8Array(createHmac(algo, Buffer.from(key)).update(data).digest()) + }, + + createFileConfigLoader(configDir?: string): ConfigLoader { + return new FileConfigLoader(configDir) + }, + + createAuthUI(useConsoleAuth: boolean): AuthUI3 { + return useConsoleAuth ? new ConsoleAuthUI() : new UnavailableAuthUI() + }, +} diff --git a/KeeperSdk/src/platform/types.ts b/KeeperSdk/src/platform/types.ts new file mode 100644 index 00000000..7816e006 --- /dev/null +++ b/KeeperSdk/src/platform/types.ts @@ -0,0 +1,25 @@ +import type { AuthUI3 } from '@keeper-security/keeperapi' +import type { ConfigLoader } from '../auth/config' + +export type SdkRuntime = 'node' | 'browser' + +export interface SdkReadline { + question(prompt: string): Promise + close(): void +} + +/** Small platform surface for KeeperSdk (auth UI, config, TOTP, timers). */ +export interface SdkPlatform { + readonly runtime: SdkRuntime + + delay(ms: number): Promise + + createReadline(input?: unknown, output?: unknown): SdkReadline + + /** HMAC digest (e.g. SHA-1 for TOTP). */ + hmac(algorithm: 'sha1' | 'sha256' | 'sha512', key: Uint8Array, data: Uint8Array): Uint8Array + + createFileConfigLoader(configDir?: string): ConfigLoader + + createAuthUI(useConsoleAuth: boolean): AuthUI3 +} diff --git a/KeeperSdk/src/records/RecordOperations.ts b/KeeperSdk/src/records/RecordOperations.ts index 9d744712..5b747fbd 100644 --- a/KeeperSdk/src/records/RecordOperations.ts +++ b/KeeperSdk/src/records/RecordOperations.ts @@ -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..345d3010 100644 --- a/KeeperSdk/src/records/RecordUtils.ts +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -44,10 +44,55 @@ 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) +} + +/** Extract otpauth URL or raw TOTP secret from a field value. */ +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 +171,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 +184,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 +236,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 +255,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 +315,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, unmask } = resolveFormatRecordOptions(showDetailsOrOptions) const summary = getRecordSummary(record) const lines: string[] = [ RECORD_SEPARATOR, @@ -261,20 +405,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: ${unmask ? summary.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, unmask) + 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: ${unmask ? totpUrl : 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..92980757 100644 --- a/KeeperSdk/src/records/Totp.ts +++ b/KeeperSdk/src/records/Totp.ts @@ -1,4 +1,4 @@ -import { createHmac } from 'crypto' +import { getSdkPlatform } from '../platform' export type TotpAlgorithm = 'SHA1' | 'SHA256' | 'SHA512' @@ -22,10 +22,7 @@ const DEFAULT_ALGORITHM: TotpAlgorithm = 'SHA1' const UINT32_MAX = 0x100000000 function decodeBase32(input: string): Uint8Array { - const noWhitespace = input.replace(/\s+/g, '') - let endIndex = noWhitespace.length - while (endIndex > 0 && noWhitespace.charCodeAt(endIndex - 1) === 0x3d) endIndex-- - const cleaned = noWhitespace.slice(0, endIndex).toUpperCase() + const cleaned = input.replace(/=+$/g, '').replace(/\s+/g, '').toUpperCase() const out: number[] = [] let buffer = 0 let bits = 0 @@ -76,10 +73,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 +99,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/roles/RoleManager.ts b/KeeperSdk/src/roles/RoleManager.ts index c398af41..6afc3f7d 100644 --- a/KeeperSdk/src/roles/RoleManager.ts +++ b/KeeperSdk/src/roles/RoleManager.ts @@ -35,6 +35,48 @@ import { type DeleteRoleResult, type FormattedDeleteRoleTable, } from './deleteRole' +import { + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + type SetRoleEnforcementsInput, + type SetRoleEnforcementsResult, + type FormattedRoleEnforcementTable, +} from './roleEnforcement' +import { + copyRoles, + formatCopyRoleResult, + renderCopyRoleAsciiTable, + type CopyRoleInput, + type CopyRoleResult, + type FormattedCopyRoleTable, +} from './copyRole' +import { + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + type AddUsersToRolesInput, + type RemoveUsersFromRolesInput, + type RoleUserResult, + type FormattedRoleUserTable, +} from './roleUser' +import { + manageRoleNodes, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + type ManageRoleNodesInput, + type ManageRoleNodesResult, + type FormattedManageRoleNodesTable, +} from './manageNode' +import { + changeRolePrivileges, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, + type ChangeRolePrivilegesInput, + type ChangeRolePrivilegesResult, + type FormattedRolePrivilegeTable, +} from './rolePrivilege' export type AuthProvider = () => Auth @@ -115,6 +157,82 @@ export class RoleManager { return renderDeleteRoleAsciiTable(table) } + public async setRoleEnforcements(input: SetRoleEnforcementsInput): Promise { + const result = await setRoleEnforcements(this.requireAuth(), input) + if (result.applied > 0) this.invalidateEnterpriseData() + return result + } + + public formatSetRoleEnforcementsResult(result: SetRoleEnforcementsResult): FormattedRoleEnforcementTable { + return formatSetRoleEnforcementsResult(result) + } + + public renderRoleEnforcementAsciiTable(table: FormattedRoleEnforcementTable): string { + return renderRoleEnforcementAsciiTable(table) + } + + public async copyRoles(input: CopyRoleInput): Promise { + const result = await copyRoles(this.requireAuth(), input) + if (result.success) this.invalidateEnterpriseData() + return result + } + + public formatCopyRoleResult(result: CopyRoleResult): FormattedCopyRoleTable { + return formatCopyRoleResult(result) + } + + public renderCopyRoleAsciiTable(table: FormattedCopyRoleTable): string { + return renderCopyRoleAsciiTable(table) + } + + public async addUsersToRoles(input: AddUsersToRolesInput): Promise { + const result = await addUsersToRoles(this.requireAuth(), input) + if (result.succeeded > 0) this.invalidateEnterpriseData() + return result + } + + public async removeUsersFromRoles(input: RemoveUsersFromRolesInput): Promise { + const result = await removeUsersFromRoles(this.requireAuth(), input) + if (result.succeeded > 0) this.invalidateEnterpriseData() + return result + } + + public formatRoleUserResult(result: RoleUserResult): FormattedRoleUserTable { + return formatRoleUserResult(result) + } + + public renderRoleUserAsciiTable(table: FormattedRoleUserTable): string { + return renderRoleUserAsciiTable(table) + } + + public async manageRoleNodes(input: ManageRoleNodesInput): Promise { + const result = await manageRoleNodes(this.requireAuth(), input) + if (result.succeeded > 0) this.invalidateEnterpriseData() + return result + } + + public formatManageRoleNodesResult(result: ManageRoleNodesResult): FormattedManageRoleNodesTable { + return formatManageRoleNodesResult(result) + } + + public renderManageRoleNodesAsciiTable(table: FormattedManageRoleNodesTable): string { + return renderManageRoleNodesAsciiTable(table) + } + + public async changeRolePrivileges(input: ChangeRolePrivilegesInput): Promise { + const result = await changeRolePrivileges(this.requireAuth(), input) + if (result.succeeded > 0) this.invalidateEnterpriseData() + return result + } + + public formatChangeRolePrivilegesResult(result: ChangeRolePrivilegesResult): FormattedRolePrivilegeTable { + return formatChangeRolePrivilegesResult(result) + } + + public renderChangeRolePrivilegesAsciiTable(table: FormattedRolePrivilegeTable): string { + return renderChangeRolePrivilegesAsciiTable(table) + } + private getEnterpriseData(): EnterpriseDataManager { if (!this.enterpriseData) { this.enterpriseData = new EnterpriseDataManager(this.requireAuth()) diff --git a/KeeperSdk/src/roles/copyRole.ts b/KeeperSdk/src/roles/copyRole.ts new file mode 100644 index 00000000..523c3153 --- /dev/null +++ b/KeeperSdk/src/roles/copyRole.ts @@ -0,0 +1,309 @@ +import { + createInMessage, + encryptObjectForStorage, + Enterprise, + enterpriseAllocateIdsCommand, + normal64Bytes, + roleAddCommand, + roleEnforcementAddCommand, + roleUserAddCommand, + type Auth, + type RoleEditRequest, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { + applyDecryptedNodeNames, + applyEnterpriseNameToRoot, + parentNeedsNameLookup, + resolveParentNode, +} from '../teams/teamUtils' +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + nodePathOrFallback, + resolveExistingRoles, + validateRoleName, +} from './roleUtils' + +const COPY_ROLE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleEnforcements, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.RoleTeams, +] + +export type CopyRoleInput = { + source: string + name?: string + parent?: string | number | null + clone?: boolean +} + +export type CopyRoleResult = { + success: boolean + sourceRoleId: number + sourceRoleName: string + newRoleId: number + newRoleName: string + nodeId: number + nodeName: string + enforcementsCopied: number + enforcementsFailed: number + usersCopied: number + usersFailed: number + teamsCopied: number + teamsFailed: number + message?: string +} + +export type FormattedCopyRoleTable = { + rows: Array<{ label: string; value: string }> +} + +export async function copyRoles(auth: Auth, input: CopyRoleInput): Promise { + const sourceIdentifier = (input.source ?? '').trim() + if (!sourceIdentifier) { + throw new KeeperSdkError('Source role is required.', ResultCodes.ROLE_SOURCE_REQUIRED) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(COPY_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + if (parentNeedsNameLookup(input.parent ?? null)) await enterpriseData.decryptNodeNames(nodes) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const [sourceRole] = resolveExistingRoles(roles, [sourceIdentifier]) + const sourceRoleName = (sourceRole.displayName || '').trim() || String(sourceRole.role_id) + + const targetNode = + input.parent !== undefined && input.parent !== null && input.parent !== '' + ? resolveParentNode(nodes, input.parent) + : resolveParentNode(nodes, sourceRole.node_id ?? null) + const nodeName = nodePathOrFallback(nodes, targetNode) + + const newName = (input.name || `${sourceRoleName} Copy`).trim() + validateRoleName(newName) + + const treeKey = await enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer roles.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + + const newRoleId = await allocateRoleId(auth) + const encryptedData = await encryptObjectForStorage({ displayname: newName }, treeKey) + + try { + await sendRoleAdd(auth, { + role_id: newRoleId, + node_id: targetNode.node_id, + encrypted_data: encryptedData, + visible_below: sourceRole.visible_below ?? false, + new_user_inherit: sourceRole.new_user_inherit ?? false, + }) + } catch (err) { + return { + success: false, + sourceRoleId: sourceRole.role_id, + sourceRoleName, + newRoleId, + newRoleName: newName, + nodeId: targetNode.node_id, + nodeName, + enforcementsCopied: 0, + enforcementsFailed: 0, + usersCopied: 0, + usersFailed: 0, + teamsCopied: 0, + teamsFailed: 0, + message: extractErrorMessage(err), + } + } + + const { copied: enforcementsCopied, failed: enforcementsFailed } = await copyEnforcements( + auth, + sourceRole.role_id, + newRoleId, + response.role_enforcements || [] + ) + + let usersCopied = 0 + let usersFailed = 0 + let teamsCopied = 0 + let teamsFailed = 0 + + if (input.clone === true) { + const userResult = await copyRoleUsers(auth, sourceRole.role_id, newRoleId, response.role_users || []) + usersCopied = userResult.copied + usersFailed = userResult.failed + + const teamResult = await copyRoleTeams( + auth, + newRoleId, + (response.role_teams || []) + .filter((link) => link.role_id === sourceRole.role_id) + .map((link) => link.team_uid) + ) + teamsCopied = teamResult.copied + teamsFailed = teamResult.failed + } + + return { + success: true, + sourceRoleId: sourceRole.role_id, + sourceRoleName, + newRoleId, + newRoleName: newName, + nodeId: targetNode.node_id, + nodeName, + enforcementsCopied, + enforcementsFailed, + usersCopied, + usersFailed, + teamsCopied, + teamsFailed, + } +} + +export function formatCopyRoleResult(result: CopyRoleResult): FormattedCopyRoleTable { + const rows: Array<{ label: string; value: string }> = [ + { label: 'Source Role', value: `${result.sourceRoleName} (${result.sourceRoleId})` }, + { label: 'New Role', value: `${result.newRoleName} (${result.newRoleId})` }, + { label: 'Node', value: `${result.nodeName} (${result.nodeId})` }, + { label: 'Status', value: result.success ? 'success' : 'failed' }, + { + label: 'Enforcements Copied', + value: `${result.enforcementsCopied} (failed: ${result.enforcementsFailed})`, + }, + ] + if (result.usersCopied > 0 || result.usersFailed > 0) { + rows.push({ + label: 'Users Copied', + value: `${result.usersCopied} (failed: ${result.usersFailed})`, + }) + } + if (result.teamsCopied > 0 || result.teamsFailed > 0) { + rows.push({ + label: 'Teams Copied', + value: `${result.teamsCopied} (failed: ${result.teamsFailed})`, + }) + } + if (result.message) rows.push({ label: 'Detail', value: result.message }) + return { rows } +} + +export function renderCopyRoleAsciiTable(table: FormattedCopyRoleTable): string { + const labelWidth = Math.max(...table.rows.map((r) => r.label.length)) + return table.rows.map((row) => `${row.label.padEnd(labelWidth)} ${row.value}`).join('\n') +} + +async function allocateRoleId(auth: Auth): Promise { + const response = await auth.executeRestCommand(enterpriseAllocateIdsCommand({ number_requested: 1 })) + if (!response.base_id) { + throw new KeeperSdkError( + 'Failed to allocate enterprise ID for new role.', + ResultCodes.ROLE_ID_ALLOCATION_FAILED + ) + } + return response.base_id +} + +async function sendRoleAdd(auth: Auth, payload: RoleEditRequest): Promise { + const response = await auth.executeRestCommand(roleAddCommand(payload)) + assertCommandSucceeded(response, `role_add failed for role_id=${payload.role_id}`, ResultCodes.ROLE_ADD_FAILED) +} + +async function copyEnforcements( + auth: Auth, + sourceRoleId: number, + newRoleId: number, + links: ReadonlyArray<{ role_id: number; enforcement_type: string; value?: string }> +): Promise<{ copied: number; failed: number }> { + let copied = 0 + let failed = 0 + for (const link of links) { + if (link.role_id !== sourceRoleId) continue + try { + const response = await auth.executeRestCommand( + roleEnforcementAddCommand({ + role_id: newRoleId, + enforcement: link.enforcement_type, + value: link.value, + }) + ) + assertCommandSucceeded( + response, + `role_enforcement_add failed for ${link.enforcement_type}`, + ResultCodes.ROLE_ENFORCEMENT_FAILED + ) + copied++ + } catch { + failed++ + } + } + return { copied, failed } +} + +async function copyRoleUsers( + auth: Auth, + sourceRoleId: number, + newRoleId: number, + links: ReadonlyArray<{ role_id: number; enterprise_user_id: number }> +): Promise<{ copied: number; failed: number }> { + let copied = 0 + let failed = 0 + for (const link of links) { + if (link.role_id !== sourceRoleId) continue + try { + const response = await auth.executeRestCommand( + roleUserAddCommand({ + role_id: newRoleId, + enterprise_user_id: link.enterprise_user_id, + }) + ) + assertCommandSucceeded( + response, + `role_user_add failed for enterprise_user_id=${link.enterprise_user_id}`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + copied++ + } catch { + failed++ + } + } + return { copied, failed } +} + +async function copyRoleTeams( + auth: Auth, + newRoleId: number, + teamUids: string[] +): Promise<{ copied: number; failed: number }> { + if (teamUids.length === 0) return { copied: 0, failed: 0 } + try { + const payload = Enterprise.RoleTeams.create({ + roleTeam: teamUids.map((teamUid) => + Enterprise.RoleTeam.create({ + roleId: newRoleId, + teamUid: normal64Bytes(teamUid), + }) + ), + }) + const message = createInMessage(payload, 'enterprise/role_team_add', Enterprise.RoleTeams) + await auth.executeRestAction(message) + return { copied: teamUids.length, failed: 0 } + } catch { + return { copied: 0, failed: teamUids.length } + } +} diff --git a/KeeperSdk/src/roles/index.ts b/KeeperSdk/src/roles/index.ts index 65d11f01..1d7f35e3 100644 --- a/KeeperSdk/src/roles/index.ts +++ b/KeeperSdk/src/roles/index.ts @@ -40,4 +40,63 @@ export type { UpdateRoleInput, UpdateRoleResult, UpdateRoleItemResult, Formatted export { deleteRoles, formatDeleteRoleResult, renderDeleteRoleAsciiTable, DeleteRoleStatus } from './deleteRole' export type { DeleteRoleInput, DeleteRoleResult, DeleteRoleItemResult, FormattedDeleteRoleTable } from './deleteRole' +export { + setRoleEnforcements, + formatSetRoleEnforcementsResult, + renderRoleEnforcementAsciiTable, + RoleEnforcementStatus, +} from './roleEnforcement' +export type { + SetRoleEnforcementsInput, + RoleEnforcementItemResult, + SetRoleEnforcementsResult, + FormattedRoleEnforcementTable, +} from './roleEnforcement' + +export { copyRoles, formatCopyRoleResult, renderCopyRoleAsciiTable } from './copyRole' +export type { CopyRoleInput, CopyRoleResult, FormattedCopyRoleTable } from './copyRole' + +export { + addUsersToRoles, + removeUsersFromRoles, + formatRoleUserResult, + renderRoleUserAsciiTable, + RoleUserStatus, + RoleUserSkipReason, +} from './roleUser' +export type { + AddUsersToRolesInput, + RemoveUsersFromRolesInput, + RoleUserItemResult, + RoleUserResult, + FormattedRoleUserTable, +} from './roleUser' + +export { + manageRoleNodes, + formatManageRoleNodesResult, + renderManageRoleNodesAsciiTable, + RoleManagedNodeStatus, +} from './manageNode' +export type { + ManageRoleNodesAction, + ManageRoleNodesInput, + RoleManagedNodeItemResult, + ManageRoleNodesResult, + FormattedManageRoleNodesTable, +} from './manageNode' + +export { + changeRolePrivileges, + formatChangeRolePrivilegesResult, + renderChangeRolePrivilegesAsciiTable, + RolePrivilegeStatus, +} from './rolePrivilege' +export type { + ChangeRolePrivilegesInput, + RolePrivilegeItemResult, + ChangeRolePrivilegesResult, + FormattedRolePrivilegeTable, +} from './rolePrivilege' + export type { RoleToggleInput, RoleToggle, EnforcementPair } from './roleUtils' diff --git a/KeeperSdk/src/roles/manageNode.ts b/KeeperSdk/src/roles/manageNode.ts new file mode 100644 index 00000000..11508974 --- /dev/null +++ b/KeeperSdk/src/roles/manageNode.ts @@ -0,0 +1,379 @@ +import { + roleManagedNodeAddCommand, + roleManagedNodeRemoveCommand, + roleManagedNodeUpdateCommand, + type Auth, + type RoleManagedNodeTreeKey, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, logger, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseNode, + type EnterpriseRole, + type EnterpriseRoleManagedNodeLink, + type EnterpriseRoleUserLink, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, resolveParentNode } from '../teams/teamUtils' +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + nodePathOrFallback, + normalizeIdentifiers, + resolveExistingRoles, + resolveToggle, + type RoleToggleInput, +} from './roleUtils' +import { + encryptForUserPublicKey, + fetchUserPublicKeys, + getMissingTreeKeyMaps, + buildTreeKeysFromProvidedPublicKeys, +} from './roleCrypto' + +const MANAGE_NODE_BASE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.ManagedNodes, +] + +const MANAGE_NODE_TABLE_HEADERS = ['#', 'Status', 'Role Name', 'Role ID', 'Node Name', 'Node ID', 'Detail'] + +export type ManageRoleNodesAction = 'add' | 'update' | 'remove' + +export enum RoleManagedNodeStatus { + Added = 'added', + Updated = 'updated', + Removed = 'removed', + Skipped = 'skipped', + Failed = 'failed', +} + +export type ManageRoleNodesInput = { + roles: string[] + nodes: string[] + action: ManageRoleNodesAction + cascade?: RoleToggleInput +} + +export type RoleManagedNodeItemResult = { + roleId: number + roleName: string + nodeId: number + nodeName: string + status: RoleManagedNodeStatus + message?: string +} + +export type ManageRoleNodesResult = { + success: boolean + items: RoleManagedNodeItemResult[] + succeeded: number + skipped: number + failed: number +} + +export type FormattedManageRoleNodesTable = { + headers: string[] + rows: string[][] + summary: string +} + +function findLink( + links: EnterpriseRoleManagedNodeLink[], + roleId: number, + nodeId: number +): EnterpriseRoleManagedNodeLink | undefined { + return links.find((link) => link.role_id === roleId && link.managed_node_id === nodeId) +} + +function roleDisplayName(role: EnterpriseRole): string { + return (role.displayName || '').trim() || String(role.role_id) +} + +async function buildTreeKeysForRole( + auth: Auth, + roleId: number, + roleUserLinks: EnterpriseRoleUserLink[], + usersById: Map, + treeKey: Uint8Array +): Promise { + const memberIds = roleUserLinks.filter((link) => link.role_id === roleId).map((link) => link.enterprise_user_id) + if (memberIds.length === 0) return [] + + const emails = memberIds.map((id) => usersById.get(id)?.username).filter((email): email is string => !!email) + const publicKeyMap = await fetchUserPublicKeys(auth, emails) + + const treeKeys: RoleManagedNodeTreeKey[] = [] + for (const userId of memberIds) { + const user = usersById.get(userId) + if (!user?.username) continue + const publicKeys = publicKeyMap.get(user.username.toLowerCase()) + if (!publicKeys) { + logger.warn( + `No public key available for "${user.username}"; role admin will not be able to decrypt the enterprise tree key for this node until they re-key.` + ) + continue + } + const encrypted = await encryptForUserPublicKey(treeKey, publicKeys) + if (!encrypted) continue + treeKeys.push({ + enterprise_user_id: userId, + tree_key: encrypted.ciphertext, + tree_key_type: encrypted.keyType, + }) + } + return treeKeys +} + +export async function manageRoleNodes(auth: Auth, input: ManageRoleNodesInput): Promise { + const roleIdentifiers = normalizeIdentifiers(input.roles) + if (roleIdentifiers.length === 0) { + throw new KeeperSdkError('No roles specified.', ResultCodes.ROLE_REQUIRED) + } + const nodeIdentifiers = normalizeIdentifiers(input.nodes) + if (nodeIdentifiers.length === 0) { + throw new KeeperSdkError('No nodes specified.', ResultCodes.NO_NODES_FOR_ROLE_OP) + } + + const action = input.action + const cascade = resolveToggle(input.cascade) + + const includes = [...MANAGE_NODE_BASE_INCLUDES] + if (action === 'add') { + includes.push(EnterpriseDataInclude.Users, EnterpriseDataInclude.RoleUsers) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(includes), + enterpriseData.getDisplayNames(), + ]) + + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + await enterpriseData.decryptNodeNames(nodes) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const resolvedRoles = resolveExistingRoles(roles, roleIdentifiers) + const resolvedNodes: EnterpriseNode[] = nodeIdentifiers.map((identifier) => resolveParentNode(nodes, identifier)) + + const managedNodeLinks = response.managed_nodes || [] + const usersById = new Map() + for (const user of response.users || []) usersById.set(user.enterprise_user_id, user) + + let treeKey: Uint8Array | null = null + if (action === 'add') { + treeKey = await enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer roles.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + } + + const items: RoleManagedNodeItemResult[] = [] + for (const role of resolvedRoles) { + const roleName = roleDisplayName(role) + + for (const node of resolvedNodes) { + const nodeName = nodePathOrFallback(nodes, node) + const existingLink = findLink(managedNodeLinks, role.role_id, node.node_id) + + try { + if (action === 'add') { + if (existingLink) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Skipped, + message: `Role "${roleName}" already manages node "${nodeName}".`, + }) + continue + } + const treeKeys = await buildTreeKeysForRole( + auth, + role.role_id, + response.role_users || [], + usersById, + treeKey as Uint8Array + ) + const addPayload = { + role_id: role.role_id, + managed_node_id: node.node_id, + cascade_node_management: cascade ?? false, + tree_keys: treeKeys.length > 0 ? treeKeys : undefined, + } + try { + const addResponse = await auth.executeRestCommand(roleManagedNodeAddCommand(addPayload)) + assertCommandSucceeded( + addResponse, + `role_managed_node_add failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_ADD_FAILED + ) + } catch (err) { + const missing = getMissingTreeKeyMaps(err) + if (!missing || !treeKey) throw err + // API: encrypt tree key with returned public keys and resend. + const retryKeys = await buildTreeKeysFromProvidedPublicKeys(treeKey, missing.rsa, missing.ecc) + const merged = [...(addPayload.tree_keys || []), ...retryKeys] + const retryResponse = await auth.executeRestCommand( + roleManagedNodeAddCommand({ + ...addPayload, + tree_keys: merged.length > 0 ? merged : undefined, + }) + ) + assertCommandSucceeded( + retryResponse, + `role_managed_node_add retry failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_ADD_FAILED + ) + } + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Added, + }) + } else if (action === 'update') { + if (!existingLink) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Skipped, + message: `Role "${roleName}" does not manage node "${nodeName}".`, + }) + continue + } + const updateResponse = await auth.executeRestCommand( + roleManagedNodeUpdateCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + cascade_node_management: cascade ?? existingLink.cascade_node_management, + }) + ) + assertCommandSucceeded( + updateResponse, + `role_managed_node_update failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_UPDATE_FAILED + ) + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Updated, + }) + } else { + if (!existingLink) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Skipped, + message: `Role "${roleName}" does not manage node "${nodeName}".`, + }) + continue + } + const removeResponse = await auth.executeRestCommand( + roleManagedNodeRemoveCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + }) + ) + assertCommandSucceeded( + removeResponse, + `role_managed_node_remove failed for role_id=${role.role_id}, node_id=${node.node_id}`, + ResultCodes.ROLE_MANAGED_NODE_REMOVE_FAILED + ) + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Removed, + }) + } + } catch (err) { + items.push({ + roleId: role.role_id, + roleName, + nodeId: node.node_id, + nodeName, + status: RoleManagedNodeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + } + + return finalizeResult(items) +} + +function finalizeResult(items: RoleManagedNodeItemResult[]): ManageRoleNodesResult { + let succeeded = 0 + let skipped = 0 + let failed = 0 + for (const item of items) { + if ( + item.status === RoleManagedNodeStatus.Added || + item.status === RoleManagedNodeStatus.Updated || + item.status === RoleManagedNodeStatus.Removed + ) + succeeded++ + else if (item.status === RoleManagedNodeStatus.Skipped) skipped++ + else failed++ + } + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + } +} + +export function formatManageRoleNodesResult(result: ManageRoleNodesResult): FormattedManageRoleNodesTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.roleName, + String(item.roleId), + item.nodeName, + String(item.nodeId), + item.message || '', + ]) + return { + headers: [...MANAGE_NODE_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderManageRoleNodesAsciiTable(table: FormattedManageRoleNodesTable): string { + const { headers, rows } = table + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + return [ + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join('\n') +} diff --git a/KeeperSdk/src/roles/roleCrypto.ts b/KeeperSdk/src/roles/roleCrypto.ts new file mode 100644 index 00000000..34476778 --- /dev/null +++ b/KeeperSdk/src/roles/roleCrypto.ts @@ -0,0 +1,187 @@ +import { + getPublicKeysMessage, + shareKey, + shareKeyEC, + normal64Bytes, + platform, + type Auth, + type Authentication, + type RoleManagedNodeTreeKey, + type ManagedNodeRoleKey, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, logger } from '../utils' + +/** Wire strings for JSON enterprise commands (.NET DataMember parity). */ +export const ENCRYPTED_BY_PUBLIC_KEY = 'encrypted_by_public_key' +export const ENCRYPTED_BY_PUBLIC_KEY_ECC = 'encrypted_by_public_key_ecc' + +export type UserPublicKeys = { + rsaPublicKey: Uint8Array | null + eccPublicKey: Uint8Array | null +} + +export type EncryptedForUser = { + ciphertext: string + keyType: typeof ENCRYPTED_BY_PUBLIC_KEY | typeof ENCRYPTED_BY_PUBLIC_KEY_ECC +} + +/** Raw Auth session keys for unwrapping enterprise payloads (Node + browser via platform). */ +export type SessionUnwrapKeys = { + dataKey?: Uint8Array | null + privateKey?: Uint8Array | null + eccPrivateKey?: Uint8Array | null +} + +/** Server-returned map: enterprise_user_id string → public key (base64/url-safe). */ +export type MissingPublicKeysMap = Record + +/** + * Decrypt by Keeper key-type enum (EncryptedKeyType / BackupKeyType / Folder.EncryptedKeyType): + * 1=data CBC, 2=RSA, 3=data GCM, 4=ECC. Uses connected keeperapi {@link platform}. + */ +export async function decryptByKeyType( + encrypted: Uint8Array | string, + keyType: number | null | undefined, + keys: SessionUnwrapKeys +): Promise { + const data = typeof encrypted === 'string' ? normal64Bytes(encrypted) : encrypted + switch (keyType) { + case 1: // ENCRYPTED_BY_DATA_KEY + if (!keys.dataKey) return null + return platform.aesCbcDecrypt(data, keys.dataKey, true) + case 2: // ENCRYPTED_BY_PUBLIC_KEY + if (!keys.privateKey) return null + return platform.privateDecrypt(data, keys.privateKey) + case 3: // ENCRYPTED_BY_DATA_KEY_GCM + if (!keys.dataKey) return null + return platform.aesGcmDecrypt(data, keys.dataKey) + case 4: // ENCRYPTED_BY_PUBLIC_KEY_ECC + if (!keys.eccPrivateKey) return null + return platform.privateDecryptEC(data, keys.eccPrivateKey) + default: + return null + } +} + +export async function fetchUserPublicKeys(auth: Auth, emails: string[]): Promise> { + const result = new Map() + if (emails.length === 0) return result + + let response: Authentication.IGetPublicKeysResponse + try { + response = await auth.executeRest(getPublicKeysMessage({ usernames: emails })) + } catch (err) { + logger.warn(`Failed to fetch public keys: ${extractErrorMessage(err)}`) + return result + } + + for (const entry of response.keyResponses || []) { + const username = (entry.username || '').toLowerCase() + if (!username || entry.errorCode) continue + result.set(username, { + rsaPublicKey: entry.publicKey && entry.publicKey.length > 0 ? entry.publicKey : null, + eccPublicKey: entry.publicEccKey && entry.publicEccKey.length > 0 ? entry.publicEccKey : null, + }) + } + return result +} + +/** Prefer RSA (parity with .NET), fall back to ECC. */ +export async function encryptForUserPublicKey( + plaintext: Uint8Array, + publicKeys: UserPublicKeys +): Promise { + if (publicKeys.rsaPublicKey && publicKeys.rsaPublicKey.length > 0) { + return { + ciphertext: shareKey(plaintext, platform.bytesToBase64(publicKeys.rsaPublicKey)), + keyType: ENCRYPTED_BY_PUBLIC_KEY, + } + } + if (publicKeys.eccPublicKey && publicKeys.eccPublicKey.length > 0) { + return { + ciphertext: await shareKeyEC(plaintext, publicKeys.eccPublicKey), + keyType: ENCRYPTED_BY_PUBLIC_KEY_ECC, + } + } + return null +} + +export function isCommandFailure(err: unknown): err is Record { + return typeof err === 'object' && err !== null && 'result_code' in err +} + +export function getResultCode(err: unknown): string { + if (!isCommandFailure(err)) return '' + return String(err.result_code || '').toLowerCase() +} + +/** Parse missing_tree_keys / missing_ecc_tree_keys from role_managed_node_add failures. */ +export function getMissingTreeKeyMaps(err: unknown): { + rsa?: MissingPublicKeysMap + ecc?: MissingPublicKeysMap +} | null { + if (!isCommandFailure(err)) return null + const code = getResultCode(err) + if (code !== 'missing_keys' && code !== 'missing_ecc_keys') return null + const rsa = err.missing_tree_keys as MissingPublicKeysMap | undefined + const ecc = err.missing_ecc_tree_keys as MissingPublicKeysMap | undefined + if (!rsa && !ecc) return null + return { rsa, ecc } +} + +/** Parse status.missing_keys from managed_node_privilege_add failures. */ +export function getMissingRoleKeyMap(err: unknown): MissingPublicKeysMap | null { + if (!isCommandFailure(err)) return null + if (getResultCode(err) !== 'missing_keys') return null + const status = err.status as { missing_keys?: MissingPublicKeysMap } | undefined + const map = status?.missing_keys + if (!map || typeof map !== 'object') return null + return map +} + +/** Encrypt tree key for users using public keys returned by the server (missing_keys retry). */ +export async function buildTreeKeysFromProvidedPublicKeys( + treeKey: Uint8Array, + rsaKeys?: MissingPublicKeysMap, + eccKeys?: MissingPublicKeysMap +): Promise { + const treeKeys: RoleManagedNodeTreeKey[] = [] + for (const [userId, publicKey] of Object.entries(rsaKeys || {})) { + const id = Number(userId) + if (!Number.isFinite(id) || !publicKey) continue + treeKeys.push({ + enterprise_user_id: id, + tree_key: shareKey(treeKey, publicKey), + tree_key_type: ENCRYPTED_BY_PUBLIC_KEY, + }) + } + for (const [userId, publicKey] of Object.entries(eccKeys || {})) { + const id = Number(userId) + if (!Number.isFinite(id) || !publicKey) continue + const eccPub = normal64Bytes(publicKey) + treeKeys.push({ + enterprise_user_id: id, + tree_key: await shareKeyEC(treeKey, eccPub), + tree_key_type: ENCRYPTED_BY_PUBLIC_KEY_ECC, + }) + } + return treeKeys +} + +/** Encrypt role key for users using public keys from privilege-add missing_keys status. */ +export function buildRoleKeysFromProvidedPublicKeys( + roleKey: Uint8Array, + publicKeys: MissingPublicKeysMap +): ManagedNodeRoleKey[] { + const roleKeys: ManagedNodeRoleKey[] = [] + for (const [userId, publicKey] of Object.entries(publicKeys)) { + const id = Number(userId) + if (!Number.isFinite(id) || !publicKey) continue + roleKeys.push({ + enterprise_user_id: id, + role_key: shareKey(roleKey, publicKey), + tree_key_type: ENCRYPTED_BY_PUBLIC_KEY, + }) + } + return roleKeys +} diff --git a/KeeperSdk/src/roles/roleEnforcement.ts b/KeeperSdk/src/roles/roleEnforcement.ts new file mode 100644 index 00000000..c53024dd --- /dev/null +++ b/KeeperSdk/src/roles/roleEnforcement.ts @@ -0,0 +1,120 @@ +import type { Auth } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { + applyDecryptedRoleNames, + applyRoleEnforcements, + buildRoleResultRows, + normalizeIdentifiers, + parseEnforcements, + renderRoleResultTable, + resolveExistingRoles, + ROLE_TABLE_HEADERS, +} from './roleUtils' + +const ROLE_ENFORCEMENT_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleEnforcements, +] + +export enum RoleEnforcementStatus { + Applied = 'applied', + Failed = 'failed', +} + +export type SetRoleEnforcementsInput = { + roles: string[] + enforcements: string[] +} + +export type RoleEnforcementItemResult = { + roleId: number + roleName: string + nodeId: number + status: RoleEnforcementStatus + message?: string +} + +export type SetRoleEnforcementsResult = { + success: boolean + items: RoleEnforcementItemResult[] + applied: number + failed: number +} + +export type FormattedRoleEnforcementTable = { + headers: string[] + rows: string[][] + summary: string +} + +export async function setRoleEnforcements( + auth: Auth, + input: SetRoleEnforcementsInput +): Promise { + const identifiers = normalizeIdentifiers(input.roles) + if (identifiers.length === 0) { + throw new KeeperSdkError('No roles specified.', ResultCodes.ROLE_REQUIRED) + } + + const enforcements = parseEnforcements(input.enforcements ?? []) + if (enforcements.length === 0) { + throw new KeeperSdkError( + 'No enforcements to apply. Use KEY:VALUE pairs (VALUE=false removes an enforcement).', + ResultCodes.NO_ENFORCEMENTS_TO_APPLY + ) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ROLE_ENFORCEMENT_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const resolvedRoles = resolveExistingRoles(roles, identifiers) + + const items: RoleEnforcementItemResult[] = [] + for (const role of resolvedRoles) { + const roleName = (role.displayName || '').trim() || String(role.role_id) + const nodeId = role.node_id ?? 0 + try { + await applyRoleEnforcements(auth, role.role_id, enforcements, response.role_enforcements || []) + items.push({ + roleId: role.role_id, + roleName, + nodeId, + status: RoleEnforcementStatus.Applied, + }) + } catch (err) { + items.push({ + roleId: role.role_id, + roleName, + nodeId, + status: RoleEnforcementStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + return finalizeResult(items) +} + +export function formatSetRoleEnforcementsResult(result: SetRoleEnforcementsResult): FormattedRoleEnforcementTable { + return { + headers: [...ROLE_TABLE_HEADERS], + rows: buildRoleResultRows(result.items, (item) => item.message || ''), + summary: `Applied: ${result.applied} Failed: ${result.failed}`, + } +} + +export function renderRoleEnforcementAsciiTable(table: FormattedRoleEnforcementTable): string { + return renderRoleResultTable(table.headers, table.rows, table.summary) +} + +function finalizeResult(items: RoleEnforcementItemResult[]): SetRoleEnforcementsResult { + const applied = items.filter((item) => item.status === RoleEnforcementStatus.Applied).length + const failed = items.filter((item) => item.status === RoleEnforcementStatus.Failed).length + return { success: failed === 0 && applied > 0, items, applied, failed } +} diff --git a/KeeperSdk/src/roles/rolePrivilege.ts b/KeeperSdk/src/roles/rolePrivilege.ts new file mode 100644 index 00000000..46e47290 --- /dev/null +++ b/KeeperSdk/src/roles/rolePrivilege.ts @@ -0,0 +1,381 @@ +import { + encryptKey, + generateEncryptionKey, + managedNodePrivilegeAddCommand, + managedNodePrivilegeRemoveCommand, + platform, + type Auth, + type ManagedNodeMspKey, + type ManagedNodePrivilegeAddRequest, + type ManagedNodeRoleKey, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRoleUserLink, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { applyDecryptedNodeNames, applyEnterpriseNameToRoot, resolveParentNode } from '../teams/teamUtils' +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + nodePathOrFallback, + normalizeIdentifiers, + resolveExistingRoles, +} from './roleUtils' +import { + buildRoleKeysFromProvidedPublicKeys, + encryptForUserPublicKey, + fetchUserPublicKeys, + getMissingRoleKeyMap, +} from './roleCrypto' + +/** + * API: transfer_account requires role key material (RSA fields deprecated for EC migration but + * still required for Commander/bridge). manage_companies uses msp_keys, not role keys. + */ +const TRANSFER_ACCOUNT = 'transfer_account' +const MANAGE_COMPANIES = 'manage_companies' + +const ROLE_PRIVILEGE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Nodes, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.ManagedNodes, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.RoleUsers, +] + +const ROLE_PRIVILEGE_TABLE_HEADERS = ['#', 'Status', 'Action', 'Privilege', 'Detail'] + +export enum RolePrivilegeStatus { + Added = 'added', + Removed = 'removed', + Skipped = 'skipped', + Failed = 'failed', +} + +export type ChangeRolePrivilegesInput = { + role: string + node: string | number + add?: string[] + remove?: string[] + /** Required by API when adding manage_companies. */ + mspKeys?: ManagedNodeMspKey[] +} + +export type RolePrivilegeItemResult = { + privilege: string + action: 'add' | 'remove' + status: RolePrivilegeStatus + message?: string +} + +export type ChangeRolePrivilegesResult = { + success: boolean + roleId: number + roleName: string + nodeId: number + nodeName: string + items: RolePrivilegeItemResult[] + succeeded: number + skipped: number + failed: number +} + +export type FormattedRolePrivilegeTable = { + headers: string[] + rows: string[][] + parentLabel: string + summary: string +} + +type RoleKeyMaterial = { + roleKey: Uint8Array + role_key_enc_with_tree_key: string + role_public_key: string + role_private_key: string + role_keys?: ManagedNodeRoleKey[] +} + +async function buildRoleKeyMaterial( + auth: Auth, + treeKey: Uint8Array, + roleKey: Uint8Array, + roleUserLinks: EnterpriseRoleUserLink[], + usersById: Map, + roleId: number +): Promise { + const roleKeyEncWithTreeKey = await encryptKey(roleKey, treeKey) + const { privateKey, publicKey } = await platform.generateRSAKeyPair() + const rolePrivateKey = await encryptKey(privateKey, roleKey) + + const memberIds = roleUserLinks.filter((link) => link.role_id === roleId).map((link) => link.enterprise_user_id) + const emails = memberIds.map((id) => usersById.get(id)?.username).filter((email): email is string => !!email) + const publicKeyMap = await fetchUserPublicKeys(auth, emails) + + const roleKeys: ManagedNodeRoleKey[] = [] + for (const userId of memberIds) { + const user = usersById.get(userId) + if (!user?.username) continue + const publicKeys = publicKeyMap.get(user.username.toLowerCase()) + if (!publicKeys) continue + const encrypted = await encryptForUserPublicKey(roleKey, publicKeys) + if (!encrypted) continue + roleKeys.push({ + enterprise_user_id: userId, + role_key: encrypted.ciphertext, + tree_key_type: encrypted.keyType, + }) + } + + return { + roleKey, + role_key_enc_with_tree_key: roleKeyEncWithTreeKey, + role_public_key: platform.bytesToBase64(publicKey), + role_private_key: rolePrivateKey, + role_keys: roleKeys.length > 0 ? roleKeys : undefined, + } +} + +async function addTransferAccountPrivilege( + auth: Auth, + payload: ManagedNodePrivilegeAddRequest, + roleKeyMaterial: RoleKeyMaterial +): Promise { + Object.assign(payload, { + role_key_enc_with_tree_key: roleKeyMaterial.role_key_enc_with_tree_key, + role_public_key: roleKeyMaterial.role_public_key, + role_private_key: roleKeyMaterial.role_private_key, + role_keys: roleKeyMaterial.role_keys, + }) + + try { + const addResponse = await auth.executeRestCommand(managedNodePrivilegeAddCommand(payload)) + assertCommandSucceeded( + addResponse, + `managed_node_privilege_add failed for privilege=${TRANSFER_ACCOUNT}`, + ResultCodes.ROLE_PRIVILEGE_ADD_FAILED + ) + } catch (err) { + const missing = getMissingRoleKeyMap(err) + if (!missing) throw err + // API: encrypt role_key for missing admins and resend. + const extraKeys = buildRoleKeysFromProvidedPublicKeys(roleKeyMaterial.roleKey, missing) + payload.role_keys = [...(payload.role_keys || []), ...extraKeys] + const retryResponse = await auth.executeRestCommand(managedNodePrivilegeAddCommand(payload)) + assertCommandSucceeded( + retryResponse, + `managed_node_privilege_add retry failed for privilege=${TRANSFER_ACCOUNT}`, + ResultCodes.ROLE_PRIVILEGE_ADD_FAILED + ) + } +} + +export async function changeRolePrivileges( + auth: Auth, + input: ChangeRolePrivilegesInput +): Promise { + const roleIdentifiers = normalizeIdentifiers([input.role]) + if (roleIdentifiers.length === 0) { + throw new KeeperSdkError('A role is required.', ResultCodes.ROLE_REQUIRED) + } + if (input.node === undefined || input.node === null || input.node === '') { + throw new KeeperSdkError('A managed node is required.', ResultCodes.NO_NODES_FOR_ROLE_OP) + } + + const addPrivileges = normalizeIdentifiers(input.add ?? []).map((p) => p.toLowerCase()) + const removePrivileges = normalizeIdentifiers(input.remove ?? []).map((p) => p.toLowerCase()) + if (addPrivileges.length === 0 && removePrivileges.length === 0) { + throw new KeeperSdkError('No privileges specified. Use add and/or remove.', ResultCodes.NO_PRIVILEGES_SPECIFIED) + } + + if (addPrivileges.includes(MANAGE_COMPANIES) && (!input.mspKeys || input.mspKeys.length === 0)) { + throw new KeeperSdkError( + 'manage_companies requires mspKeys (mc_enterprise_id + tree_key per managed company).', + ResultCodes.NO_PRIVILEGES_SPECIFIED + ) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ROLE_PRIVILEGE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + applyEnterpriseNameToRoot(nodes, response.enterprise_name) + await enterpriseData.decryptNodeNames(nodes) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const resolvedRoles = resolveExistingRoles(roles, roleIdentifiers) + const role = resolvedRoles[0] + const roleName = (role.displayName || '').trim() || String(role.role_id) + + const node = resolveParentNode(nodes, input.node) + const nodeName = nodePathOrFallback(nodes, node) + + const managedNodeLink = (response.managed_nodes || []).find( + (link) => link.role_id === role.role_id && link.managed_node_id === node.node_id + ) + if (!managedNodeLink) { + throw new KeeperSdkError( + `Role "${roleName}" does not manage node "${nodeName}". Add the role to the node first.`, + ResultCodes.MANAGED_NODE_NOT_MANAGED_BY_ROLE + ) + } + + const needsTransferKeys = addPrivileges.includes(TRANSFER_ACCOUNT) + let roleKeyMaterial: RoleKeyMaterial | null = null + if (needsTransferKeys) { + const treeKey = await enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable; cannot grant transfer_account.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + const roleKey = (await enterpriseData.getRoleKey(role.role_id)) ?? generateEncryptionKey() + const usersById = new Map() + for (const user of response.users || []) usersById.set(user.enterprise_user_id, user) + roleKeyMaterial = await buildRoleKeyMaterial( + auth, + treeKey, + roleKey, + response.role_users || [], + usersById, + role.role_id + ) + } + + const items: RolePrivilegeItemResult[] = [] + + for (const privilege of removePrivileges) { + try { + const removeResponse = await auth.executeRestCommand( + managedNodePrivilegeRemoveCommand({ + role_id: role.role_id, + managed_node_id: node.node_id, + privilege, + }) + ) + assertCommandSucceeded( + removeResponse, + `managed_node_privilege_remove failed for privilege=${privilege}`, + ResultCodes.ROLE_PRIVILEGE_REMOVE_FAILED + ) + items.push({ + privilege, + action: 'remove', + status: RolePrivilegeStatus.Removed, + }) + } catch (err) { + items.push({ + privilege, + action: 'remove', + status: RolePrivilegeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + for (const privilege of addPrivileges) { + try { + const payload: ManagedNodePrivilegeAddRequest = { + role_id: role.role_id, + managed_node_id: node.node_id, + privilege, + } + if (privilege === MANAGE_COMPANIES && input.mspKeys) { + payload.msp_keys = input.mspKeys + } + if (privilege === TRANSFER_ACCOUNT && roleKeyMaterial) { + await addTransferAccountPrivilege(auth, payload, roleKeyMaterial) + } else { + const addResponse = await auth.executeRestCommand(managedNodePrivilegeAddCommand(payload)) + assertCommandSucceeded( + addResponse, + `managed_node_privilege_add failed for privilege=${privilege}`, + ResultCodes.ROLE_PRIVILEGE_ADD_FAILED + ) + } + items.push({ + privilege, + action: 'add', + status: RolePrivilegeStatus.Added, + }) + } catch (err) { + items.push({ + privilege, + action: 'add', + status: RolePrivilegeStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + + return finalizeResult(role.role_id, roleName, node.node_id, nodeName, items) +} + +function finalizeResult( + roleId: number, + roleName: string, + nodeId: number, + nodeName: string, + items: RolePrivilegeItemResult[] +): ChangeRolePrivilegesResult { + let succeeded = 0 + let failed = 0 + for (const item of items) { + if (item.status === RolePrivilegeStatus.Added || item.status === RolePrivilegeStatus.Removed) succeeded++ + else if (item.status === RolePrivilegeStatus.Failed) failed++ + } + return { + success: failed === 0 && succeeded > 0, + roleId, + roleName, + nodeId, + nodeName, + items, + succeeded, + skipped: 0, + failed, + } +} + +export function formatChangeRolePrivilegesResult(result: ChangeRolePrivilegesResult): FormattedRolePrivilegeTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.action, + item.privilege, + item.message || '', + ]) + return { + headers: [...ROLE_PRIVILEGE_TABLE_HEADERS], + rows, + parentLabel: `Role: ${result.roleName} Node: ${result.nodeName}`, + summary: `Succeeded: ${result.succeeded} Failed: ${result.failed}`, + } +} + +export function renderChangeRolePrivilegesAsciiTable(table: FormattedRolePrivilegeTable): string { + const { headers, rows } = table + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + return [ + table.parentLabel, + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join('\n') +} diff --git a/KeeperSdk/src/roles/roleUser.ts b/KeeperSdk/src/roles/roleUser.ts new file mode 100644 index 00000000..16523e63 --- /dev/null +++ b/KeeperSdk/src/roles/roleUser.ts @@ -0,0 +1,387 @@ +import { + roleUserAddCommand, + roleUserRemoveCommand, + type Auth, + type RoleUserAddRequest, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRole, + type EnterpriseRoleManagedNodeLink, + type EnterpriseRoleUserLink, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { resolveExistingUsers } from '../users/userTypes' +import { + applyDecryptedRoleNames, + assertCommandSucceeded, + normalizeIdentifiers, + resolveExistingRoles, +} from './roleUtils' +import { encryptForUserPublicKey, fetchUserPublicKeys, type UserPublicKeys } from './roleCrypto' + +const ROLE_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.Users, + EnterpriseDataInclude.RoleUsers, + EnterpriseDataInclude.ManagedNodes, +] + +const ROLE_USER_TABLE_HEADERS = ['#', 'Status', 'User Email', 'User ID', 'Role Name', 'Role ID', 'Detail'] + +export enum RoleUserStatus { + Added = 'added', + Removed = 'removed', + Skipped = 'skipped', + Failed = 'failed', +} + +export enum RoleUserSkipReason { + AlreadyMember = 'already_member', + NotMember = 'not_member', +} + +export type AddUsersToRolesInput = { + roles: string[] + users: string[] + /** + * When true, always send tree_key (admin role with managed nodes). + * When false, never. When omitted, send tree_key iff the role manages nodes (API contract). + */ + admin?: boolean +} + +export type RemoveUsersFromRolesInput = { + roles: string[] + users: string[] +} + +export type RoleUserItemResult = { + username: string + enterpriseUserId: number + roleId: number + roleName: string + status: RoleUserStatus + skipReason?: RoleUserSkipReason + message?: string +} + +export type RoleUserResult = { + success: boolean + items: RoleUserItemResult[] + succeeded: number + skipped: number + failed: number +} + +export type FormattedRoleUserTable = { + headers: string[] + rows: string[][] + summary: string +} + +type RoleUserContext = { + enterpriseData: EnterpriseDataManager + roles: EnterpriseRole[] + users: EnterpriseUser[] + membership: Set + /** Roles that manage at least one node — API requires tree_key on role_user_add. */ + managedNodeRoleIds: Set + forceAdmin: boolean | undefined +} + +const membershipKey = (roleId: number, userId: number): string => `${roleId}:${userId}` + +function buildManagedNodeRoleIds(managedNodes: EnterpriseRoleManagedNodeLink[] | undefined): Set { + const set = new Set() + for (const m of managedNodes || []) set.add(m.role_id) + return set +} + +async function loadRoleUserContext( + auth: Auth, + rawRoles: string[], + rawUsers: string[], + admin?: boolean +): Promise { + const roleIdentifiers = normalizeIdentifiers(rawRoles) + if (roleIdentifiers.length === 0) { + throw new KeeperSdkError('No roles specified.', ResultCodes.ROLE_REQUIRED) + } + const userIdentifiers = normalizeIdentifiers(rawUsers) + if (userIdentifiers.length === 0) { + throw new KeeperSdkError('No users specified.', ResultCodes.NO_USERS_FOR_ROLE_OP) + } + + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(ROLE_USER_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + + return { + enterpriseData, + roles: resolveExistingRoles(roles, roleIdentifiers), + users: resolveExistingUsers(response.users || [], userIdentifiers), + membership: buildMembershipSet(response.role_users), + managedNodeRoleIds: buildManagedNodeRoleIds(response.managed_nodes), + forceAdmin: admin, + } +} + +function buildMembershipSet(roleUsers: EnterpriseRoleUserLink[] | undefined): Set { + const membership = new Set() + for (const link of roleUsers || []) { + membership.add(membershipKey(link.role_id, link.enterprise_user_id)) + } + return membership +} + +function roleDisplayName(role: EnterpriseRole): string { + return (role.displayName || '').trim() || String(role.role_id) +} + +function buildItemBase(user: EnterpriseUser, role: EnterpriseRole): Omit { + return { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + roleId: role.role_id, + roleName: roleDisplayName(role), + } +} + +function needsTreeKey(ctx: RoleUserContext, roleId: number): boolean { + if (ctx.forceAdmin === true) return true + if (ctx.forceAdmin === false) return false + return ctx.managedNodeRoleIds.has(roleId) +} + +async function buildRoleUserAddRequest( + role: EnterpriseRole, + user: EnterpriseUser, + publicKeys: UserPublicKeys | undefined, + treeKey: Uint8Array | null, + roleKey: Uint8Array | null, + includeTreeKey: boolean +): Promise { + const request: RoleUserAddRequest = { + role_id: role.role_id, + enterprise_user_id: user.enterprise_user_id, + } + + if (includeTreeKey) { + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable (missing_tree_key).', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + if (!publicKeys) { + throw new KeeperSdkError( + `User "${user.username}": public key is not available`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + } + const treeEncrypted = await encryptForUserPublicKey(treeKey, publicKeys) + if (!treeEncrypted) { + throw new KeeperSdkError( + `User "${user.username}": public key is not available`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + } + request.tree_key = treeEncrypted.ciphertext + request.tree_key_type = treeEncrypted.keyType + } + + if (roleKey) { + if (!publicKeys) { + throw new KeeperSdkError( + `User "${user.username}": public key is not available (missing_role_key)`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + } + const roleEncrypted = await encryptForUserPublicKey(roleKey, publicKeys) + if (roleEncrypted) { + request.role_admin_key = roleEncrypted.ciphertext + request.role_admin_key_type = roleEncrypted.keyType + } + } + + return request +} + +export async function addUsersToRoles(auth: Auth, input: AddUsersToRolesInput): Promise { + const ctx = await loadRoleUserContext(auth, input.roles, input.users, input.admin) + const items: RoleUserItemResult[] = [] + + const rolesNeedingTreeKey = ctx.roles.filter((role) => needsTreeKey(ctx, role.role_id)) + let treeKey: Uint8Array | null = null + const roleKeyById = new Map() + let publicKeyMap = new Map() + + // Prefetch role keys for all target roles (role_admin_key when the role already has a key). + for (const role of ctx.roles) { + roleKeyById.set(role.role_id, await ctx.enterpriseData.getRoleKey(role.role_id)) + } + + const needsPublicKeys = rolesNeedingTreeKey.length > 0 || [...roleKeyById.values()].some((key) => key != null) + + if (rolesNeedingTreeKey.length > 0) { + treeKey = await ctx.enterpriseData.getTreeKey() + if (!treeKey) { + throw new KeeperSdkError( + 'Enterprise tree key is unavailable. The current user may not have permission to administer roles.', + ResultCodes.ENTERPRISE_TREE_KEY_UNAVAILABLE + ) + } + } + + if (needsPublicKeys) { + publicKeyMap = await fetchUserPublicKeys( + auth, + ctx.users.map((u) => u.username) + ) + } + + for (const role of ctx.roles) { + const includeTreeKey = needsTreeKey(ctx, role.role_id) + const roleKey = roleKeyById.get(role.role_id) ?? null + for (const user of ctx.users) { + const base = buildItemBase(user, role) + if (ctx.membership.has(membershipKey(role.role_id, user.enterprise_user_id))) { + items.push({ + ...base, + status: RoleUserStatus.Skipped, + skipReason: RoleUserSkipReason.AlreadyMember, + }) + continue + } + + try { + const request = await buildRoleUserAddRequest( + role, + user, + publicKeyMap.get(user.username.toLowerCase()), + treeKey, + roleKey, + includeTreeKey + ) + const response = await auth.executeRestCommand(roleUserAddCommand(request)) + assertCommandSucceeded( + response, + `role_user_add failed for role_id=${role.role_id}, user=${user.username}`, + ResultCodes.ROLE_USER_ADD_FAILED + ) + items.push({ ...base, status: RoleUserStatus.Added }) + } catch (err) { + items.push({ + ...base, + status: RoleUserStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + } + + return finalizeResult(items) +} + +export async function removeUsersFromRoles(auth: Auth, input: RemoveUsersFromRolesInput): Promise { + const ctx = await loadRoleUserContext(auth, input.roles, input.users) + const items: RoleUserItemResult[] = [] + + for (const role of ctx.roles) { + for (const user of ctx.users) { + const base = buildItemBase(user, role) + if (!ctx.membership.has(membershipKey(role.role_id, user.enterprise_user_id))) { + items.push({ + ...base, + status: RoleUserStatus.Skipped, + skipReason: RoleUserSkipReason.NotMember, + }) + continue + } + + try { + const response = await auth.executeRestCommand( + roleUserRemoveCommand({ + role_id: role.role_id, + enterprise_user_id: user.enterprise_user_id, + }) + ) + assertCommandSucceeded( + response, + `role_user_remove failed for role_id=${role.role_id}, user=${user.username}`, + ResultCodes.ROLE_USER_REMOVE_FAILED + ) + items.push({ ...base, status: RoleUserStatus.Removed }) + } catch (err) { + items.push({ + ...base, + status: RoleUserStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + } + + return finalizeResult(items) +} + +function finalizeResult(items: RoleUserItemResult[]): RoleUserResult { + let succeeded = 0 + let skipped = 0 + let failed = 0 + for (const item of items) { + if (item.status === RoleUserStatus.Added || item.status === RoleUserStatus.Removed) succeeded++ + else if (item.status === RoleUserStatus.Skipped) skipped++ + else failed++ + } + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + } +} + +export function formatRoleUserResult(result: RoleUserResult): FormattedRoleUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + item.roleName, + String(item.roleId), + item.message || item.skipReason || '', + ]) + return { + headers: [...ROLE_USER_TABLE_HEADERS], + rows, + summary: `Succeeded: ${result.succeeded} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderRoleUserAsciiTable(table: FormattedRoleUserTable): string { + const { headers, rows } = table + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + return [ + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join('\n') +} diff --git a/KeeperSdk/src/sharedFolders/SharedFolderManager.ts b/KeeperSdk/src/sharedFolders/SharedFolderManager.ts index a3621a6c..0cc29e5d 100644 --- a/KeeperSdk/src/sharedFolders/SharedFolderManager.ts +++ b/KeeperSdk/src/sharedFolders/SharedFolderManager.ts @@ -5,6 +5,10 @@ import { formatSharedFoldersTable, listSharedFolders, renderSharedFoldersAsciiTa import type { FormattedSharedFoldersTable, ListSharedFolderRow, ListSharedFoldersOptions } from './listSharedFolders' import { shareFolder } from './shareFolder' import type { ShareFolderInput, ShareFolderResult } from './shareFolder' +import { downloadMembership } from './downloadMembership' +import type { DownloadMembershipOptions, MembershipData } from './downloadMembership' +import { applyMembership } from './applyMembership' +import type { ApplyMembershipInput, ApplyMembershipOptions, ApplyMembershipResult } from './applyMembership' export type AuthProvider = () => Auth @@ -46,4 +50,15 @@ export class SharedFolderManager { public async shareFolder(input: ShareFolderInput): Promise { return shareFolder(this.requireAuth(), this.storage, input) } + + public async downloadMembership(options: DownloadMembershipOptions = {}): Promise { + return downloadMembership(this.requireAuth(), this.storage, options) + } + + public async applyMembership( + data: ApplyMembershipInput, + options: ApplyMembershipOptions = {} + ): Promise { + return applyMembership(this.requireAuth(), this.storage, data, options) + } } diff --git a/KeeperSdk/src/sharedFolders/applyMembership.ts b/KeeperSdk/src/sharedFolders/applyMembership.ts new file mode 100644 index 00000000..a6435a6f --- /dev/null +++ b/KeeperSdk/src/sharedFolders/applyMembership.ts @@ -0,0 +1,602 @@ +import type { Auth, DSharedFolder, DSharedFolderTeam, DSharedFolderUser, DTeam } from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { FolderKind, VaultObjectKind, sharedFolderName } from '../folders/folderHelpers' +import { updateSharedFolderPermissions } from '../folders/updateFolder' +import { updateSharedFolderMembership, type ShareFolderUserStatus } from './shareFolder' +import { buildAccountUidEmailMap, resolveUserEmail } from './downloadMembership' +import type { MembershipData, MembershipSharedFolder, MembershipTeam } from './downloadMembership' +import { addUsersToTeams, removeUsersFromTeams } from '../users/teamUser' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { resolveExistingTeams } from '../teams/teamUtils' +import { extractErrorMessage, isValidEmail, KeeperSdkError } from '../utils' + +export type ApplyMembershipInput = MembershipData | MembershipSharedFolder[] + +export type ApplyMembershipOptions = { + /** + * When `true`, also updates permission flags that differ from the JSON + * and removes users/teams (and team members) that are not present in the + * JSON. When `false` (default, matches Commander), only missing grants + * are added; existing membership is left untouched even if permissions differ. + */ + fullSync?: boolean +} + +export type ApplyMembershipCounts = { + usersAdded: number + usersUpdated: number + usersRemoved: number + teamsAdded: number + teamsUpdated: number + teamsRemoved: number + teamMembersAdded: number + teamMembersRemoved: number +} + +export type ApplyMembershipFolderResult = { + sharedFolderUid?: string + path: string + success: boolean + message?: string + counts: ApplyMembershipCounts + results: ShareFolderUserStatus[] +} + +export type ApplyMembershipTeamMembershipResult = { + teamUid?: string + name: string + success: boolean + message?: string + added: number + removed: number +} + +export type ApplyMembershipResult = { + success: boolean + folders: ApplyMembershipFolderResult[] + teamMembership: ApplyMembershipTeamMembershipResult[] + totals: ApplyMembershipCounts +} + +function emptyCounts(): ApplyMembershipCounts { + return { + usersAdded: 0, + usersUpdated: 0, + usersRemoved: 0, + teamsAdded: 0, + teamsUpdated: 0, + teamsRemoved: 0, + teamMembersAdded: 0, + teamMembersRemoved: 0, + } +} + +function addCounts(totals: ApplyMembershipCounts, counts: ApplyMembershipCounts): ApplyMembershipCounts { + return { + usersAdded: totals.usersAdded + counts.usersAdded, + usersUpdated: totals.usersUpdated + counts.usersUpdated, + usersRemoved: totals.usersRemoved + counts.usersRemoved, + teamsAdded: totals.teamsAdded + counts.teamsAdded, + teamsUpdated: totals.teamsUpdated + counts.teamsUpdated, + teamsRemoved: totals.teamsRemoved + counts.teamsRemoved, + teamMembersAdded: totals.teamMembersAdded + counts.teamMembersAdded, + teamMembersRemoved: totals.teamMembersRemoved + counts.teamMembersRemoved, + } +} + +function normalizeMembershipData(data: ApplyMembershipInput): MembershipData { + if (Array.isArray(data)) return { shared_folders: data } + return { shared_folders: data.shared_folders || [], teams: data.teams } +} + +function resolveSharedFolder(storage: InMemoryStorage, entry: MembershipSharedFolder): DSharedFolder | undefined { + const uid = (entry.uid || '').trim() + if (uid) { + const byUid = storage.getByUid(FolderKind.SharedFolder, uid) + if (byUid) return byUid + } + + const path = (entry.path || '').trim() + if (!path) return undefined + const lowerPath = path.toLowerCase() + const matches = storage + .getAll(FolderKind.SharedFolder) + .filter((sharedFolder) => sharedFolderName(sharedFolder).toLowerCase() === lowerPath) + if (matches.length === 1) return matches[0] + if (matches.length > 1) { + throw new KeeperSdkError( + `Shared folder name "${path}" is not unique. Use the shared folder UID instead.`, + 'shared_folder_ambiguous' + ) + } + return undefined +} + +function getExistingUsers( + storage: InMemoryStorage, + sharedFolderUid: string, + emailMap: Map +): Map { + const existing = new Map() + for (const sharedUser of storage.getAll(VaultObjectKind.SharedFolderUser)) { + if (sharedUser.sharedFolderUid !== sharedFolderUid) continue + const email = resolveUserEmail(sharedUser.accountUid, sharedUser.accountUsername, emailMap) + if (!email) continue + existing.set(email.toLowerCase(), sharedUser) + } + return existing +} + +function getExistingTeams(storage: InMemoryStorage, sharedFolderUid: string): Map { + const existing = new Map() + for (const sharedTeam of storage.getAll(VaultObjectKind.SharedFolderTeam)) { + if (sharedTeam.sharedFolderUid !== sharedFolderUid) continue + existing.set(sharedTeam.teamUid, sharedTeam) + } + return existing +} + +/** Resolves team names/uids to team uids, preserving a 1:1 identifier -> uid mapping. */ +async function resolveTeamIdentifierMap( + auth: Auth, + storage: InMemoryStorage, + identifiers: string[] +): Promise> { + const resolved = new Map() + if (identifiers.length === 0) return resolved + + const vaultTeams = storage.getAll(VaultObjectKind.Team) + const byUid = new Set(vaultTeams.map((team) => team.uid)) + const byLowerName = new Map() + for (const team of vaultTeams) { + const name = (team.name || '').trim().toLowerCase() + if (!name) continue + const uids = byLowerName.get(name) || [] + uids.push(team.uid) + byLowerName.set(name, uids) + } + + const unresolved: string[] = [] + for (const rawIdentifier of identifiers) { + const identifier = rawIdentifier.trim() + if (byUid.has(identifier)) { + resolved.set(rawIdentifier, identifier) + continue + } + const nameMatches = byLowerName.get(identifier.toLowerCase()) + if (nameMatches && nameMatches.length === 1) { + resolved.set(rawIdentifier, nameMatches[0]) + continue + } + if (nameMatches && nameMatches.length > 1) { + throw new KeeperSdkError( + `Team name "${rawIdentifier}" is not unique. Use the team UID instead.`, + 'multiple_team_matches' + ) + } + unresolved.push(rawIdentifier) + } + + if (unresolved.length > 0) { + const enterpriseData = new EnterpriseDataManager(auth) + const data = await enterpriseData.getData([EnterpriseDataInclude.Teams, EnterpriseDataInclude.QueuedTeams]) + for (const rawIdentifier of unresolved) { + try { + const teams = resolveExistingTeams(data.teams || [], [rawIdentifier.trim()], data.queued_teams || []) + if (teams.length > 0) resolved.set(rawIdentifier, teams[0].team_uid) + } catch { + // Leave unresolved; caller reports the missing target per-permission. + } + } + } + + return resolved +} + +function tallyCounts( + addUsers: { email: string }[], + updateUsers: { email: string }[], + removeUsers: string[], + addTeams: { teamUid: string }[], + updateTeams: { teamUid: string }[], + removeTeams: string[], + results: ShareFolderUserStatus[] +): ApplyMembershipCounts { + const addUserSet = new Set(addUsers.map((grant) => grant.email.toLowerCase())) + const updateUserSet = new Set(updateUsers.map((grant) => grant.email.toLowerCase())) + const removeUserSet = new Set(removeUsers.map((email) => email.toLowerCase())) + const addTeamSet = new Set(addTeams.map((grant) => grant.teamUid)) + const updateTeamSet = new Set(updateTeams.map((grant) => grant.teamUid)) + const removeTeamSet = new Set(removeTeams) + + const counts = emptyCounts() + for (const result of results) { + if (!result.success) continue + const key = (result.email || '').toLowerCase() + if (addUserSet.has(key)) counts.usersAdded += 1 + else if (updateUserSet.has(key)) counts.usersUpdated += 1 + else if (removeUserSet.has(key)) counts.usersRemoved += 1 + else if (addTeamSet.has(result.email)) counts.teamsAdded += 1 + else if (updateTeamSet.has(result.email)) counts.teamsUpdated += 1 + else if (removeTeamSet.has(result.email)) counts.teamsRemoved += 1 + } + return counts +} + +async function applyFolderMembership( + auth: Auth, + storage: InMemoryStorage, + entry: MembershipSharedFolder, + fullSync: boolean +): Promise { + const path = entry.path || entry.uid || '(unknown)' + const counts = emptyCounts() + + let sharedFolder: DSharedFolder | undefined + try { + sharedFolder = resolveSharedFolder(storage, entry) + } catch (err) { + return { + path, + success: false, + message: extractErrorMessage(err), + counts, + results: [], + } + } + if (!sharedFolder) { + return { + path, + success: false, + message: `Shared folder "${entry.uid || entry.path}" was not found in the vault.`, + counts, + results: [], + } + } + + const emailMap = buildAccountUidEmailMap(storage) + const existingUsers = getExistingUsers(storage, sharedFolder.uid, emailMap) + const existingTeams = getExistingTeams(storage, sharedFolder.uid) + + const permissions = entry.permissions || [] + const userPermissions = permissions.filter((permission) => isValidEmail(permission.name)) + const teamPermissions = permissions.filter((permission) => !isValidEmail(permission.name)) + + let teamIdentifierMap: Map + try { + teamIdentifierMap = await resolveTeamIdentifierMap( + auth, + storage, + teamPermissions.map((permission) => permission.name) + ) + } catch (err) { + return { + sharedFolderUid: sharedFolder.uid, + path, + success: false, + message: extractErrorMessage(err), + counts, + results: [], + } + } + + const addUsers: { + email: string + manageUsers?: boolean + manageRecords?: boolean + }[] = [] + const updateUsers: { + email: string + manageUsers?: boolean + manageRecords?: boolean + }[] = [] + const seenEmails = new Set() + + for (const permission of userPermissions) { + const email = permission.name.trim() + const key = email.toLowerCase() + if (!key || seenEmails.has(key)) continue + seenEmails.add(key) + const wantManageUsers = permission.manage_users === true + const wantManageRecords = permission.manage_records === true + const existing = existingUsers.get(key) + if (!existing) { + addUsers.push({ + email, + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + }) + } else if ( + fullSync && + (existing.manageUsers !== wantManageUsers || existing.manageRecords !== wantManageRecords) + ) { + updateUsers.push({ + email, + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + }) + } + } + + const removeUsers: string[] = [] + if (fullSync) { + const ownerEmail = (sharedFolder.ownerUsername || '').trim().toLowerCase() + for (const [email] of existingUsers) { + if (seenEmails.has(email)) continue + if (ownerEmail && ownerEmail === email) continue + removeUsers.push(email) + } + } + + const addTeams: { + teamUid: string + manageUsers?: boolean + manageRecords?: boolean + }[] = [] + const updateTeams: { + teamUid: string + manageUsers?: boolean + manageRecords?: boolean + }[] = [] + const seenTeamUids = new Set() + const missingTargets: string[] = [] + + for (const permission of teamPermissions) { + const teamUid = teamIdentifierMap.get(permission.name) + if (!teamUid) { + missingTargets.push(permission.name) + continue + } + if (seenTeamUids.has(teamUid)) continue + seenTeamUids.add(teamUid) + const wantManageUsers = permission.manage_users === true + const wantManageRecords = permission.manage_records === true + const existing = existingTeams.get(teamUid) + if (!existing) { + addTeams.push({ + teamUid, + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + }) + } else if ( + fullSync && + (existing.manageUsers !== wantManageUsers || existing.manageRecords !== wantManageRecords) + ) { + updateTeams.push({ + teamUid, + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + }) + } + } + + const removeTeams: string[] = [] + if (fullSync) { + for (const [teamUid] of existingTeams) { + if (!seenTeamUids.has(teamUid)) removeTeams.push(teamUid) + } + } + + let membershipResult + try { + membershipResult = await updateSharedFolderMembership(auth, storage, { + sharedFolderUid: sharedFolder.uid, + addUsers, + updateUsers, + removeUsers, + addTeams, + updateTeams, + removeTeams, + }) + } catch (err) { + return { + sharedFolderUid: sharedFolder.uid, + path, + success: false, + message: extractErrorMessage(err), + counts, + results: [], + } + } + + let permissionsMessage: string | undefined + if (fullSync) { + const wantManageUsers = entry.manage_users === true + const wantManageRecords = entry.manage_records === true + const wantCanEdit = entry.can_edit === true + const wantCanShare = entry.can_share === true + const defaultsChanged = + sharedFolder.defaultManageUsers !== wantManageUsers || + sharedFolder.defaultManageRecords !== wantManageRecords || + sharedFolder.defaultCanEdit !== wantCanEdit || + sharedFolder.defaultCanShare !== wantCanShare + if (defaultsChanged) { + try { + const permissionResult = await updateSharedFolderPermissions(auth, storage, sharedFolder.uid, { + manageUsers: wantManageUsers, + manageRecords: wantManageRecords, + canEdit: wantCanEdit, + canShare: wantCanShare, + }) + if (!permissionResult.success) permissionsMessage = permissionResult.message + } catch (err) { + permissionsMessage = extractErrorMessage(err) + } + } + } + + const missingMessage = + missingTargets.length > 0 ? `Could not resolve team(s): ${missingTargets.join(', ')}` : undefined + const message = + [membershipResult.message, permissionsMessage, missingMessage] + .filter((part): part is string => !!part) + .join('; ') || undefined + + return { + sharedFolderUid: sharedFolder.uid, + path, + success: membershipResult.success && !permissionsMessage && missingTargets.length === 0, + message, + counts: tallyCounts( + addUsers, + updateUsers, + removeUsers, + addTeams, + updateTeams, + removeTeams, + membershipResult.results + ), + results: membershipResult.results, + } +} + +async function applyTeamMembership( + auth: Auth, + team: MembershipTeam, + fullSync: boolean +): Promise { + const name = team.name || team.uid || '(unknown)' + const desired = new Set((team.members || []).map((member) => member.trim().toLowerCase()).filter(Boolean)) + if (desired.size === 0 && !fullSync) { + return { teamUid: team.uid, name, success: true, added: 0, removed: 0 } + } + + const enterpriseData = new EnterpriseDataManager(auth) + let data + try { + data = await enterpriseData.getData([ + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.QueuedTeamUsers, + ]) + } catch (err) { + return { + teamUid: team.uid, + name, + success: false, + message: extractErrorMessage(err), + added: 0, + removed: 0, + } + } + + const identifier = (team.uid || team.name || '').trim() + let resolvedTeamUid = '' + try { + const resolved = resolveExistingTeams(data.teams || [], identifier ? [identifier] : [], data.queued_teams || []) + resolvedTeamUid = resolved[0]?.team_uid || '' + } catch (err) { + return { + teamUid: team.uid, + name, + success: false, + message: extractErrorMessage(err), + added: 0, + removed: 0, + } + } + if (!resolvedTeamUid) { + return { + teamUid: team.uid, + name, + success: false, + message: `Team "${name}" was not found.`, + added: 0, + removed: 0, + } + } + + const usernameById = new Map() + for (const user of data.users || []) usernameById.set(user.enterprise_user_id, user.username) + + const currentMembers = new Set() + for (const link of [...(data.team_users || []), ...(data.queued_team_users || [])]) { + if (link.team_uid !== resolvedTeamUid) continue + const username = usernameById.get(link.enterprise_user_id) + if (username) currentMembers.add(username.toLowerCase()) + } + + const toAdd = [...desired].filter((email) => !currentMembers.has(email)) + const toRemove = fullSync ? [...currentMembers].filter((email) => !desired.has(email)) : [] + + let added = 0 + let removed = 0 + const messages: string[] = [] + + if (toAdd.length > 0) { + try { + const result = await addUsersToTeams(auth, { + users: toAdd, + teams: [resolvedTeamUid], + }) + added = result.succeeded + if (result.failed > 0) messages.push(`${result.failed} member(s) failed to add`) + } catch (err) { + messages.push(`add failed: ${extractErrorMessage(err)}`) + } + } + + if (toRemove.length > 0) { + try { + const result = await removeUsersFromTeams(auth, { + users: toRemove, + teams: [resolvedTeamUid], + }) + removed = result.succeeded + if (result.failed > 0) messages.push(`${result.failed} member(s) failed to remove`) + } catch (err) { + messages.push(`remove failed: ${extractErrorMessage(err)}`) + } + } + + return { + teamUid: resolvedTeamUid, + name, + success: messages.length === 0, + message: messages.length > 0 ? messages.join('; ') : undefined, + added, + removed, + } +} + +/** + * Applies a previously downloaded (Commander-compatible) shared folder + * membership JSON to the vault: missing users/teams are granted access + * (and enterprise team membership from `teams[].members` is filled in). + * + * With `options.fullSync`, permission flags that differ are also updated and + * users/teams (and team members) absent from the JSON are removed. Without + * it (the default, matching Commander), only missing grants are added. + */ +export async function applyMembership( + auth: Auth, + storage: InMemoryStorage, + data: ApplyMembershipInput, + options: ApplyMembershipOptions = {} +): Promise { + const fullSync = options.fullSync === true + const normalized = normalizeMembershipData(data) + + const folders: ApplyMembershipFolderResult[] = [] + for (const entry of normalized.shared_folders || []) { + folders.push(await applyFolderMembership(auth, storage, entry, fullSync)) + } + + const teamMembership: ApplyMembershipTeamMembershipResult[] = [] + for (const team of normalized.teams || []) { + teamMembership.push(await applyTeamMembership(auth, team, fullSync)) + } + + let totals = emptyCounts() + for (const folder of folders) totals = addCounts(totals, folder.counts) + for (const team of teamMembership) { + totals.teamMembersAdded += team.added + totals.teamMembersRemoved += team.removed + } + + const success = folders.every((folder) => folder.success) && teamMembership.every((team) => team.success) + + return { success, folders, teamMembership, totals } +} diff --git a/KeeperSdk/src/sharedFolders/downloadMembership.ts b/KeeperSdk/src/sharedFolders/downloadMembership.ts new file mode 100644 index 00000000..177e1dfb --- /dev/null +++ b/KeeperSdk/src/sharedFolders/downloadMembership.ts @@ -0,0 +1,255 @@ +import type { Auth, DSharedFolder, DSharedFolderTeam, DSharedFolderUser, DUser } from '@keeper-security/keeperapi' +import { webSafe64FromBytes } from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { FolderKind, VaultObjectKind, sharedFolderName } from '../folders/folderHelpers' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { extractErrorMessage, logger } from '../utils' + +/** A single user or team permission entry on a shared folder (Commander JSON shape). */ +export type MembershipPermission = { + name: string + manage_users: boolean + manage_records: boolean +} + +/** A shared folder's membership, matching Commander's `download-membership` JSON shape. */ +export type MembershipSharedFolder = { + uid: string + path: string + manage_users: boolean + manage_records: boolean + can_edit: boolean + can_share: boolean + permissions: MembershipPermission[] +} + +/** Enterprise team + its members, included alongside shared folder membership. */ +export type MembershipTeam = { + uid: string + name: string + members: string[] +} + +export type MembershipData = { + shared_folders: MembershipSharedFolder[] + teams?: MembershipTeam[] +} + +export type DownloadMembershipOptions = { + /** Skip enterprise team lookup and only export shared folder membership. */ + foldersOnly?: boolean + /** Case-insensitive substring/exact-uid filter applied to shared folder name or uid. */ + folderFilter?: string + /** Force every exported user/team permission's manage_users flag to `true`. */ + forceManageUsers?: boolean + /** Force every exported user/team permission's manage_records flag to `true`. */ + forceManageRecords?: boolean + /** Force every exported user/team permission's manage_users flag to `false`. */ + forceRestrictUsers?: boolean + /** Force every exported user/team permission's manage_records flag to `false`. */ + forceRestrictRecords?: boolean +} + +function resolveForcedFlag(value: boolean, force: boolean | undefined, restrict: boolean | undefined): boolean { + if (force === true) return true + if (restrict === true) return false + return value +} + +/** Maps `DUser.accountUid` (webSafe64) to the account's username/email. */ +export function buildAccountUidEmailMap(storage: InMemoryStorage): Map { + const accountUidToEmail = new Map() + for (const user of storage.getAll(VaultObjectKind.User)) { + const uid = user.accountUid ? webSafe64FromBytes(user.accountUid) : '' + const email = (user.username || '').trim() + if (uid && email) accountUidToEmail.set(uid, email) + } + return accountUidToEmail +} + +/** Resolves the best-known email/username for a shared-folder user/owner entry. */ +export function resolveUserEmail( + accountUid: string | undefined, + accountUsername: string | undefined, + emailMap: Map +): string { + const explicit = (accountUsername || '').trim() + if (explicit) return explicit + if (accountUid) return emailMap.get(accountUid) || accountUid + return '' +} + +function collectFolderPermissions( + storage: InMemoryStorage, + sharedFolder: DSharedFolder, + emailMap: Map, + options: DownloadMembershipOptions +): MembershipPermission[] { + const permissions: MembershipPermission[] = [] + const seenUserUids = new Set() + const seenEmails = new Set() + + for (const sharedUser of storage.getAll(VaultObjectKind.SharedFolderUser)) { + if (sharedUser.sharedFolderUid !== sharedFolder.uid) continue + if (sharedUser.accountUid) seenUserUids.add(sharedUser.accountUid) + const email = resolveUserEmail(sharedUser.accountUid, sharedUser.accountUsername, emailMap) + if (!email) continue + const key = email.toLowerCase() + if (seenEmails.has(key)) continue + seenEmails.add(key) + permissions.push({ + name: email, + manage_users: resolveForcedFlag( + sharedUser.manageUsers === true, + options.forceManageUsers, + options.forceRestrictUsers + ), + manage_records: resolveForcedFlag( + sharedUser.manageRecords === true, + options.forceManageRecords, + options.forceRestrictRecords + ), + }) + } + + if (sharedFolder.ownerAccountUid && !seenUserUids.has(sharedFolder.ownerAccountUid)) { + const ownerEmail = resolveUserEmail(sharedFolder.ownerAccountUid, sharedFolder.ownerUsername, emailMap) + const key = ownerEmail.toLowerCase() + if (ownerEmail && !seenEmails.has(key)) { + seenEmails.add(key) + permissions.push({ + name: ownerEmail, + manage_users: resolveForcedFlag(true, options.forceManageUsers, options.forceRestrictUsers), + manage_records: resolveForcedFlag(true, options.forceManageRecords, options.forceRestrictRecords), + }) + } + } + + for (const sharedTeam of storage.getAll(VaultObjectKind.SharedFolderTeam)) { + if (sharedTeam.sharedFolderUid !== sharedFolder.uid) continue + const name = (sharedTeam.name || sharedTeam.teamUid || '').trim() + if (!name) continue + permissions.push({ + name, + manage_users: resolveForcedFlag( + sharedTeam.manageUsers === true, + options.forceManageUsers, + options.forceRestrictUsers + ), + manage_records: resolveForcedFlag( + sharedTeam.manageRecords === true, + options.forceManageRecords, + options.forceRestrictRecords + ), + }) + } + + permissions.sort((permissionA, permissionB) => + permissionA.name.toLowerCase().localeCompare(permissionB.name.toLowerCase()) + ) + return permissions +} + +function matchesFolderFilter(sharedFolder: DSharedFolder, filter: string | undefined): boolean { + const pattern = (filter || '').trim().toLowerCase() + if (!pattern) return true + if (sharedFolder.uid.toLowerCase() === pattern) return true + return sharedFolderName(sharedFolder).toLowerCase().includes(pattern) +} + +async function collectEnterpriseTeams(auth: Auth): Promise { + const enterpriseData = new EnterpriseDataManager(auth) + const data = await enterpriseData.getData([ + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.QueuedTeams, + EnterpriseDataInclude.TeamUsers, + EnterpriseDataInclude.QueuedTeamUsers, + ]) + + const usernameById = new Map() + for (const user of data.users || []) { + usernameById.set(user.enterprise_user_id, user.username) + } + + const membersByTeam = new Map>() + for (const link of [...(data.team_users || []), ...(data.queued_team_users || [])]) { + const username = usernameById.get(link.enterprise_user_id) + if (!username) continue + let members = membersByTeam.get(link.team_uid) + if (!members) { + members = new Set() + membersByTeam.set(link.team_uid, members) + } + members.add(username) + } + + const teams: MembershipTeam[] = [] + const seenTeamUids = new Set() + for (const team of [...(data.teams || []), ...(data.queued_teams || [])]) { + if (!team.team_uid || seenTeamUids.has(team.team_uid)) continue + seenTeamUids.add(team.team_uid) + const members = Array.from(membersByTeam.get(team.team_uid) || []).sort((memberA, memberB) => + memberA.localeCompare(memberB) + ) + teams.push({ + uid: team.team_uid, + name: team.name || team.team_uid, + members, + }) + } + teams.sort((teamA, teamB) => teamA.name.toLowerCase().localeCompare(teamB.name.toLowerCase())) + return teams +} + +/** + * Exports shared folder (and optionally enterprise team) membership to a + * Commander-compatible JSON structure, suitable for later re-applying with + * `applyMembership`. + * + * `auth` is only required (and only used) to fetch enterprise team data; pass + * `null` (or set `options.foldersOnly`) to export shared folder membership only. + */ +export async function downloadMembership( + auth: Auth | null, + storage: InMemoryStorage, + options: DownloadMembershipOptions = {} +): Promise { + const emailMap = buildAccountUidEmailMap(storage) + + const shared_folders: MembershipSharedFolder[] = storage + .getAll(FolderKind.SharedFolder) + .filter((sharedFolder) => matchesFolderFilter(sharedFolder, options.folderFilter)) + .map((sharedFolder) => ({ + uid: sharedFolder.uid, + path: sharedFolderName(sharedFolder), + manage_users: resolveForcedFlag( + sharedFolder.defaultManageUsers === true, + options.forceManageUsers, + options.forceRestrictUsers + ), + manage_records: resolveForcedFlag( + sharedFolder.defaultManageRecords === true, + options.forceManageRecords, + options.forceRestrictRecords + ), + can_edit: sharedFolder.defaultCanEdit === true, + can_share: sharedFolder.defaultCanShare === true, + permissions: collectFolderPermissions(storage, sharedFolder, emailMap, options), + })) + .sort((folderA, folderB) => folderA.path.toLowerCase().localeCompare(folderB.path.toLowerCase())) + + const result: MembershipData = { shared_folders } + + if (!options.foldersOnly && auth) { + try { + result.teams = await collectEnterpriseTeams(auth) + } catch (err) { + logger.debug( + `download-membership: could not include enterprise team membership: ${extractErrorMessage(err)}` + ) + } + } + + return result +} 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/sharedFolders/shareFolder.ts b/KeeperSdk/src/sharedFolders/shareFolder.ts index d365d670..6278da5a 100644 --- a/KeeperSdk/src/sharedFolders/shareFolder.ts +++ b/KeeperSdk/src/sharedFolders/shareFolder.ts @@ -3,7 +3,9 @@ import type { Authentication, DSharedFolder, DSharedFolderFolder, + DSharedFolderTeam, DSharedFolderUser, + DTeam, DUserFolder, } from '@keeper-security/keeperapi' import { @@ -12,8 +14,12 @@ import { normal64Bytes, platform, sharedFolderUpdateV3Message, + webSafe64FromBytes, + type RestCommand, } from '@keeper-security/keeperapi' import { InMemoryStorage } from '../storage/InMemoryStorage' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../teams/enterpriseData' +import { resolveExistingTeams } from '../teams/teamUtils' import { extractErrorMessage, isBoolean, isObject, isValidEmail, KeeperSdkError } from '../utils' import { FolderKind, FolderResultStatus, VaultObjectKind } from '../folders/folderHelpers' @@ -70,12 +76,28 @@ type ResolvedFolder = } | { kind: FolderKind.UserFolder; folderUid: string; displayName: string } -type UserPublicKeys = { +type TeamPublicKeys = { rsaPublicKey: Uint8Array | null eccPublicKey: Uint8Array | null - errorCode?: string - message?: string - username: string + aesKey: Uint8Array | null +} + +const TEAM_GET_KEYS_BATCH_SIZE = 90 + +type TeamGetKeysResponse = { + keys?: Array<{ + team_uid: string + key: string + type: number + }> +} + +function teamGetKeysCommand(teams: string[]): RestCommand<{ teams: string[] }, TeamGetKeysResponse> { + return { + baseRequest: { command: 'team_get_keys' }, + request: { teams }, + authorization: {}, + } } function toSetBoolean(value: boolean | undefined): Folder.SetBooleanValue { @@ -185,30 +207,196 @@ async function fetchUserPublicKeys(auth: Auth, emails: string[]): Promise() - const dedupedEmails: string[] = [] - for (const rawEmail of emails) { - const normalized = (rawEmail || '').trim().toLowerCase() + const deduped: string[] = [] + for (const rawTarget of targets) { + const normalized = (rawTarget || '').trim() if (!normalized) continue - if (seen.has(normalized)) continue - seen.add(normalized) - dedupedEmails.push(normalized) + const key = isValidEmail(normalized) ? normalized.toLowerCase() : normalized + if (seen.has(key)) continue + seen.add(key) + deduped.push(isValidEmail(normalized) ? key : normalized) + } + return deduped +} + +async function resolveTeamUids(auth: Auth, storage: InMemoryStorage, identifiers: string[]): Promise { + if (identifiers.length === 0) return [] + + const vaultTeams = storage.getAll(VaultObjectKind.Team) + const byUid = new Map(vaultTeams.map((team) => [team.uid, team.uid])) + const byLowerName = new Map() + for (const team of vaultTeams) { + const name = (team.name || '').trim().toLowerCase() + if (name && !byLowerName.has(name)) byLowerName.set(name, team.uid) + } + + const toResolve: string[] = [] + const resolved: string[] = [] + for (const raw of identifiers) { + const id = raw.trim() + if (byUid.has(id)) { + resolved.push(byUid.get(id)!) + continue + } + const nameMatch = byLowerName.get(id.toLowerCase()) + if (nameMatch) { + resolved.push(nameMatch) + continue + } + toResolve.push(id) + } + + if (toResolve.length > 0) { + const enterpriseData = new EnterpriseDataManager(auth) + const data = await enterpriseData.getData([EnterpriseDataInclude.Teams, EnterpriseDataInclude.QueuedTeams]) + const teams = resolveExistingTeams(data.teams || [], toResolve, data.queued_teams || []) + resolved.push(...teams.map((team) => team.team_uid)) + } + + return [...new Set(resolved)] +} + +async function loadTeamKeys(auth: Auth, teamUids: string[]): Promise> { + const keysByTeam = new Map() + if (teamUids.length === 0) return keysByTeam + + const pending = [...new Set(teamUids)] + while (pending.length > 0) { + const batch = pending.splice(0, TEAM_GET_KEYS_BATCH_SIZE) + let response + try { + response = await auth.executeRestCommand(teamGetKeysCommand(batch)) + } catch (err) { + throw new KeeperSdkError(`Failed to fetch team keys: ${extractErrorMessage(err)}`) + } + + for (const entry of response.keys || []) { + const teamUid = (entry.team_uid || '').trim() + if (!teamUid || !entry.key) continue + try { + const encryptedKey = normal64Bytes(entry.key) + keysByTeam.set(teamUid, await decryptTeamKeyEntry(auth, encryptedKey, entry.type)) + } catch (err) { + throw new KeeperSdkError(`Failed to decrypt team key for "${teamUid}": ${extractErrorMessage(err)}`) + } + } + } + + return keysByTeam +} + +async function decryptTeamKeyEntry(auth: Auth, encryptedKey: Uint8Array, keyType: number): Promise { + const empty: TeamPublicKeys = { + rsaPublicKey: null, + eccPublicKey: null, + aesKey: null, + } + switch (keyType) { + case 1: + return { + ...empty, + aesKey: await platform.aesCbcDecrypt(encryptedKey, auth.dataKey, true), + } + case 2: + if (!auth.privateKey) return empty + return { + ...empty, + aesKey: platform.privateDecrypt(encryptedKey, auth.privateKey), + } + case 3: + return { + ...empty, + aesKey: await platform.aesGcmDecrypt(encryptedKey, auth.dataKey), + } + case 4: + if (!auth.eccPrivateKey) return empty + return { + ...empty, + aesKey: await platform.privateDecryptEC(encryptedKey, auth.eccPrivateKey), + } + case -1: + return { ...empty, eccPublicKey: encryptedKey } + case -3: + return { ...empty, rsaPublicKey: encryptedKey } + default: + return empty + } +} + +async function encryptSharedFolderKeyForTeam( + sharedFolderKey: Uint8Array, + teamKeys: TeamPublicKeys +): Promise { + if (teamKeys.aesKey) { + return { + encryptedKey: await platform.aesCbcEncrypt(sharedFolderKey, teamKeys.aesKey, true), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_data_key, + } + } + if (teamKeys.eccPublicKey) { + return { + encryptedKey: await platform.publicEncryptEC(sharedFolderKey, teamKeys.eccPublicKey), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key_ecc, + } + } + if (teamKeys.rsaPublicKey) { + return { + encryptedKey: platform.publicEncrypt(sharedFolderKey, platform.bytesToBase64(teamKeys.rsaPublicKey)), + encryptedKeyType: Folder.EncryptedKeyType.encrypted_by_public_key, + } } - return dedupedEmails + return undefined +} + +function teamUidFromStatus(teamUid: Uint8Array | null | undefined): string { + return teamUid && teamUid.length > 0 ? webSafe64FromBytes(teamUid) : '' } async function removeFromSharedFolder( auth: Auth, sharedFolder: DSharedFolder, resolved: Extract, - emails: string[] + userEmails: string[], + teamUids: string[] ): Promise { const updateRequest: Folder.ISharedFolderUpdateV3Request = { sharedFolderUid: normal64Bytes(sharedFolder.uid), revision: sharedFolder.revision, forceUpdate: false, - sharedFolderRemoveUser: emails, + } + if (userEmails.length > 0) updateRequest.sharedFolderRemoveUser = userEmails + if (teamUids.length > 0) { + updateRequest.sharedFolderRemoveTeam = teamUids.map((teamUid) => normal64Bytes(teamUid)) + } + + if ( + (updateRequest.sharedFolderRemoveUser || []).length === 0 && + (updateRequest.sharedFolderRemoveTeam || []).length === 0 + ) { + throw new KeeperSdkError('Provide at least one user or team to remove.', 'no_targets') } let response: Folder.ISharedFolderUpdateV3ResponseV2 @@ -237,8 +425,16 @@ async function removeFromSharedFolder( status, }) } + for (const teamStatus of innerResponse?.sharedFolderRemoveTeamStatus || []) { + const status = teamStatus.status || ShareFolderUserResultStatus.Unknown + userResults.push({ + email: teamUidFromStatus(teamStatus.teamUid as Uint8Array), + success: status === FolderResultStatus.Success, + status, + }) + } - const allUsersOk = userResults.length > 0 && userResults.every((userResult) => userResult.success) + const allUsersOk = userResults.length === 0 ? requestOk : userResults.every((userResult) => userResult.success) const failureReason = !requestOk ? innerResponse?.status || @@ -273,18 +469,16 @@ async function shareWithSharedFolder( ) } - const emails = dedupeEmails(input.emails) - if (emails.length === 0) { - throw new KeeperSdkError('Provide at least one user email.', 'no_emails') + const targets = dedupeTargets(input.emails) + if (targets.length === 0) { + throw new KeeperSdkError('Provide at least one user email or team.', 'no_targets') } - const invalidEmails = emails.filter((email) => !isValidEmail(email)) - if (invalidEmails.length > 0) { - throw new KeeperSdkError(`Invalid email(s): ${invalidEmails.join(', ')}`, 'invalid_email') - } + const { userEmails, teamIdentifiers } = splitShareTargets(targets) + const teamUids = await resolveTeamUids(auth, storage, teamIdentifiers) if (input.action === ShareFolderAction.Remove) { - return removeFromSharedFolder(auth, sharedFolder, resolved, emails) + return removeFromSharedFolder(auth, sharedFolder, resolved, userEmails, teamUids) } const existingMembers = new Set() @@ -294,25 +488,53 @@ async function shareWithSharedFolder( } } - const newEmails = emails.filter((email) => !existingMembers.has(email)) + const existingTeams = new Set() + const existingTeamByUid = new Map() + for (const sharedFolderTeam of storage.getAll(VaultObjectKind.SharedFolderTeam)) { + if (sharedFolderTeam.sharedFolderUid === sharedFolder.uid && sharedFolderTeam.teamUid) { + existingTeams.add(sharedFolderTeam.teamUid) + existingTeamByUid.set(sharedFolderTeam.teamUid, sharedFolderTeam) + } + } + + const newEmails = userEmails.filter((email) => !existingMembers.has(email)) + const newTeamUids = teamUids.filter((teamUid) => !existingTeams.has(teamUid)) const usernameToKeys = await fetchUserPublicKeys(auth, newEmails) + const teamKeysByUid = await loadTeamKeys(auth, newTeamUids) const usersToAdd: Folder.ISharedFolderUpdateUser[] = [] const usersToUpdate: Folder.ISharedFolderUpdateUser[] = [] + const teamsToAdd: Folder.ISharedFolderUpdateTeam[] = [] + const teamsToUpdate: Folder.ISharedFolderUpdateTeam[] = [] const userResults: ShareFolderUserStatus[] = [] const newUserManageRecords = isBoolean(input.manageRecords) ? input.manageRecords : sharedFolder.defaultManageRecords const newUserManageUsers = isBoolean(input.manageUsers) ? input.manageUsers : sharedFolder.defaultManageUsers + const hasPermissionChange = isBoolean(input.manageRecords) || isBoolean(input.manageUsers) + const newTeamManageRecords = isBoolean(input.manageRecords) + ? input.manageRecords + : sharedFolder.defaultManageRecords === true + const newTeamManageUsers = isBoolean(input.manageUsers) + ? input.manageUsers + : sharedFolder.defaultManageUsers === true - for (const email of emails) { + for (const email of userEmails) { if (existingMembers.has(email)) { - usersToUpdate.push({ - username: email, - manageRecords: toSetBoolean(input.manageRecords), - manageUsers: toSetBoolean(input.manageUsers), - }) + if (hasPermissionChange) { + usersToUpdate.push({ + username: email, + manageRecords: toSetBoolean(input.manageRecords), + manageUsers: toSetBoolean(input.manageUsers), + }) + } else { + userResults.push({ + email, + success: true, + status: FolderResultStatus.Success, + }) + } continue } @@ -363,13 +585,64 @@ async function shareWithSharedFolder( }) } - if (usersToAdd.length === 0 && usersToUpdate.length === 0) { + for (const teamUid of teamUids) { + if (existingTeams.has(teamUid)) { + if (hasPermissionChange) { + const existingTeam = existingTeamByUid.get(teamUid) + teamsToUpdate.push({ + teamUid: normal64Bytes(teamUid), + manageRecords: isBoolean(input.manageRecords) + ? input.manageRecords + : existingTeam?.manageRecords === true, + manageUsers: isBoolean(input.manageUsers) ? input.manageUsers : existingTeam?.manageUsers === true, + }) + } else { + userResults.push({ + email: teamUid, + success: true, + status: FolderResultStatus.Success, + }) + } + continue + } + + const teamKeys = teamKeysByUid.get(teamUid) + const typedSharedFolderKey = teamKeys + ? await encryptSharedFolderKeyForTeam(sharedFolderKey, teamKeys) + : undefined + if (!typedSharedFolderKey) { + userResults.push({ + email: teamUid, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No usable team key for "${teamUid}" (folder="${resolved.displayName}")`, + }) + continue + } + + teamsToAdd.push({ + teamUid: normal64Bytes(teamUid), + manageRecords: newTeamManageRecords, + manageUsers: newTeamManageUsers, + typedSharedFolderKey, + }) + } + + if ( + usersToAdd.length === 0 && + usersToUpdate.length === 0 && + teamsToAdd.length === 0 && + teamsToUpdate.length === 0 + ) { + const allOk = userResults.length > 0 && userResults.every((userResult) => userResult.success) return { - success: false, + success: allOk, folderUid: resolved.folderUid, sharedFolderUid: sharedFolder.uid, folderKind: FolderKind.SharedFolder, - message: `No users could be processed for shared folder "${resolved.displayName}" (uid=${sharedFolder.uid}).`, + message: allOk + ? undefined + : `No users or teams could be processed for shared folder "${resolved.displayName}" (uid=${sharedFolder.uid}).`, results: userResults, } } @@ -381,6 +654,8 @@ async function shareWithSharedFolder( } if (usersToAdd.length > 0) updateRequest.sharedFolderAddUser = usersToAdd if (usersToUpdate.length > 0) updateRequest.sharedFolderUpdateUser = usersToUpdate + if (teamsToAdd.length > 0) updateRequest.sharedFolderAddTeam = teamsToAdd + if (teamsToUpdate.length > 0) updateRequest.sharedFolderUpdateTeam = teamsToUpdate let response: Folder.ISharedFolderUpdateV3ResponseV2 try { @@ -416,8 +691,50 @@ async function shareWithSharedFolder( status, }) } + for (const addTeamStatus of innerResponse?.sharedFolderAddTeamStatus || []) { + const status = addTeamStatus.status || ShareFolderUserResultStatus.Unknown + userResults.push({ + email: teamUidFromStatus(addTeamStatus.teamUid as Uint8Array), + success: status === FolderResultStatus.Success, + status, + }) + } + for (const updateTeamStatus of innerResponse?.sharedFolderUpdateTeamStatus || []) { + const status = updateTeamStatus.status || ShareFolderUserResultStatus.Unknown + userResults.push({ + email: teamUidFromStatus(updateTeamStatus.teamUid as Uint8Array), + success: status === FolderResultStatus.Success, + status, + }) + } + + if (requestOk) { + const reported = new Set(userResults.map((userResult) => (userResult.email || '').toLowerCase())) + for (const user of [...usersToAdd, ...usersToUpdate]) { + const email = (user.username || '').trim() + const key = email.toLowerCase() + if (!key || reported.has(key)) continue + userResults.push({ + email, + success: true, + status: FolderResultStatus.Success, + }) + reported.add(key) + } + for (const team of [...teamsToAdd, ...teamsToUpdate]) { + const email = teamUidFromStatus(team.teamUid as Uint8Array) + const key = email.toLowerCase() + if (!key || reported.has(key)) continue + userResults.push({ + email, + success: true, + status: FolderResultStatus.Success, + }) + reported.add(key) + } + } - const allUsersOk = userResults.length > 0 && userResults.every((userResult) => userResult.success) + const allUsersOk = userResults.length === 0 ? requestOk : userResults.every((userResult) => userResult.success) const failureReason = !requestOk ? innerResponse?.status || @@ -453,3 +770,322 @@ export async function shareFolder( return shareWithSharedFolder(auth, storage, resolved, input) } + +export type SharedFolderMembershipUserGrant = { + email: string + manageUsers?: boolean + manageRecords?: boolean +} + +export type SharedFolderMembershipTeamGrant = { + teamUid: string + manageUsers?: boolean + manageRecords?: boolean +} + +export type UpdateSharedFolderMembershipInput = { + sharedFolderUid: string + addUsers?: SharedFolderMembershipUserGrant[] + updateUsers?: SharedFolderMembershipUserGrant[] + removeUsers?: string[] + addTeams?: SharedFolderMembershipTeamGrant[] + updateTeams?: SharedFolderMembershipTeamGrant[] + removeTeams?: string[] +} + +export type SharedFolderMembershipUpdateResult = { + success: boolean + message?: string + sharedFolderUid: string + results: ShareFolderUserStatus[] +} + +/** + * Low-level shared-folder membership primitive used by both `shareFolder` + * and `applyMembership`: a single `shared_folder_update_v3` request that can + * add, update, and remove users/teams (each with its own permission flags) + * in one round trip. + */ +export async function updateSharedFolderMembership( + auth: Auth, + storage: InMemoryStorage, + input: UpdateSharedFolderMembershipInput +): Promise { + const sharedFolder = storage.getByUid(FolderKind.SharedFolder, input.sharedFolderUid) + if (!sharedFolder) { + throw new KeeperSdkError(`Shared folder "${input.sharedFolderUid}" not found.`, 'shared_folder_not_found') + } + + const addUsers = input.addUsers || [] + const updateUsers = input.updateUsers || [] + const removeUsers = dedupeTargets(input.removeUsers || []) + const addTeams = input.addTeams || [] + const updateTeams = input.updateTeams || [] + const removeTeams = [...new Set((input.removeTeams || []).map((uid) => uid.trim()).filter(Boolean))] + + const results: ShareFolderUserStatus[] = [] + + let sharedFolderKey: Uint8Array | undefined + if (addUsers.length > 0 || addTeams.length > 0) { + sharedFolderKey = await storage.getKeyBytes(sharedFolder.uid) + if (!sharedFolderKey) { + throw new KeeperSdkError( + 'Shared folder encryption key not available. Sync the vault and try again.', + 'shared_folder_key_missing' + ) + } + } + + const usersToAdd: Folder.ISharedFolderUpdateUser[] = [] + const usersToUpdate: Folder.ISharedFolderUpdateUser[] = [] + const teamsToAdd: Folder.ISharedFolderUpdateTeam[] = [] + const teamsToUpdate: Folder.ISharedFolderUpdateTeam[] = [] + + if (addUsers.length > 0) { + const usernameToKeys = await fetchUserPublicKeys( + auth, + addUsers.map((grant) => grant.email) + ) + for (const grant of addUsers) { + const publicKeys = usernameToKeys.get(grant.email.toLowerCase()) + if (!publicKeys) { + results.push({ + email: grant.email, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No public key returned for user "${grant.email}"`, + }) + continue + } + if (publicKeys.errorCode) { + results.push({ + email: grant.email, + success: false, + status: publicKeys.errorCode, + message: publicKeys.message || publicKeys.errorCode, + }) + continue + } + + let encryptedKey: Uint8Array + let encryptedKeyType: Folder.EncryptedKeyType + if (publicKeys.rsaPublicKey) { + encryptedKey = platform.publicEncrypt(sharedFolderKey!, platform.bytesToBase64(publicKeys.rsaPublicKey)) + encryptedKeyType = Folder.EncryptedKeyType.encrypted_by_public_key + } else if (publicKeys.eccPublicKey) { + encryptedKey = await platform.publicEncryptEC(sharedFolderKey!, publicKeys.eccPublicKey) + encryptedKeyType = Folder.EncryptedKeyType.encrypted_by_public_key_ecc + } else { + results.push({ + email: grant.email, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No usable public key for user "${grant.email}"`, + }) + continue + } + + usersToAdd.push({ + username: grant.email, + manageRecords: toSetBoolean(grant.manageRecords), + manageUsers: toSetBoolean(grant.manageUsers), + typedSharedFolderKey: { encryptedKey, encryptedKeyType }, + }) + } + } + + for (const grant of updateUsers) { + usersToUpdate.push({ + username: grant.email, + manageRecords: toSetBoolean(grant.manageRecords), + manageUsers: toSetBoolean(grant.manageUsers), + }) + } + + if (addTeams.length > 0) { + const teamKeysByUid = await loadTeamKeys( + auth, + addTeams.map((grant) => grant.teamUid) + ) + for (const grant of addTeams) { + const teamKeys = teamKeysByUid.get(grant.teamUid) + const typedSharedFolderKey = teamKeys + ? await encryptSharedFolderKeyForTeam(sharedFolderKey!, teamKeys) + : undefined + if (!typedSharedFolderKey) { + results.push({ + email: grant.teamUid, + success: false, + status: ShareFolderUserResultStatus.MissingPublicKey, + message: `No usable team key for "${grant.teamUid}"`, + }) + continue + } + teamsToAdd.push({ + teamUid: normal64Bytes(grant.teamUid), + manageRecords: grant.manageRecords === true, + manageUsers: grant.manageUsers === true, + typedSharedFolderKey, + }) + } + } + + for (const grant of updateTeams) { + teamsToUpdate.push({ + teamUid: normal64Bytes(grant.teamUid), + manageRecords: grant.manageRecords === true, + manageUsers: grant.manageUsers === true, + }) + } + + const hasServerChange = + usersToAdd.length > 0 || + usersToUpdate.length > 0 || + removeUsers.length > 0 || + teamsToAdd.length > 0 || + teamsToUpdate.length > 0 || + removeTeams.length > 0 + + if (!hasServerChange) { + const allOk = results.length === 0 || results.every((result) => result.success) + return { success: allOk, sharedFolderUid: sharedFolder.uid, results } + } + + const updateRequest: Folder.ISharedFolderUpdateV3Request = { + sharedFolderUid: normal64Bytes(sharedFolder.uid), + revision: sharedFolder.revision, + forceUpdate: false, + } + if (usersToAdd.length > 0) updateRequest.sharedFolderAddUser = usersToAdd + if (usersToUpdate.length > 0) updateRequest.sharedFolderUpdateUser = usersToUpdate + if (removeUsers.length > 0) updateRequest.sharedFolderRemoveUser = removeUsers + if (teamsToAdd.length > 0) updateRequest.sharedFolderAddTeam = teamsToAdd + if (teamsToUpdate.length > 0) updateRequest.sharedFolderUpdateTeam = teamsToUpdate + if (removeTeams.length > 0) { + updateRequest.sharedFolderRemoveTeam = removeTeams.map((teamUid) => normal64Bytes(teamUid)) + } + + let response: Folder.ISharedFolderUpdateV3ResponseV2 + try { + response = await auth.executeRest(sharedFolderUpdateV3Message({ sharedFoldersUpdateV3: [updateRequest] })) + } catch (err) { + return { + success: false, + sharedFolderUid: sharedFolder.uid, + message: `shared_folder_update_v3 failed for shared folder (uid=${sharedFolder.uid}): ${extractErrorMessage(err)}`, + results, + } + } + + const innerResponse = (response.sharedFoldersUpdateV3Response || [])[0] + const requestOk = !innerResponse?.status || innerResponse.status === FolderResultStatus.Success + + for (const status of innerResponse?.sharedFolderAddUserStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown + results.push({ + email: status.username || '', + success: value === FolderResultStatus.Success || value === FolderResultStatus.Invited, + status: value, + }) + } + for (const status of innerResponse?.sharedFolderUpdateUserStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown + results.push({ + email: status.username || '', + success: value === FolderResultStatus.Success, + status: value, + }) + } + for (const status of innerResponse?.sharedFolderRemoveUserStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown + results.push({ + email: status.username || '', + success: value === FolderResultStatus.Success, + status: value, + }) + } + for (const status of innerResponse?.sharedFolderAddTeamStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown + results.push({ + email: teamUidFromStatus(status.teamUid as Uint8Array), + success: value === FolderResultStatus.Success, + status: value, + }) + } + for (const status of innerResponse?.sharedFolderUpdateTeamStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown + results.push({ + email: teamUidFromStatus(status.teamUid as Uint8Array), + success: value === FolderResultStatus.Success, + status: value, + }) + } + for (const status of innerResponse?.sharedFolderRemoveTeamStatus || []) { + const value = status.status || ShareFolderUserResultStatus.Unknown + results.push({ + email: teamUidFromStatus(status.teamUid as Uint8Array), + success: value === FolderResultStatus.Success, + status: value, + }) + } + + if (requestOk) { + const reported = new Set(results.map((result) => (result.email || '').toLowerCase())) + for (const user of [...usersToAdd, ...usersToUpdate]) { + const email = (user.username || '').trim() + const key = email.toLowerCase() + if (!key || reported.has(key)) continue + results.push({ + email, + success: true, + status: FolderResultStatus.Success, + }) + reported.add(key) + } + for (const team of [...teamsToAdd, ...teamsToUpdate]) { + const email = teamUidFromStatus(team.teamUid as Uint8Array) + const key = email.toLowerCase() + if (!key || reported.has(key)) continue + results.push({ + email, + success: true, + status: FolderResultStatus.Success, + }) + reported.add(key) + } + for (const email of removeUsers) { + const key = email.toLowerCase() + if (!key || reported.has(key)) continue + results.push({ + email, + success: true, + status: FolderResultStatus.Success, + }) + reported.add(key) + } + for (const teamUid of removeTeams) { + const key = teamUid.toLowerCase() + if (!key || reported.has(key)) continue + results.push({ + email: teamUid, + success: true, + status: FolderResultStatus.Success, + }) + reported.add(key) + } + } + + const allOk = results.length === 0 ? requestOk : results.every((result) => result.success) + + const failureReason = !requestOk + ? innerResponse?.status || + `shared_folder_update_v3 failed for shared folder (uid=${sharedFolder.uid}): server returned no status` + : undefined + + return { + success: requestOk && allOk, + sharedFolderUid: sharedFolder.uid, + message: failureReason, + results, + } +} 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/teams/TeamManager.ts b/KeeperSdk/src/teams/TeamManager.ts index d846bb05..372c28b7 100644 --- a/KeeperSdk/src/teams/TeamManager.ts +++ b/KeeperSdk/src/teams/TeamManager.ts @@ -42,6 +42,7 @@ import { type DeleteTeamResult, type FormattedDeleteTeamTable, } from './deleteTeam' +import { changeTeamRoles, type ChangeTeamRolesInput, type TeamRoleResult } from './teamRole' export type AuthProvider = () => Auth @@ -112,6 +113,10 @@ export class TeamManager { return renderDeleteTeamAsciiTable(table) } + public async changeTeamRoles(input: ChangeTeamRolesInput): Promise { + return changeTeamRoles(this.requireAuth(), input) + } + private requireAuth(): Auth { const auth = this.authProvider() if (!auth) { diff --git a/KeeperSdk/src/teams/addTeam.ts b/KeeperSdk/src/teams/addTeam.ts index af105f95..ca2b0123 100644 --- a/KeeperSdk/src/teams/addTeam.ts +++ b/KeeperSdk/src/teams/addTeam.ts @@ -50,6 +50,8 @@ export enum AddTeamStatus { export enum AddTeamSkipReason { AlreadyExistsInParent = 'already_exists_in_parent', ExistsElsewhereDeclined = 'exists_elsewhere_declined', + NotQueued = 'not_queued', + AlreadyActive = 'already_active', } export enum AddTeamSourceKind { @@ -77,6 +79,8 @@ export type AddTeamInput = { restrictView?: TeamRestrictionInput force?: boolean confirm?: AddTeamConfirm + /** When true, only promote queued teams (SCIM/Bridge); do not create new teams. */ + approveQueued?: boolean } export type AddTeamItemResult = { @@ -138,6 +142,7 @@ export async function addTeams(auth: Auth, input: AddTeamInput): Promise 0) { + items.push({ + teamUid: conflicts[0]!.team_uid, + teamName: conflicts[0]!.name || raw, + nodeId: conflicts[0]!.node_id, + source: AddTeamSourceKind.NewName, + status: AddTeamStatus.Skipped, + skipReason: AddTeamSkipReason.AlreadyActive, + message: `Team "${conflicts[0]!.name || raw}" already exists (not queued).`, + }) + } else { + items.push({ + teamUid: '', + teamName: raw, + nodeId: parentNodeId, + source: AddTeamSourceKind.NewName, + status: AddTeamStatus.Failed, + skipReason: AddTeamSkipReason.NotQueued, + message: `Team "${raw}" is not in the approval queue.`, + }) + } + continue + } + const conflicts = teamsByLowerName.get(lower) || [] const sameParent = conflicts.find((team) => team.node_id === parentNodeId) if (sameParent) { @@ -246,8 +277,9 @@ export async function addTeams(auth: Auth, input: AddTeamInput): Promise getDisplayNames(): Promise getTreeKey(): Promise + getRoleKey(roleId: number): Promise decryptNodeNames(nodes: EnterpriseNode[]): Promise clearCache(): void } @@ -181,6 +183,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { private displayNamesPromise: Promise | null = null private treeKeyPromise: Promise | null = null private readonly dataCache = new Map>() + private readonly roleKeyCache = new Map() constructor(auth: Auth) { this.auth = auth @@ -247,6 +250,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { this.dataCache.clear() this.displayNamesPromise = null this.treeKeyPromise = null + this.roleKeyCache.clear() } public static getNodePath(nodes: EnterpriseNode[], nodeId: number, options: NodePathOptions = {}): string { @@ -343,6 +347,59 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { return this.treeKeyPromise } + private sessionUnwrapKeys(): SessionUnwrapKeys { + return { + dataKey: this.auth.dataKey, + privateKey: this.auth.privateKey, + eccPrivateKey: this.auth.eccPrivateKey, + } + } + + public async getRoleKey(roleId: number): Promise { + if (this.roleKeyCache.has(roleId)) { + return this.roleKeyCache.get(roleId) ?? null + } + + let response: Enterprise.IGetEnterpriseDataKeysResponse + try { + response = await this.auth.executeRest(getEnterpriseDataKeysMessage({ roleId: [roleId] })) + } catch (err) { + logger.debug(`enterprise/get_enterprise_data_keys (role ${roleId}) failed: ${extractErrorMessage(err)}`) + this.roleKeyCache.set(roleId, null) + return null + } + + const treeKey = await this.getTreeKey() + for (const rKey of response.reEncryptedRoleKey || []) { + if (rKey.roleId !== roleId || !rKey.encryptedRoleKey || rKey.encryptedRoleKey.length === 0) continue + if (!treeKey) continue + try { + const roleKey = await platform.aesGcmDecrypt(rKey.encryptedRoleKey, treeKey) + this.roleKeyCache.set(roleId, roleKey) + return roleKey + } catch (err) { + logger.debug(`Failed to decrypt re-encrypted role key for role ${roleId}: ${extractErrorMessage(err)}`) + } + } + + const unwrapKeys = this.sessionUnwrapKeys() + for (const rKey of response.roleKey || []) { + if (rKey.roleId !== roleId || !rKey.encryptedKey) continue + try { + const roleKey = await decryptByKeyType(rKey.encryptedKey, rKey.keyType ?? undefined, unwrapKeys) + if (roleKey) { + this.roleKeyCache.set(roleId, roleKey) + return roleKey + } + } catch (err) { + logger.debug(`Failed to decrypt role key for role ${roleId}: ${extractErrorMessage(err)}`) + } + } + + this.roleKeyCache.set(roleId, null) + return null + } + private async fetchAndDecryptTreeKey(): Promise { let response: Enterprise.IGetEnterpriseDataKeysResponse try { @@ -365,33 +422,18 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { private async decryptTreeKey(treeKey: Enterprise.ITreeKey): Promise { if (!treeKey.treeKey) return null - const encrypted = normal64Bytes(treeKey.treeKey) - const keyType = (treeKey.keyTypeId ?? 0) as Enterprise.BackupKeyType - try { - switch (keyType) { - case Enterprise.BackupKeyType.ENCRYPTED_BY_DATA_KEY: { - if (!this.auth.dataKey) return null - return await platform.aesCbcDecrypt(encrypted, this.auth.dataKey, true) - } - case Enterprise.BackupKeyType.ENCRYPTED_BY_DATA_KEY_GCM: { - if (!this.auth.dataKey) return null - return await platform.aesGcmDecrypt(encrypted, this.auth.dataKey) - } - case Enterprise.BackupKeyType.ENCRYPTED_BY_PUBLIC_KEY: { - if (!this.auth.privateKey) return null - return platform.privateDecrypt(encrypted, this.auth.privateKey) - } - case Enterprise.BackupKeyType.ENCRYPTED_BY_PUBLIC_KEY_ECC: { - if (!this.auth.eccPrivateKey) return null - return await platform.privateDecryptEC(encrypted, this.auth.eccPrivateKey) - } - default: - logger.debug(`Unsupported tree-key keyTypeId=${keyType}`) - return null + const decrypted = await decryptByKeyType( + treeKey.treeKey, + treeKey.keyTypeId ?? undefined, + this.sessionUnwrapKeys() + ) + if (!decrypted) { + logger.debug(`Unsupported or unavailable tree-key keyTypeId=${treeKey.keyTypeId}`) } + return decrypted } catch (err) { - logger.debug(`Tree-key decryption failed (keyTypeId=${keyType}): ${extractErrorMessage(err)}`) + logger.debug(`Tree-key decryption failed (keyTypeId=${treeKey.keyTypeId}): ${extractErrorMessage(err)}`) return null } } @@ -437,6 +479,7 @@ export class EnterpriseDataManager implements EnterpriseDataManagerApi { } if (message.parentId != null) node.parent_id = EnterpriseDataManager.toNumber(message.parentId) if (message.encryptedData) node.encrypted_data = message.encryptedData + if (message.restrictVisibility != null) node.restrict_visibility = message.restrictVisibility return node } diff --git a/KeeperSdk/src/teams/listTeams.ts b/KeeperSdk/src/teams/listTeams.ts index 57688938..1ce1b917 100644 --- a/KeeperSdk/src/teams/listTeams.ts +++ b/KeeperSdk/src/teams/listTeams.ts @@ -26,12 +26,7 @@ export type TeamColumnInput = TeamColumn | `${TeamColumn}` export const SUPPORTED_TEAM_COLUMNS: readonly TeamColumn[] = Object.values(TeamColumn) -export const DEFAULT_TEAM_COLUMNS: readonly TeamColumn[] = [ - TeamColumn.Restricts, - TeamColumn.Node, - TeamColumn.UserCount, - TeamColumn.RoleCount, -] +export const DEFAULT_TEAM_COLUMNS: readonly TeamColumn[] = [] const NODE_PATH_SEPARATOR = '\\' const MIN_ASCII_COL_WIDTH = 2 @@ -54,6 +49,8 @@ export type ListTeamsOptions = { export type ListTeamRow = { team_uid: string name: string + /** Enterprise / company name (Commander list-team "Company" column). */ + company?: string restricts?: string node?: string user_count?: number @@ -69,6 +66,7 @@ export type FormattedTeamsTable = { export type FormatTeamsTableOptions = { columns?: ListTeamsOptions['columns'] + usersColumnTitle?: string } type DecorateContext = { @@ -115,11 +113,13 @@ export async function listTeams(auth: Auth, options: ListTeamsOptions = {}): Pro } const pattern = options.pattern?.trim() || null + const company = (response.enterprise_name || '').trim() const rows: ListTeamRow[] = [] for (const team of teams) { const row: ListTeamRow = { team_uid: team.team_uid, name: teamDisplayName(team), + company, } decorateRow(row, team, columns, context) if (pattern && !rowMatchesPattern(row, pattern)) continue @@ -132,10 +132,17 @@ export async function listTeams(auth: Auth, options: ListTeamsOptions = {}): Pro export function formatTeamsTable(rows: ListTeamRow[], options: FormatTeamsTableOptions = {}): FormattedTeamsTable { const columns = resolveColumns(options.columns) - const headers: string[] = ['#', 'Team UID', 'Name', ...columns.map((column) => HEADER_BY_COLUMN[column])] + const usersTitle = options.usersColumnTitle?.trim() || HEADER_BY_COLUMN[TeamColumn.Users] + const headers: string[] = [ + '#', + 'Company', + 'Team UID', + 'Name', + ...columns.map((column) => (column === TeamColumn.Users ? usersTitle : HEADER_BY_COLUMN[column])), + ] const outRows: string[][] = rows.map((row, rowIndex) => { - const cells: string[] = [String(rowIndex + 1), row.team_uid, row.name] + const cells: string[] = [String(rowIndex + 1), row.company ?? '', row.team_uid, row.name] for (const column of columns) cells.push(formatCell(row, column)) return cells }) @@ -230,6 +237,7 @@ function rowMatchesPattern(row: ListTeamRow, pattern: string): boolean { const lowered = pattern.toLowerCase() const tokens: string[] = [] tokens.push(row.team_uid.toLowerCase()) + if (row.company) tokens.push(...tokenize(row.company.toLowerCase())) tokens.push(...tokenize(row.name.toLowerCase())) if (row.restricts) tokens.push(row.restricts.toLowerCase()) if (row.node) tokens.push(...tokenize(row.node.toLowerCase())) diff --git a/KeeperSdk/src/teams/teamRole.ts b/KeeperSdk/src/teams/teamRole.ts new file mode 100644 index 00000000..65cf6d3c --- /dev/null +++ b/KeeperSdk/src/teams/teamRole.ts @@ -0,0 +1,201 @@ +import { type Auth, createInMessage, Enterprise, normal64Bytes } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseRole, + type EnterpriseRoleTeamLink, +} from './enterpriseData' +import { applyDecryptedRoleNames, resolveExistingRoles } from '../roles/roleUtils' +import { resolveExistingTeams } from './teamUtils' + +const TEAM_ROLE_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.Roles, + EnterpriseDataInclude.RoleTeams, +] + +export type ChangeTeamRolesInput = { + teams: string[] + addRoles?: string[] + removeRoles?: string[] +} + +export enum TeamRoleStatus { + Added = 'added', + Removed = 'removed', + Skipped = 'skipped', + Failed = 'failed', +} + +export type TeamRoleItemResult = { + status: TeamRoleStatus + roleName: string + roleId: number + teamName: string + teamUid: string + message?: string +} + +export type TeamRoleResult = { + success: boolean + items: TeamRoleItemResult[] + succeeded: number + failed: number + skipped: number +} + +type ResolvedTeam = { team_uid: string; name: string } + +async function loadTeamRoleContext(auth: Auth, teamIds: string[]) { + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData(TEAM_ROLE_INCLUDES), + enterpriseData.getDisplayNames(), + ]) + const roles = response.roles || [] + applyDecryptedRoleNames(roles, displayNames.roles) + const teams = resolveExistingTeams(response.teams || [], teamIds) + return { teams, allRoles: roles, roleTeams: response.role_teams || [] } +} + +function existingRoleTeamLinks(roleTeams: EnterpriseRoleTeamLink[], teamUid: string): Set { + const ids = new Set() + for (const link of roleTeams) { + if (link.team_uid === teamUid) ids.add(link.role_id) + } + return ids +} + +function expandRemoveRoles( + removeRoles: string[], + roleTeams: EnterpriseRoleTeamLink[], + teamUid: string, + allRoles: EnterpriseRole[] +): EnterpriseRole[] { + if (removeRoles.some((role) => role.trim().toLowerCase() === '@all')) { + const ids = existingRoleTeamLinks(roleTeams, teamUid) + return allRoles.filter((role) => ids.has(role.role_id)) + } + return resolveExistingRoles(allRoles, removeRoles) +} + +async function sendRoleTeamBatch( + auth: Auth, + links: Array<{ roleId: number; teamUid: string }>, + add: boolean +): Promise { + if (links.length === 0) return + const payload = Enterprise.RoleTeams.create({ + roleTeam: links.map((link) => + Enterprise.RoleTeam.create({ + roleId: link.roleId, + teamUid: normal64Bytes(link.teamUid), + }) + ), + }) + const path = add ? 'enterprise/role_team_add' : 'enterprise/role_team_remove' + const message = createInMessage(payload, path, Enterprise.RoleTeams) + await auth.executeRestAction(message) +} + +function roleDisplayName(role: { role_id: number; displayName?: string }): string { + return (role.displayName || '').trim() || String(role.role_id) +} + +export async function changeTeamRoles(auth: Auth, input: ChangeTeamRolesInput): Promise { + const teamIds = (input.teams || []).map((t) => t.trim()).filter(Boolean) + const addRoleIds = (input.addRoles || []).map((r) => r.trim()).filter(Boolean) + const removeRoleIds = (input.removeRoles || []).map((r) => r.trim()).filter(Boolean) + + if (teamIds.length === 0) { + throw new KeeperSdkError('No teams specified.', ResultCodes.NO_TEAMS_TO_UPDATE) + } + if (addRoleIds.length === 0 && removeRoleIds.length === 0) { + throw new KeeperSdkError('No roles specified.', ResultCodes.ROLE_NOT_FOUND) + } + + const ctx = await loadTeamRoleContext(auth, teamIds) + const items: TeamRoleItemResult[] = [] + const toAdd: Array<{ roleId: number; teamUid: string }> = [] + const toRemove: Array<{ roleId: number; teamUid: string }> = [] + + for (const team of ctx.teams as ResolvedTeam[]) { + const teamName = team.name || team.team_uid + const linkedRoleIds = existingRoleTeamLinks(ctx.roleTeams, team.team_uid) + + if (addRoleIds.length > 0) { + const rolesToAdd = resolveExistingRoles(ctx.allRoles, addRoleIds) + for (const role of rolesToAdd) { + const base = { + roleName: roleDisplayName(role), + roleId: role.role_id, + teamName, + teamUid: team.team_uid, + } + if (linkedRoleIds.has(role.role_id)) { + items.push({ + ...base, + status: TeamRoleStatus.Skipped, + message: 'Role is already assigned to team', + }) + continue + } + toAdd.push({ roleId: role.role_id, teamUid: team.team_uid }) + items.push({ ...base, status: TeamRoleStatus.Added }) + } + } + + if (removeRoleIds.length > 0) { + const rolesToRemove = expandRemoveRoles(removeRoleIds, ctx.roleTeams, team.team_uid, ctx.allRoles) + for (const role of rolesToRemove) { + const base = { + roleName: roleDisplayName(role), + roleId: role.role_id, + teamName, + teamUid: team.team_uid, + } + if (!linkedRoleIds.has(role.role_id)) { + items.push({ + ...base, + status: TeamRoleStatus.Skipped, + message: 'Role is not assigned to team', + }) + continue + } + toRemove.push({ roleId: role.role_id, teamUid: team.team_uid }) + items.push({ ...base, status: TeamRoleStatus.Removed }) + } + } + } + + try { + await sendRoleTeamBatch(auth, toRemove, false) + await sendRoleTeamBatch(auth, toAdd, true) + } catch (err) { + const message = extractErrorMessage(err) + for (const item of items) { + if (item.status === TeamRoleStatus.Added || item.status === TeamRoleStatus.Removed) { + item.status = TeamRoleStatus.Failed + item.message = message + } + } + } + + let succeeded = 0 + let skipped = 0 + let failed = 0 + for (const item of items) { + if (item.status === TeamRoleStatus.Added || item.status === TeamRoleStatus.Removed) succeeded++ + else if (item.status === TeamRoleStatus.Skipped) skipped++ + else failed++ + } + + return { + success: failed === 0 && succeeded > 0, + items, + succeeded, + skipped, + failed, + } +} diff --git a/KeeperSdk/src/users/UserManager.ts b/KeeperSdk/src/users/UserManager.ts index 042e088f..3670bea5 100644 --- a/KeeperSdk/src/users/UserManager.ts +++ b/KeeperSdk/src/users/UserManager.ts @@ -6,6 +6,7 @@ import { deleteUsers, formatDeleteUserResult, renderDeleteUserAsciiTable } from import { actionUsers, formatUserActionResult, renderUserActionAsciiTable } from './actionUser' import { aliasUser } from './aliasUser' import { addUsersToTeams, removeUsersFromTeams, formatTeamUserResult, renderTeamUserAsciiTable } from './teamUser' +import { updateUsersOnTeams, formatUpdateTeamUserResult, renderUpdateTeamUserAsciiTable } from './updateTeamUser' import { listUsers, formatUsersTable, renderUsersAsciiTable } from './listUsers' import { viewUser, formatUserView, userViewTable } from './viewUser' import type { @@ -37,6 +38,7 @@ import type { TeamUserResult, FormattedTeamUserTable, } from './userTypes' +import type { UpdateUsersOnTeamsInput, UpdateUsersOnTeamsResult, FormattedUpdateTeamUserTable } from './updateTeamUser' export type AuthProvider = () => Auth @@ -139,6 +141,18 @@ export class UserManager { return renderTeamUserAsciiTable(table) } + public async updateUsersOnTeams(input: UpdateUsersOnTeamsInput): Promise { + return updateUsersOnTeams(this.requireAuth(), input) + } + + public formatUpdateTeamUserResult(result: UpdateUsersOnTeamsResult): FormattedUpdateTeamUserTable { + return formatUpdateTeamUserResult(result) + } + + public renderUpdateTeamUserAsciiTable(table: FormattedUpdateTeamUserTable): string { + return renderUpdateTeamUserAsciiTable(table) + } + private requireAuth(): Auth { const auth = this.authProvider() if (!auth) { diff --git a/KeeperSdk/src/users/actionUser.ts b/KeeperSdk/src/users/actionUser.ts index 00de3f8f..7a95b269 100644 --- a/KeeperSdk/src/users/actionUser.ts +++ b/KeeperSdk/src/users/actionUser.ts @@ -1,4 +1,5 @@ import { + createInMessage, Enterprise, enterpriseUsersLockMessage, type Auth, @@ -23,8 +24,13 @@ export { UserAction, UserActionStatus, UserActionSkipReason } export type { UserActionInput, UserActionItemResult, UserActionResult, FormattedUserActionTable } const EXPIRE_PASSWORD_COMMAND = 'set_master_password_expire' +const EXTEND_TRANSFER_COMMAND = 'extend_account_share_expiration' const ALL_USERS_SENTINEL = '@all' -const ACTIONS_NOT_SUPPORTING_ALL = new Set([UserAction.Lock, UserAction.ExpirePassword]) +const ACTIONS_NOT_SUPPORTING_ALL = new Set([ + UserAction.Lock, + UserAction.ExpirePassword, + UserAction.Disable2fa, +]) const LOCK_UNLOCK_BATCH_SIZE = 1000 const SELF_ACTION_MESSAGE = 'This operation cannot be done on yourself.' @@ -36,6 +42,10 @@ type ExpirePasswordPayload = { email: string } +type ExtendTransferPayload = { + enterprise_user_id: number +} + type ActionUserTarget = { kind: 'user'; user: EnterpriseUser } | { kind: 'not_found'; identifier: string } export async function actionUsers(auth: Auth, input: UserActionInput): Promise { @@ -65,6 +75,7 @@ export async function actionUsers(auth: Auth, input: UserActionInput): Promise() + const disable2faTargets = new Map() for (const target of targets) { if (target.kind === 'not_found') { @@ -107,8 +118,17 @@ export async function actionUsers(auth: Auth, input: UserActionInput): Promise { } } +async function sendExtendTransferConsent(auth: Auth, enterpriseUserId: number): Promise { + const command: RestCommand = { + baseRequest: { command: EXTEND_TRANSFER_COMMAND }, + request: { enterprise_user_id: enterpriseUserId }, + authorization: {}, + } + const response = await auth.executeRestCommand(command) + const result = (response.result || '').toLowerCase() + if (result && result !== 'success') { + throw new KeeperSdkError( + response.message || + response.result_code || + `${EXTEND_TRANSFER_COMMAND} failed for user id ${enterpriseUserId}`, + response.result_code || ResultCodes.USER_ACTION_FAILED + ) + } +} + +async function sendDisableTwoFa(auth: Auth, targets: Map): Promise { + if (targets.size === 0) { + return + } + + const enterpriseUserIds = [...targets.keys()] + try { + await auth.executeRestAction( + createInMessage( + { enterpriseUserId: enterpriseUserIds }, + 'enterprise/disable_two_fa', + Enterprise.EnterpriseUserIds + ) + ) + for (const item of targets.values()) { + item.status = UserActionStatus.Success + } + } catch (err) { + const message = extractErrorMessage(err) + for (const item of targets.values()) { + item.status = UserActionStatus.Failed + item.message = message + } + } +} + function chunkArray(values: T[], size: number): T[][] { const chunks: T[][] = [] for (let index = 0; index < values.length; index += size) { diff --git a/KeeperSdk/src/users/updateTeamUser.ts b/KeeperSdk/src/users/updateTeamUser.ts new file mode 100644 index 00000000..8177b71e --- /dev/null +++ b/KeeperSdk/src/users/updateTeamUser.ts @@ -0,0 +1,201 @@ +import { Enterprise, teamEnterpriseUserUpdateCommand, type Auth } from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../utils' +import { + EnterpriseDataInclude, + EnterpriseDataManager, + type EnterpriseTeamRecord, + type EnterpriseTeamUserLink, + type EnterpriseUser, +} from '../teams/enterpriseData' +import { resolveExistingTeams } from '../teams/teamUtils' +import { normalizeEmailInputs, resolveExistingUsers } from './userTypes' + +const UPDATE_TEAM_USER_INCLUDES: EnterpriseDataInclude[] = [ + EnterpriseDataInclude.Users, + EnterpriseDataInclude.Teams, + EnterpriseDataInclude.TeamUsers, +] + +const UPDATE_TEAM_USER_TABLE_HEADERS = ['#', 'Status', 'User Email', 'User ID', 'Team Name', 'Team UID', 'Detail'] + +export enum UpdateTeamUserStatus { + Updated = 'updated', + Skipped = 'skipped', + Failed = 'failed', +} + +export enum UpdateTeamUserSkipReason { + NotMember = 'not_member', +} + +export type UpdateUsersOnTeamsInput = { + users: string[] + teams: string[] + hideSharedFolders: boolean +} + +export type UpdateTeamUserItemResult = { + username: string + enterpriseUserId: number + teamUid: string + teamName: string + status: UpdateTeamUserStatus + skipReason?: UpdateTeamUserSkipReason + message?: string +} + +export type UpdateUsersOnTeamsResult = { + success: boolean + items: UpdateTeamUserItemResult[] + updated: number + skipped: number + failed: number +} + +export type FormattedUpdateTeamUserTable = { + headers: string[] + rows: string[][] + summary: string +} + +type ResolvedTeam = Pick + +const membershipKey = (userId: number, teamUid: string): string => `${userId}:${teamUid}` + +function buildMembershipSet(teamUsers: EnterpriseTeamUserLink[] | undefined): Set { + const membership = new Set() + for (const link of teamUsers || []) { + membership.add(membershipKey(link.enterprise_user_id, link.team_uid)) + } + return membership +} + +function normalizeTeamIdentifiers(teams: string[] | undefined): string[] { + return (teams || []).map((t) => t.trim()).filter((t) => t.length > 0) +} + +export async function updateUsersOnTeams( + auth: Auth, + input: UpdateUsersOnTeamsInput +): Promise { + const emails = normalizeEmailInputs(input.users) + if (emails.length === 0) { + throw new KeeperSdkError('No users provided.', ResultCodes.NO_USERS_TO_UPDATE) + } + const teamIdentifiers = normalizeTeamIdentifiers(input.teams) + if (teamIdentifiers.length === 0) { + throw new KeeperSdkError('No teams provided.', ResultCodes.NO_TEAMS_FOR_USER_OP) + } + if (input.hideSharedFolders === undefined || input.hideSharedFolders === null) { + throw new KeeperSdkError('hideSharedFolders (on/off) is required.', ResultCodes.HIDE_SHARED_FOLDERS_REQUIRED) + } + + const userType = input.hideSharedFolders ? Enterprise.TeamUserType.ADMIN_ONLY : Enterprise.TeamUserType.USER + + const enterpriseData = new EnterpriseDataManager(auth) + const response = await enterpriseData.getData(UPDATE_TEAM_USER_INCLUDES) + + const teams: ResolvedTeam[] = resolveExistingTeams(response.teams || [], teamIdentifiers).map((team) => ({ + team_uid: team.team_uid, + name: team.name, + })) + const users: EnterpriseUser[] = resolveExistingUsers(response.users || [], emails) + const membership = buildMembershipSet(response.team_users) + + const items: UpdateTeamUserItemResult[] = [] + for (const team of teams) { + for (const user of users) { + const base = { + username: user.username, + enterpriseUserId: user.enterprise_user_id, + teamUid: team.team_uid, + teamName: team.name, + } + + if (!membership.has(membershipKey(user.enterprise_user_id, team.team_uid))) { + items.push({ + ...base, + status: UpdateTeamUserStatus.Skipped, + skipReason: UpdateTeamUserSkipReason.NotMember, + }) + continue + } + + try { + const response = await auth.executeRestCommand( + teamEnterpriseUserUpdateCommand({ + team_uid: team.team_uid, + enterprise_user_id: user.enterprise_user_id, + user_type: userType, + }) + ) + const result = (response.result || '').toLowerCase() + if (result && result !== 'success') { + throw new KeeperSdkError( + response.message || + response.result_code || + `team_enterprise_user_update failed for user=${user.username}, team=${team.team_uid}`, + response.result_code || ResultCodes.TEAM_ENTERPRISE_USER_UPDATE_FAILED + ) + } + items.push({ ...base, status: UpdateTeamUserStatus.Updated }) + } catch (err) { + items.push({ + ...base, + status: UpdateTeamUserStatus.Failed, + message: extractErrorMessage(err), + }) + } + } + } + + return finalizeResult(items) +} + +function finalizeResult(items: UpdateTeamUserItemResult[]): UpdateUsersOnTeamsResult { + const updated = items.filter((item) => item.status === UpdateTeamUserStatus.Updated).length + const skipped = items.filter((item) => item.status === UpdateTeamUserStatus.Skipped).length + const failed = items.filter((item) => item.status === UpdateTeamUserStatus.Failed).length + return { + success: failed === 0 && updated > 0, + items, + updated, + skipped, + failed, + } +} + +export function formatUpdateTeamUserResult(result: UpdateUsersOnTeamsResult): FormattedUpdateTeamUserTable { + const rows = result.items.map((item, index) => [ + String(index + 1), + item.status, + item.username, + String(item.enterpriseUserId), + item.teamName, + item.teamUid, + item.message || item.skipReason || '', + ]) + return { + headers: [...UPDATE_TEAM_USER_TABLE_HEADERS], + rows, + summary: `Updated: ${result.updated} Skipped: ${result.skipped} Failed: ${result.failed}`, + } +} + +export function renderUpdateTeamUserAsciiTable(table: FormattedUpdateTeamUserTable): string { + const { headers, rows } = table + const widths = headers.map((header, columnIndex) => + Math.max(header.length, ...rows.map((row) => (row[columnIndex] || '').length)) + ) + const padCell = (cell: string, columnIndex: number): string => + cell + ' '.repeat(Math.max(0, widths[columnIndex] - cell.length)) + const formatRow = (cells: string[]): string => + cells.map((cell, columnIndex) => padCell(cell, columnIndex)).join(' ') + + return [ + formatRow(headers), + formatRow(widths.map((w) => '-'.repeat(w))), + ...rows.map(formatRow), + table.summary, + ].join('\n') +} diff --git a/KeeperSdk/src/users/updateUser.ts b/KeeperSdk/src/users/updateUser.ts index fe25f499..dc6d5f1c 100644 --- a/KeeperSdk/src/users/updateUser.ts +++ b/KeeperSdk/src/users/updateUser.ts @@ -52,7 +52,7 @@ type UserUpdatePayload = { enterprise_user_id: number enterprise_user_username: string node_id: number - encrypted_data?: string + encrypted_data: string full_name?: string job_title?: string } @@ -117,18 +117,17 @@ export async function updateUsers(auth: Auth, input: UpdateUserInput): Promise - 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..cd1c2fb2 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,7 +25,27 @@ export { EMAIL_LIST_SEPARATOR_PATTERN, TOKEN_SEPARATOR_PATTERN, REGEX_ESCAPE_PATTERN, + TRAILING_EQUALS_PATTERN, + WHITESPACE_PATTERN, isValidEmail, escapeRegExp, resolveSearchPattern, } from './patterns' +export { + DEFAULT_PASSWORD_LENGTH, + PW_SPECIAL_CHARACTERS, + GEN_PASSWORD_ALGORITHMS, + KeeperPasswordGenerator, + generatePasswordFromOptions, + resolveGenPasswordAlgorithm, + generatePassword, + parseGenParametersFromValue, + isGenerateFieldValue, + parseGeneratePasswordFlag, +} from './passwordGenerator' +export type { + GenPasswordAlgorithm, + PasswordGenerationOptions, + PasswordComplexityPolicy, + PassphraseGenOptions, +} from './passwordGenerator' diff --git a/KeeperSdk/src/utils/passwordGenerator.ts b/KeeperSdk/src/utils/passwordGenerator.ts new file mode 100644 index 00000000..0d31afa1 --- /dev/null +++ b/KeeperSdk/src/utils/passwordGenerator.ts @@ -0,0 +1,557 @@ +/** + * Password generation — random passwords match Keeper .NET + * `CryptoUtils.GeneratePassword` (keeper-sdk-dotnet). + * Passphrase/dice/crypto/$GEN helpers align with Commander `generator.py`. + */ + +import { BIP39_WORDS, DICEWARE_WORDS } from './resources/wordlists' + +/** Matches `CryptoUtils.SpecialCharacters` in keeper-sdk-dotnet. */ +export const PW_SPECIAL_CHARACTERS = '!@#$%()+;<>=?[]{}^.,' + +export const DEFAULT_PASSWORD_LENGTH = 20 +export const GEN_PASSWORD_ALGORITHMS = ['rand', 'dice', 'crypto', 'passphrase'] as const + +export type GenPasswordAlgorithm = (typeof GEN_PASSWORD_ALGORITHMS)[number] + +/** + * Matches `PasswordGenerationOptions` in keeper-sdk-dotnet. + * Use -1 for upper/lower/digit/special to exclude that character class. + */ +export type PasswordGenerationOptions = { + length?: number + lower?: number + upper?: number + digit?: number + special?: number + specialCharacters?: string +} + +export type PasswordComplexityPolicy = { + length?: number + 'lower-use'?: boolean + 'lower-min'?: number + 'upper-use'?: boolean + 'upper-min'?: number + 'digit-use'?: boolean + 'digit-min'?: number + 'special-use'?: boolean + 'special-min'?: number + special?: string + 'passphrase-allow'?: boolean + 'passphrase-length'?: number + 'passphrase-separator'?: string +} + +const PP_SEPARATOR_CHARACTERS = '-._?! ' +const DEFAULT_PASSPHRASE_SEPARATOR = '-' +const DEFAULT_PASSPHRASE_WORD_COUNT = 5 +const MIN_PASSPHRASE_WORD_COUNT = 5 +const MAX_PASSPHRASE_WORD_COUNT = 9 +const LETTER_COUNT = 'z'.charCodeAt(0) - 'a'.charCodeAt(0) + 1 + +export type PassphraseGenOptions = { + wordCount: number | null + separator: string | null + capitalize: boolean | null + appendNumber: boolean | null +} + +function randomBytes(length: number): Uint8Array { + const buf = new Uint8Array(length) + crypto.getRandomValues(buf) + return buf +} + +function randomInt(max: number): number { + if (max <= 0) return 0 + const buf = new Uint32Array(1) + crypto.getRandomValues(buf) + return buf[0] % max +} + +/** Matches `CryptoUtils.Shuffle` (keeper-sdk-dotnet). */ +function shuffleInPlace(array: T[]): void { + if (!array || array.length < 2) return + const bigArray = array.length > 255 + const randoms = randomBytes(array.length * (bigArray ? 4 : 1)) + for (let i = array.length - 1; i >= 0; i--) { + let random: number + if (bigArray) { + const offset = i * 4 + random = + ((randoms[offset] | + (randoms[offset + 1] << 8) | + (randoms[offset + 2] << 16) | + (randoms[offset + 3] << 24)) >>> + 0) & + 0x7fffffff + } else { + random = randoms[i] + } + const j = random % array.length + if (i !== j) { + const ch = array[i] + array[i] = array[j] + array[j] = ch + } + } +} + +/** + * Generates a random password using Keeper vault rules. + * Port of `CryptoUtils.GeneratePassword` (keeper-sdk-dotnet). + */ +export function generatePasswordFromOptions(options?: PasswordGenerationOptions | null): string { + let length = options?.length ?? 20 + let upper = options?.upper ?? 4 + let lower = options?.lower ?? 4 + let digit = options?.digit ?? 2 + let special = options?.special ?? -1 + + if (length <= 0) { + length = 20 + } + + if (upper < 0 && lower < 0 && digit < 0 && special < 0) { + lower = length + } + + let required = Math.max(upper, 0) + Math.max(lower, 0) + Math.max(digit, 0) + Math.max(special, 0) + let extra = required - length + if (extra > 0) { + let left = extra + if (lower > 0) { + let toSubtract = Math.ceil((lower / required) * extra) + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract) + lower -= toSubtract + left -= toSubtract + } + } + if (left > 0 && upper > 0) { + let toSubtract = Math.ceil((upper / required) * extra) + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract) + upper -= toSubtract + left -= toSubtract + } + } + if (left > 0 && digit > 0) { + let toSubtract = Math.ceil((digit / required) * extra) + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract) + digit -= toSubtract + left -= toSubtract + } + } + if (left > 0 && special > 0) { + let toSubtract = Math.ceil((special / required) * extra) + if (toSubtract > 0) { + toSubtract = Math.min(left, toSubtract) + special -= toSubtract + left -= toSubtract + } + } + } + + required = Math.max(upper, 0) + Math.max(lower, 0) + Math.max(digit, 0) + Math.max(special, 0) + extra = length - required + while (extra > 0) { + if (extra > 0 && lower >= 0) { + lower++ + extra-- + } + if (extra > 0 && upper >= 0) { + upper++ + extra-- + } + if (extra > 0 && digit >= 0) { + digit++ + extra-- + } + if (extra > 0 && special >= 0) { + special++ + extra-- + } + } + + const buffer = new Array(length) + const indexes = Array.from({ length }, (_, i) => i) + shuffleInPlace(indexes) + const randoms = randomBytes(length) + const specialCharacters = + options?.specialCharacters && options.specialCharacters.length > 0 + ? options.specialCharacters + : PW_SPECIAL_CHARACTERS + + for (const pos of indexes) { + if (upper > 0) { + buffer[pos] = String.fromCharCode('A'.charCodeAt(0) + (randoms[pos] % LETTER_COUNT)) + upper-- + } else if (lower > 0) { + buffer[pos] = String.fromCharCode('a'.charCodeAt(0) + (randoms[pos] % LETTER_COUNT)) + lower-- + } else if (digit > 0) { + buffer[pos] = String.fromCharCode('0'.charCodeAt(0) + (randoms[pos] % 10)) + digit-- + } else if (special > 0) { + buffer[pos] = specialCharacters[randoms[pos] % specialCharacters.length] + special-- + } else { + buffer[pos] = String.fromCharCode('a'.charCodeAt(0) + (randoms[pos] % LETTER_COUNT)) + } + } + + shuffleInPlace(buffer) + return buffer.join('') +} + +function policyToGenerationOptions( + policy: PasswordComplexityPolicy, + lengthOverride?: number | null +): PasswordGenerationOptions { + const opts: PasswordGenerationOptions = { + length: lengthOverride ?? policy.length, + specialCharacters: policy.special, + } + if (policy['lower-use'] === false) opts.lower = -1 + else if (policy['lower-min'] !== undefined) opts.lower = policy['lower-min'] + if (policy['upper-use'] === false) opts.upper = -1 + else if (policy['upper-min'] !== undefined) opts.upper = policy['upper-min'] + if (policy['digit-use'] === false) opts.digit = -1 + else if (policy['digit-min'] !== undefined) opts.digit = policy['digit-min'] + if (policy['special-use'] === false) opts.special = -1 + else if (policy['special-min'] !== undefined) opts.special = policy['special-min'] + return opts +} + +/** Thin wrapper over {@link generatePasswordFromOptions} for CLI / legacy callers. */ +export class KeeperPasswordGenerator { + private readonly options: PasswordGenerationOptions + + constructor(lengthOrOptions: number | PasswordGenerationOptions = DEFAULT_PASSWORD_LENGTH) { + this.options = typeof lengthOrOptions === 'number' ? { length: lengthOrOptions } : { ...lengthOrOptions } + } + + generate(): string { + return generatePasswordFromOptions(this.options) + } + + static createFromPolicy(policy: PasswordComplexityPolicy, lengthOverride?: number | null): KeeperPasswordGenerator { + return new KeeperPasswordGenerator(policyToGenerationOptions(policy, lengthOverride)) + } +} + +function clampPassphraseWordCount(wordCount: number | null | undefined): number { + if (typeof wordCount !== 'number') return DEFAULT_PASSPHRASE_WORD_COUNT + if (wordCount < MIN_PASSPHRASE_WORD_COUNT) return MIN_PASSPHRASE_WORD_COUNT + if (wordCount > MAX_PASSPHRASE_WORD_COUNT) return MAX_PASSPHRASE_WORD_COUNT + return wordCount +} + +class KeeperPassphraseGenerator { + private readonly wordCount: number + private readonly separator: string + private readonly capitalize: boolean + private readonly appendNumber: boolean + private readonly vocabulary: readonly string[] + + constructor( + wordCount: number, + separator: string, + capitalize: boolean, + appendNumber: boolean, + vocabulary: readonly string[] + ) { + this.wordCount = wordCount + this.separator = separator + this.capitalize = capitalize + this.appendNumber = appendNumber + this.vocabulary = vocabulary + } + + generate(): string { + let passphrase = '' + for (let i = 0; i < this.wordCount; i++) { + let word = this.vocabulary[randomInt(this.vocabulary.length)] + if (this.capitalize && word) word = word[0].toUpperCase() + word.slice(1) + if (this.appendNumber && i === 0) word += String(randomInt(10)) + if (i > 0) passphrase += this.separator + passphrase += word + } + return passphrase + } + + static createWithOptions( + policy: PasswordComplexityPolicy | null | undefined, + options: PassphraseGenOptions + ): KeeperPassphraseGenerator { + const wordCount = clampPassphraseWordCount( + options.wordCount ?? policy?.['passphrase-length'] ?? DEFAULT_PASSPHRASE_WORD_COUNT + ) + const separator = + options.separator ?? + (policy?.['passphrase-separator']?.trim() + ? policy['passphrase-separator'].replace(/\u2423/g, ' ')[0] || DEFAULT_PASSPHRASE_SEPARATOR + : DEFAULT_PASSPHRASE_SEPARATOR) + const capitalize = options.capitalize ?? true + const appendNumber = options.appendNumber ?? true + return new KeeperPassphraseGenerator(wordCount, separator, capitalize, appendNumber, DICEWARE_WORDS) + } +} + +class DicewarePasswordGenerator { + private readonly rolls: number + private readonly vocabulary: readonly string[] + private readonly delimiter: string + + constructor(rolls: number, vocabulary: readonly string[], delimiter = ' ') { + this.rolls = rolls + this.vocabulary = vocabulary + this.delimiter = delimiter + } + + generate(): string { + const words: string[] = [] + for (let i = 0; i < this.rolls; i++) words.push(this.vocabulary[randomInt(this.vocabulary.length)]) + shuffleInPlace(words) + return words.join(this.delimiter) + } +} + +class CryptoPassphraseGenerator { + generate(): string { + const key = randomBytes(32) + const digest = new Uint8Array(syncSha256(key)) + const secretBytes = new Uint8Array(33) + secretBytes.set(key) + secretBytes[32] = digest[0] + let secret = BigInt(0) + for (const b of secretBytes) secret = (secret << BigInt(8)) | BigInt(b) + const indices: number[] = [] + for (let i = 0; i < 24; i++) { + indices.push(Number(secret & BigInt(0x7ff))) + secret >>= BigInt(11) + } + indices.reverse() + return indices.map((idx) => BIP39_WORDS[idx]).join(' ') + } +} + +function syncSha256(data: Uint8Array): Uint8Array { + if (typeof process !== 'undefined' && process.versions?.node) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { createHash } = require('crypto') as typeof import('crypto') + return new Uint8Array(createHash('sha256').update(data).digest()) + } + throw new Error('crypto passphrase generation requires Node.js (use rand or passphrase instead)') +} + +export function resolveGenPasswordAlgorithm(parameters?: readonly string[] | null): { + algorithm: GenPasswordAlgorithm | null + error: string | null +} { + if (!parameters || parameters.length === 0) return { algorithm: 'rand', error: null } + const first = parameters[0].trim() + const firstLower = first.toLowerCase() + if ((GEN_PASSWORD_ALGORITHMS as readonly string[]).includes(firstLower)) { + return { algorithm: firstLower as GenPasswordAlgorithm, error: null } + } + if (/^\d+$/.test(first)) return { algorithm: 'rand', error: null } + return { + algorithm: null, + error: `Unknown $GEN password algorithm "${first}". Valid algorithms: ${GEN_PASSWORD_ALGORITHMS.join(', ')}.`, + } +} + +function parsePassphraseGenParameters(parameters: readonly string[]): { + options: PassphraseGenOptions + error: string | null +} { + const empty: PassphraseGenOptions = { + wordCount: null, + separator: null, + capitalize: null, + appendNumber: null, + } + if (!parameters.length || parameters[0].trim().toLowerCase() !== 'passphrase') { + return { options: empty, error: null } + } + const extras = parameters.slice(1) + if (extras.some((t) => t.trim() === '')) { + return { + options: empty, + error: + 'Incomplete $GEN:passphrase parameters: missing value after comma. ' + + 'Format: $GEN:passphrase[,word_count][,separator][,capitalize][,number]', + } + } + let wordCount: number | null = null + let separator: string | null = null + let capitalize: boolean | null = null + let appendNumber: boolean | null = null + let idx = 0 + if (idx < extras.length) { + const token = extras[idx].trim() + if (/^\d+$/.test(token)) { + wordCount = Number(token) + if (wordCount < MIN_PASSPHRASE_WORD_COUNT || wordCount > MAX_PASSPHRASE_WORD_COUNT) { + return { + options: empty, + error: `Passphrase word count must be between ${MIN_PASSPHRASE_WORD_COUNT} and ${MAX_PASSPHRASE_WORD_COUNT} (got ${wordCount}).`, + } + } + idx++ + } + } + if (idx < extras.length && !/^(true|false)$/i.test(extras[idx].trim())) { + const token = extras[idx].trim() + if (token.toLowerCase() === 'space' || token.toLowerCase() === 'sp') separator = ' ' + else if (token.length === 1 && PP_SEPARATOR_CHARACTERS.includes(token)) separator = token + else { + return { + options: empty, + error: `Invalid passphrase separator "${token}". Allowed: ${PP_SEPARATOR_CHARACTERS.replace(/ /g, 'space ')}.`, + } + } + idx++ + } + if (idx < extras.length) { + const token = extras[idx].trim().toLowerCase() + if (token === 'true') capitalize = true + else if (token === 'false') capitalize = false + else { + return { + options: empty, + error: `Invalid $GEN:passphrase capitalize parameter "${extras[idx]}". Expected true or false.`, + } + } + idx++ + } + if (idx < extras.length) { + const token = extras[idx].trim().toLowerCase() + if (token === 'true') appendNumber = true + else if (token === 'false') appendNumber = false + else { + return { + options: empty, + error: `Invalid $GEN:passphrase number parameter "${extras[idx]}". Expected true or false.`, + } + } + idx++ + } + if (idx < extras.length) { + return { + options: empty, + error: `Unexpected $GEN:passphrase parameter "${extras[idx].trim()}".`, + } + } + return { + options: { wordCount, separator, capitalize, appendNumber }, + error: null, + } +} + +function parseLength(parameters: readonly string[]): number | null { + const numeric = parameters.find((p) => /^\d+$/.test(p.trim())) + if (!numeric) return null + return Number(numeric) +} + +function randomPasswordOptions( + length: number | null, + policy?: PasswordComplexityPolicy | null +): PasswordGenerationOptions { + if (policy) { + return policyToGenerationOptions(policy, length) + } + const opts: PasswordGenerationOptions = {} + if (typeof length === 'number') { + let clamped = length + if (clamped < 4) clamped = 4 + if (clamped > 200) clamped = 200 + opts.length = clamped + } + return opts +} + +export function generatePassword( + parameters?: readonly string[] | null, + policy?: PasswordComplexityPolicy | null +): { password: string | null; error: string | null } { + const { algorithm, error: algError } = resolveGenPasswordAlgorithm(parameters) + if (algError) return { password: null, error: algError } + if (!algorithm) return { password: null, error: 'Unknown password generation algorithm.' } + + const params = parameters ?? [] + const length = parseLength(params) + + try { + if (algorithm === 'crypto') { + if (typeof process === 'undefined' || !process.versions?.node) { + return { + password: null, + error: '$GEN:crypto is not supported in the browser; use rand or passphrase.', + } + } + return { + password: new CryptoPassphraseGenerator().generate(), + error: null, + } + } + if (algorithm === 'passphrase') { + const { options, error: ppError } = parsePassphraseGenParameters(params) + if (ppError) return { password: null, error: ppError } + if (policy?.['passphrase-allow'] === false) { + return { + password: generatePasswordFromOptions(randomPasswordOptions(length, policy)), + error: null, + } + } + const gen = KeeperPassphraseGenerator.createWithOptions(policy, options) + return { password: gen.generate(), error: null } + } + if (algorithm === 'dice') { + let rolls = length ?? 5 + if (rolls < 1) rolls = 1 + if (rolls > 40) rolls = 40 + return { + password: new DicewarePasswordGenerator(rolls, DICEWARE_WORDS).generate(), + error: null, + } + } + + return { + password: generatePasswordFromOptions(randomPasswordOptions(length, policy)), + error: null, + } + } catch (e) { + return { + password: null, + error: e instanceof Error ? e.message : String(e), + } + } +} + +/** Parse `$GEN:rand,16` or `$GEN` into generation parameters. */ +export function parseGenParametersFromValue(value: string): string[] { + if (!value.startsWith('$GEN')) return [] + let rest = value.slice(4) + if (rest.startsWith(':')) rest = rest.slice(1) + if (!rest.trim()) return [] + return rest.split(',').map((part) => part.trim()) +} + +export function isGenerateFieldValue(value: string): boolean { + return value.startsWith('$GEN') +} + +/** Parse `--generate-password` or `--generate-password=rand,16` into $GEN parameters. */ +export function parseGeneratePasswordFlag(raw: string | true | undefined): string[] | null { + if (raw === undefined) return null + if (raw === true) return [] + const trimmed = raw.trim() + if (!trimmed) return [] + return trimmed.split(',').map((part) => part.trim()) +} diff --git a/KeeperSdk/src/utils/patterns.ts b/KeeperSdk/src/utils/patterns.ts index d65cc00f..fe15b54c 100644 --- a/KeeperSdk/src/utils/patterns.ts +++ b/KeeperSdk/src/utils/patterns.ts @@ -12,10 +12,14 @@ 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 +/** Sequence of one or more `=` characters at end of string (Base32 padding). */ +export const TRAILING_EQUALS_PATTERN = /=+$/g + +/** Any whitespace run. */ +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/utils/resources/wordlists.ts b/KeeperSdk/src/utils/resources/wordlists.ts new file mode 100644 index 00000000..5539fb75 --- /dev/null +++ b/KeeperSdk/src/utils/resources/wordlists.ts @@ -0,0 +1,9830 @@ +// Auto-generated from Commander keepercommander/resources word lists. +export const DICEWARE_WORDS: readonly string[] = [ + 'abacus', + 'abdomen', + 'abdominal', + 'abide', + 'abiding', + 'ability', + 'ablaze', + 'able', + 'abnormal', + 'abrasion', + 'abrasive', + 'abreast', + 'abridge', + 'abroad', + 'abruptly', + 'absence', + 'absentee', + 'absently', + 'absinthe', + 'absolute', + 'absolve', + 'abstain', + 'abstract', + 'absurd', + 'accent', + 'acclaim', + 'acclimate', + 'accompany', + 'account', + 'accuracy', + 'accurate', + 'accustom', + 'acetone', + 'achiness', + 'aching', + 'acid', + 'acorn', + 'acquaint', + 'acquire', + 'acre', + 'acrobat', + 'acronym', + 'acting', + 'action', + 'activate', + 'activator', + 'active', + 'activism', + 'activist', + 'activity', + 'actress', + 'acts', + 'acutely', + 'acuteness', + 'aeration', + 'aerobics', + 'aerosol', + 'aerospace', + 'afar', + 'affair', + 'affected', + 'affecting', + 'affection', + 'affidavit', + 'affiliate', + 'affirm', + 'affix', + 'afflicted', + 'affluent', + 'afford', + 'affront', + 'aflame', + 'afloat', + 'aflutter', + 'afoot', + 'afraid', + 'afterglow', + 'afterlife', + 'aftermath', + 'aftermost', + 'afternoon', + 'aged', + 'ageless', + 'agency', + 'agenda', + 'agent', + 'aggregate', + 'aghast', + 'agile', + 'agility', + 'aging', + 'agnostic', + 'agonize', + 'agonizing', + 'agony', + 'agreeable', + 'agreeably', + 'agreed', + 'agreeing', + 'agreement', + 'aground', + 'ahead', + 'ahoy', + 'aide', + 'aids', + 'aim', + 'ajar', + 'alabaster', + 'alarm', + 'albatross', + 'album', + 'alfalfa', + 'algebra', + 'algorithm', + 'alias', + 'alibi', + 'alienable', + 'alienate', + 'aliens', + 'alike', + 'alive', + 'alkaline', + 'alkalize', + 'almanac', + 'almighty', + 'almost', + 'aloe', + 'aloft', + 'aloha', + 'alone', + 'alongside', + 'aloof', + 'alphabet', + 'alright', + 'although', + 'altitude', + 'alto', + 'aluminum', + 'alumni', + 'always', + 'amaretto', + 'amaze', + 'amazingly', + 'amber', + 'ambiance', + 'ambiguity', + 'ambiguous', + 'ambition', + 'ambitious', + 'ambulance', + 'ambush', + 'amendable', + 'amendment', + 'amends', + 'amenity', + 'amiable', + 'amicably', + 'amid', + 'amigo', + 'amino', + 'amiss', + 'ammonia', + 'ammonium', + 'amnesty', + 'amniotic', + 'among', + 'amount', + 'amperage', + 'ample', + 'amplifier', + 'amplify', + 'amply', + 'amuck', + 'amulet', + 'amusable', + 'amused', + 'amusement', + 'amuser', + 'amusing', + 'anaconda', + 'anaerobic', + 'anagram', + 'anatomist', + 'anatomy', + 'anchor', + 'anchovy', + 'ancient', + 'android', + 'anemia', + 'anemic', + 'aneurism', + 'anew', + 'angelfish', + 'angelic', + 'anger', + 'angled', + 'angler', + 'angles', + 'angling', + 'angrily', + 'angriness', + 'anguished', + 'angular', + 'animal', + 'animate', + 'animating', + 'animation', + 'animator', + 'anime', + 'animosity', + 'ankle', + 'annex', + 'annotate', + 'announcer', + 'annoying', + 'annually', + 'annuity', + 'anointer', + 'another', + 'answering', + 'antacid', + 'antarctic', + 'anteater', + 'antelope', + 'antennae', + 'anthem', + 'anthill', + 'anthology', + 'antibody', + 'antics', + 'antidote', + 'antihero', + 'antiquely', + 'antiques', + 'antiquity', + 'antirust', + 'antitoxic', + 'antitrust', + 'antiviral', + 'antivirus', + 'antler', + 'antonym', + 'antsy', + 'anvil', + 'anybody', + 'anyhow', + 'anymore', + 'anyone', + 'anyplace', + 'anything', + 'anytime', + 'anyway', + 'anywhere', + 'aorta', + 'apache', + 'apostle', + 'appealing', + 'appear', + 'appease', + 'appeasing', + 'appendage', + 'appendix', + 'appetite', + 'appetizer', + 'applaud', + 'applause', + 'apple', + 'appliance', + 'applicant', + 'applied', + 'apply', + 'appointee', + 'appraisal', + 'appraiser', + 'apprehend', + 'approach', + 'approval', + 'approve', + 'apricot', + 'april', + 'apron', + 'aptitude', + 'aptly', + 'aqua', + 'aqueduct', + 'arbitrary', + 'arbitrate', + 'ardently', + 'area', + 'arena', + 'arguable', + 'arguably', + 'argue', + 'arise', + 'armadillo', + 'armband', + 'armchair', + 'armed', + 'armful', + 'armhole', + 'arming', + 'armless', + 'armoire', + 'armored', + 'armory', + 'armrest', + 'army', + 'aroma', + 'arose', + 'around', + 'arousal', + 'arrange', + 'array', + 'arrest', + 'arrival', + 'arrive', + 'arrogance', + 'arrogant', + 'arson', + 'art', + 'ascend', + 'ascension', + 'ascent', + 'ascertain', + 'ashamed', + 'ashen', + 'ashes', + 'ashy', + 'aside', + 'askew', + 'asleep', + 'asparagus', + 'aspect', + 'aspirate', + 'aspire', + 'aspirin', + 'astonish', + 'astound', + 'astride', + 'astrology', + 'astronaut', + 'astronomy', + 'astute', + 'atlantic', + 'atlas', + 'atom', + 'atonable', + 'atop', + 'atrium', + 'atrocious', + 'atrophy', + 'attach', + 'attain', + 'attempt', + 'attendant', + 'attendee', + 'attention', + 'attentive', + 'attest', + 'attic', + 'attire', + 'attitude', + 'attractor', + 'attribute', + 'atypical', + 'auction', + 'audacious', + 'audacity', + 'audible', + 'audibly', + 'audience', + 'audio', + 'audition', + 'augmented', + 'august', + 'authentic', + 'author', + 'autism', + 'autistic', + 'autograph', + 'automaker', + 'automated', + 'automatic', + 'autopilot', + 'available', + 'avalanche', + 'avatar', + 'avenge', + 'avenging', + 'avenue', + 'average', + 'aversion', + 'avert', + 'aviation', + 'aviator', + 'avid', + 'avoid', + 'await', + 'awaken', + 'award', + 'aware', + 'awhile', + 'awkward', + 'awning', + 'awoke', + 'awry', + 'axis', + 'babble', + 'babbling', + 'babied', + 'baboon', + 'backache', + 'backboard', + 'backboned', + 'backdrop', + 'backed', + 'backer', + 'backfield', + 'backfire', + 'backhand', + 'backing', + 'backlands', + 'backlash', + 'backless', + 'backlight', + 'backlit', + 'backlog', + 'backpack', + 'backpedal', + 'backrest', + 'backroom', + 'backshift', + 'backside', + 'backslid', + 'backspace', + 'backspin', + 'backstab', + 'backstage', + 'backtalk', + 'backtrack', + 'backup', + 'backward', + 'backwash', + 'backwater', + 'backyard', + 'bacon', + 'bacteria', + 'bacterium', + 'badass', + 'badge', + 'badland', + 'badly', + 'badness', + 'baffle', + 'baffling', + 'bagel', + 'bagful', + 'baggage', + 'bagged', + 'baggie', + 'bagginess', + 'bagging', + 'baggy', + 'bagpipe', + 'baguette', + 'baked', + 'bakery', + 'bakeshop', + 'baking', + 'balance', + 'balancing', + 'balcony', + 'balmy', + 'balsamic', + 'bamboo', + 'banana', + 'banish', + 'banister', + 'banjo', + 'bankable', + 'bankbook', + 'banked', + 'banker', + 'banking', + 'banknote', + 'bankroll', + 'banner', + 'bannister', + 'banshee', + 'banter', + 'barbecue', + 'barbed', + 'barbell', + 'barber', + 'barcode', + 'barge', + 'bargraph', + 'barista', + 'baritone', + 'barley', + 'barmaid', + 'barman', + 'barn', + 'barometer', + 'barrack', + 'barracuda', + 'barrel', + 'barrette', + 'barricade', + 'barrier', + 'barstool', + 'bartender', + 'barterer', + 'bash', + 'basically', + 'basics', + 'basil', + 'basin', + 'basis', + 'basket', + 'batboy', + 'batch', + 'bath', + 'baton', + 'bats', + 'battalion', + 'battered', + 'battering', + 'battery', + 'batting', + 'battle', + 'bauble', + 'bazooka', + 'blabber', + 'bladder', + 'blade', + 'blah', + 'blame', + 'blaming', + 'blanching', + 'blandness', + 'blank', + 'blaspheme', + 'blasphemy', + 'blast', + 'blatancy', + 'blatantly', + 'blazer', + 'blazing', + 'bleach', + 'bleak', + 'bleep', + 'blemish', + 'blend', + 'bless', + 'blighted', + 'blimp', + 'bling', + 'blinked', + 'blinker', + 'blinking', + 'blinks', + 'blip', + 'blissful', + 'blitz', + 'blizzard', + 'bloated', + 'bloating', + 'blob', + 'blog', + 'bloomers', + 'blooming', + 'blooper', + 'blot', + 'blouse', + 'blubber', + 'bluff', + 'bluish', + 'blunderer', + 'blunt', + 'blurb', + 'blurred', + 'blurry', + 'blurt', + 'blush', + 'blustery', + 'boaster', + 'boastful', + 'boasting', + 'boat', + 'bobbed', + 'bobbing', + 'bobble', + 'bobcat', + 'bobsled', + 'bobtail', + 'bodacious', + 'body', + 'bogged', + 'boggle', + 'bogus', + 'boil', + 'bok', + 'bolster', + 'bolt', + 'bonanza', + 'bonded', + 'bonding', + 'bondless', + 'boned', + 'bonehead', + 'boneless', + 'bonelike', + 'boney', + 'bonfire', + 'bonnet', + 'bonsai', + 'bonus', + 'bony', + 'boogeyman', + 'boogieman', + 'book', + 'boondocks', + 'booted', + 'booth', + 'bootie', + 'booting', + 'bootlace', + 'bootleg', + 'boots', + 'boozy', + 'borax', + 'boring', + 'borough', + 'borrower', + 'borrowing', + 'boss', + 'botanical', + 'botanist', + 'botany', + 'botch', + 'both', + 'bottle', + 'bottling', + 'bottom', + 'bounce', + 'bouncing', + 'bouncy', + 'bounding', + 'boundless', + 'bountiful', + 'bovine', + 'boxcar', + 'boxer', + 'boxing', + 'boxlike', + 'boxy', + 'breach', + 'breath', + 'breeches', + 'breeching', + 'breeder', + 'breeding', + 'breeze', + 'breezy', + 'brethren', + 'brewery', + 'brewing', + 'briar', + 'bribe', + 'brick', + 'bride', + 'bridged', + 'brigade', + 'bright', + 'brilliant', + 'brim', + 'bring', + 'brink', + 'brisket', + 'briskly', + 'briskness', + 'bristle', + 'brittle', + 'broadband', + 'broadcast', + 'broaden', + 'broadly', + 'broadness', + 'broadside', + 'broadways', + 'broiler', + 'broiling', + 'broken', + 'broker', + 'bronchial', + 'bronco', + 'bronze', + 'bronzing', + 'brook', + 'broom', + 'brought', + 'browbeat', + 'brownnose', + 'browse', + 'browsing', + 'bruising', + 'brunch', + 'brunette', + 'brunt', + 'brush', + 'brussels', + 'brute', + 'brutishly', + 'bubble', + 'bubbling', + 'bubbly', + 'buccaneer', + 'bucked', + 'bucket', + 'buckle', + 'buckshot', + 'buckskin', + 'bucktooth', + 'buckwheat', + 'buddhism', + 'buddhist', + 'budding', + 'buddy', + 'budget', + 'buffalo', + 'buffed', + 'buffer', + 'buffing', + 'buffoon', + 'buggy', + 'bulb', + 'bulge', + 'bulginess', + 'bulgur', + 'bulk', + 'bulldog', + 'bulldozer', + 'bullfight', + 'bullfrog', + 'bullhorn', + 'bullion', + 'bullish', + 'bullpen', + 'bullring', + 'bullseye', + 'bullwhip', + 'bully', + 'bunch', + 'bundle', + 'bungee', + 'bunion', + 'bunkbed', + 'bunkhouse', + 'bunkmate', + 'bunny', + 'bunt', + 'busboy', + 'bush', + 'busily', + 'busload', + 'bust', + 'busybody', + 'buzz', + 'cabana', + 'cabbage', + 'cabbie', + 'cabdriver', + 'cable', + 'caboose', + 'cache', + 'cackle', + 'cacti', + 'cactus', + 'caddie', + 'caddy', + 'cadet', + 'cadillac', + 'cadmium', + 'cage', + 'cahoots', + 'cake', + 'calamari', + 'calamity', + 'calcium', + 'calculate', + 'calculus', + 'caliber', + 'calibrate', + 'calm', + 'caloric', + 'calorie', + 'calzone', + 'camcorder', + 'cameo', + 'camera', + 'camisole', + 'camper', + 'campfire', + 'camping', + 'campsite', + 'campus', + 'canal', + 'canary', + 'cancel', + 'candied', + 'candle', + 'candy', + 'cane', + 'canine', + 'canister', + 'cannabis', + 'canned', + 'canning', + 'cannon', + 'cannot', + 'canola', + 'canon', + 'canopener', + 'canopy', + 'canteen', + 'canyon', + 'capable', + 'capably', + 'capacity', + 'cape', + 'capillary', + 'capital', + 'capitol', + 'capped', + 'capricorn', + 'capsize', + 'capsule', + 'caption', + 'captivate', + 'captive', + 'captivity', + 'capture', + 'caramel', + 'carat', + 'caravan', + 'carbon', + 'cardboard', + 'carded', + 'cardiac', + 'cardigan', + 'cardinal', + 'cardstock', + 'carefully', + 'caregiver', + 'careless', + 'caress', + 'caretaker', + 'cargo', + 'caring', + 'carless', + 'carload', + 'carmaker', + 'carnage', + 'carnation', + 'carnival', + 'carnivore', + 'carol', + 'carpenter', + 'carpentry', + 'carpool', + 'carport', + 'carried', + 'carrot', + 'carrousel', + 'carry', + 'cartel', + 'cartload', + 'carton', + 'cartoon', + 'cartridge', + 'cartwheel', + 'carve', + 'carving', + 'carwash', + 'cascade', + 'case', + 'cash', + 'casing', + 'casino', + 'casket', + 'cassette', + 'casually', + 'casualty', + 'catacomb', + 'catalog', + 'catalyst', + 'catalyze', + 'catapult', + 'cataract', + 'catatonic', + 'catcall', + 'catchable', + 'catcher', + 'catching', + 'catchy', + 'caterer', + 'catering', + 'catfight', + 'catfish', + 'cathedral', + 'cathouse', + 'catlike', + 'catnap', + 'catnip', + 'catsup', + 'cattail', + 'cattishly', + 'cattle', + 'catty', + 'catwalk', + 'caucasian', + 'caucus', + 'causal', + 'causation', + 'cause', + 'causing', + 'cauterize', + 'caution', + 'cautious', + 'cavalier', + 'cavalry', + 'caviar', + 'cavity', + 'cedar', + 'celery', + 'celestial', + 'celibacy', + 'celibate', + 'celtic', + 'cement', + 'census', + 'ceramics', + 'ceremony', + 'certainly', + 'certainty', + 'certified', + 'certify', + 'cesarean', + 'cesspool', + 'chafe', + 'chaffing', + 'chain', + 'chair', + 'chalice', + 'challenge', + 'chamber', + 'chamomile', + 'champion', + 'chance', + 'change', + 'channel', + 'chant', + 'chaos', + 'chaperone', + 'chaplain', + 'chapped', + 'chaps', + 'chapter', + 'character', + 'charbroil', + 'charcoal', + 'charger', + 'charging', + 'chariot', + 'charity', + 'charm', + 'charred', + 'charter', + 'charting', + 'chase', + 'chasing', + 'chaste', + 'chastise', + 'chastity', + 'chatroom', + 'chatter', + 'chatting', + 'chatty', + 'cheating', + 'cheddar', + 'cheek', + 'cheer', + 'cheese', + 'cheesy', + 'chef', + 'chemicals', + 'chemist', + 'chemo', + 'cherisher', + 'cherub', + 'chess', + 'chest', + 'chevron', + 'chevy', + 'chewable', + 'chewer', + 'chewing', + 'chewy', + 'chief', + 'chihuahua', + 'childcare', + 'childhood', + 'childish', + 'childless', + 'childlike', + 'chili', + 'chill', + 'chimp', + 'chip', + 'chirping', + 'chirpy', + 'chitchat', + 'chivalry', + 'chive', + 'chloride', + 'chlorine', + 'choice', + 'chokehold', + 'choking', + 'chomp', + 'chooser', + 'choosing', + 'choosy', + 'chop', + 'chosen', + 'chowder', + 'chowtime', + 'chrome', + 'chubby', + 'chuck', + 'chug', + 'chummy', + 'chump', + 'chunk', + 'churn', + 'chute', + 'cider', + 'cilantro', + 'cinch', + 'cinema', + 'cinnamon', + 'circle', + 'circling', + 'circular', + 'circulate', + 'circus', + 'citable', + 'citadel', + 'citation', + 'citizen', + 'citric', + 'citrus', + 'city', + 'civic', + 'civil', + 'clad', + 'claim', + 'clambake', + 'clammy', + 'clamor', + 'clamp', + 'clamshell', + 'clang', + 'clanking', + 'clapped', + 'clapper', + 'clapping', + 'clarify', + 'clarinet', + 'clarity', + 'clash', + 'clasp', + 'class', + 'clatter', + 'clause', + 'clavicle', + 'claw', + 'clay', + 'clean', + 'clear', + 'cleat', + 'cleaver', + 'cleft', + 'clench', + 'clergyman', + 'clerical', + 'clerk', + 'clever', + 'clicker', + 'client', + 'climate', + 'climatic', + 'cling', + 'clinic', + 'clinking', + 'clip', + 'clique', + 'cloak', + 'clobber', + 'clock', + 'clone', + 'cloning', + 'closable', + 'closure', + 'clothes', + 'clothing', + 'cloud', + 'clover', + 'clubbed', + 'clubbing', + 'clubhouse', + 'clump', + 'clumsily', + 'clumsy', + 'clunky', + 'clustered', + 'clutch', + 'clutter', + 'coach', + 'coagulant', + 'coastal', + 'coaster', + 'coasting', + 'coastland', + 'coastline', + 'coat', + 'coauthor', + 'cobalt', + 'cobbler', + 'cobweb', + 'cocoa', + 'coconut', + 'cod', + 'coeditor', + 'coerce', + 'coexist', + 'coffee', + 'cofounder', + 'cognition', + 'cognitive', + 'cogwheel', + 'coherence', + 'coherent', + 'cohesive', + 'coil', + 'coke', + 'cola', + 'cold', + 'coleslaw', + 'coliseum', + 'collage', + 'collapse', + 'collar', + 'collected', + 'collector', + 'collide', + 'collie', + 'collision', + 'colonial', + 'colonist', + 'colonize', + 'colony', + 'colossal', + 'colt', + 'coma', + 'come', + 'comfort', + 'comfy', + 'comic', + 'coming', + 'comma', + 'commence', + 'commend', + 'comment', + 'commerce', + 'commode', + 'commodity', + 'commodore', + 'common', + 'commotion', + 'commute', + 'commuting', + 'compacted', + 'compacter', + 'compactly', + 'compactor', + 'companion', + 'company', + 'compare', + 'compel', + 'compile', + 'comply', + 'component', + 'composed', + 'composer', + 'composite', + 'compost', + 'composure', + 'compound', + 'compress', + 'comprised', + 'computer', + 'computing', + 'comrade', + 'concave', + 'conceal', + 'conceded', + 'concept', + 'concerned', + 'concert', + 'conch', + 'concierge', + 'concise', + 'conclude', + 'concrete', + 'concur', + 'condense', + 'condiment', + 'condition', + 'condone', + 'conducive', + 'conductor', + 'conduit', + 'cone', + 'confess', + 'confetti', + 'confidant', + 'confident', + 'confider', + 'confiding', + 'configure', + 'confined', + 'confining', + 'confirm', + 'conflict', + 'conform', + 'confound', + 'confront', + 'confused', + 'confusing', + 'confusion', + 'congenial', + 'congested', + 'congrats', + 'congress', + 'conical', + 'conjoined', + 'conjure', + 'conjuror', + 'connected', + 'connector', + 'consensus', + 'consent', + 'console', + 'consoling', + 'consonant', + 'constable', + 'constant', + 'constrain', + 'constrict', + 'construct', + 'consult', + 'consumer', + 'consuming', + 'contact', + 'container', + 'contempt', + 'contend', + 'contented', + 'contently', + 'contents', + 'contest', + 'context', + 'contort', + 'contour', + 'contrite', + 'control', + 'contusion', + 'convene', + 'convent', + 'copartner', + 'cope', + 'copied', + 'copier', + 'copilot', + 'coping', + 'copious', + 'copper', + 'copy', + 'coral', + 'cork', + 'cornball', + 'cornbread', + 'corncob', + 'cornea', + 'corned', + 'corner', + 'cornfield', + 'cornflake', + 'cornhusk', + 'cornmeal', + 'cornstalk', + 'corny', + 'coronary', + 'coroner', + 'corporal', + 'corporate', + 'corral', + 'correct', + 'corridor', + 'corrode', + 'corroding', + 'corrosive', + 'corsage', + 'corset', + 'cortex', + 'cosigner', + 'cosmetics', + 'cosmic', + 'cosmos', + 'cosponsor', + 'cost', + 'cottage', + 'cotton', + 'couch', + 'cough', + 'could', + 'countable', + 'countdown', + 'counting', + 'countless', + 'country', + 'county', + 'courier', + 'covenant', + 'cover', + 'coveted', + 'coveting', + 'coyness', + 'cozily', + 'coziness', + 'cozy', + 'crabbing', + 'crabgrass', + 'crablike', + 'crabmeat', + 'cradle', + 'cradling', + 'crafter', + 'craftily', + 'craftsman', + 'craftwork', + 'crafty', + 'cramp', + 'cranberry', + 'crane', + 'cranial', + 'cranium', + 'crank', + 'crate', + 'crave', + 'craving', + 'crawfish', + 'crawlers', + 'crawling', + 'crayfish', + 'crayon', + 'crazed', + 'crazily', + 'craziness', + 'crazy', + 'creamed', + 'creamer', + 'creamlike', + 'crease', + 'creasing', + 'creatable', + 'create', + 'creation', + 'creative', + 'creature', + 'credible', + 'credibly', + 'credit', + 'creed', + 'creme', + 'creole', + 'crepe', + 'crept', + 'crescent', + 'crested', + 'cresting', + 'crestless', + 'crevice', + 'crewless', + 'crewman', + 'crewmate', + 'crib', + 'cricket', + 'cried', + 'crier', + 'crimp', + 'crimson', + 'cringe', + 'cringing', + 'crinkle', + 'crinkly', + 'crisped', + 'crisping', + 'crisply', + 'crispness', + 'crispy', + 'criteria', + 'critter', + 'croak', + 'crock', + 'crook', + 'croon', + 'crop', + 'cross', + 'crouch', + 'crouton', + 'crowbar', + 'crowd', + 'crown', + 'crucial', + 'crudely', + 'crudeness', + 'cruelly', + 'cruelness', + 'cruelty', + 'crumb', + 'crummiest', + 'crummy', + 'crumpet', + 'crumpled', + 'cruncher', + 'crunching', + 'crunchy', + 'crusader', + 'crushable', + 'crushed', + 'crusher', + 'crushing', + 'crust', + 'crux', + 'crying', + 'cryptic', + 'crystal', + 'cubbyhole', + 'cube', + 'cubical', + 'cubicle', + 'cucumber', + 'cuddle', + 'cuddly', + 'cufflink', + 'culinary', + 'culminate', + 'culpable', + 'culprit', + 'cultivate', + 'cultural', + 'culture', + 'cupbearer', + 'cupcake', + 'cupid', + 'cupped', + 'cupping', + 'curable', + 'curator', + 'curdle', + 'cure', + 'curfew', + 'curing', + 'curled', + 'curler', + 'curliness', + 'curling', + 'curly', + 'curry', + 'curse', + 'cursive', + 'cursor', + 'curtain', + 'curtly', + 'curtsy', + 'curvature', + 'curve', + 'curvy', + 'cushy', + 'cusp', + 'cussed', + 'custard', + 'custodian', + 'custody', + 'customary', + 'customer', + 'customize', + 'customs', + 'cut', + 'cycle', + 'cyclic', + 'cycling', + 'cyclist', + 'cylinder', + 'cymbal', + 'cytoplasm', + 'cytoplast', + 'dab', + 'dad', + 'daffodil', + 'dagger', + 'daily', + 'daintily', + 'dainty', + 'dairy', + 'daisy', + 'dallying', + 'dance', + 'dancing', + 'dandelion', + 'dander', + 'dandruff', + 'dandy', + 'danger', + 'dangle', + 'dangling', + 'daredevil', + 'dares', + 'daringly', + 'darkened', + 'darkening', + 'darkish', + 'darkness', + 'darkroom', + 'darling', + 'darn', + 'dart', + 'darwinism', + 'dash', + 'dastardly', + 'data', + 'datebook', + 'dating', + 'daughter', + 'daunting', + 'dawdler', + 'dawn', + 'daybed', + 'daybreak', + 'daycare', + 'daydream', + 'daylight', + 'daylong', + 'dayroom', + 'daytime', + 'dazzler', + 'dazzling', + 'deacon', + 'deafening', + 'deafness', + 'dealer', + 'dealing', + 'dealmaker', + 'dealt', + 'dean', + 'debatable', + 'debate', + 'debating', + 'debit', + 'debrief', + 'debtless', + 'debtor', + 'debug', + 'debunk', + 'decade', + 'decaf', + 'decal', + 'decathlon', + 'decay', + 'deceased', + 'deceit', + 'deceiver', + 'deceiving', + 'december', + 'decency', + 'decent', + 'deception', + 'deceptive', + 'decibel', + 'decidable', + 'decimal', + 'decimeter', + 'decipher', + 'deck', + 'declared', + 'decline', + 'decode', + 'decompose', + 'decorated', + 'decorator', + 'decoy', + 'decrease', + 'decree', + 'dedicate', + 'dedicator', + 'deduce', + 'deduct', + 'deed', + 'deem', + 'deepen', + 'deeply', + 'deepness', + 'deface', + 'defacing', + 'defame', + 'default', + 'defeat', + 'defection', + 'defective', + 'defendant', + 'defender', + 'defense', + 'defensive', + 'deferral', + 'deferred', + 'defiance', + 'defiant', + 'defile', + 'defiling', + 'define', + 'definite', + 'deflate', + 'deflation', + 'deflator', + 'deflected', + 'deflector', + 'defog', + 'deforest', + 'defraud', + 'defrost', + 'deftly', + 'defuse', + 'defy', + 'degraded', + 'degrading', + 'degrease', + 'degree', + 'dehydrate', + 'deity', + 'dejected', + 'delay', + 'delegate', + 'delegator', + 'delete', + 'deletion', + 'delicacy', + 'delicate', + 'delicious', + 'delighted', + 'delirious', + 'delirium', + 'deliverer', + 'delivery', + 'delouse', + 'delta', + 'deluge', + 'delusion', + 'deluxe', + 'demanding', + 'demeaning', + 'demeanor', + 'demise', + 'democracy', + 'democrat', + 'demote', + 'demotion', + 'demystify', + 'denatured', + 'deniable', + 'denial', + 'denim', + 'denote', + 'dense', + 'density', + 'dental', + 'dentist', + 'denture', + 'deny', + 'deodorant', + 'deodorize', + 'departed', + 'departure', + 'depict', + 'deplete', + 'depletion', + 'deplored', + 'deploy', + 'deport', + 'depose', + 'depraved', + 'depravity', + 'deprecate', + 'depress', + 'deprive', + 'depth', + 'deputize', + 'deputy', + 'derail', + 'deranged', + 'derby', + 'derived', + 'desecrate', + 'deserve', + 'deserving', + 'designate', + 'designed', + 'designer', + 'designing', + 'deskbound', + 'desktop', + 'deskwork', + 'desolate', + 'despair', + 'despise', + 'despite', + 'destiny', + 'destitute', + 'destruct', + 'detached', + 'detail', + 'detection', + 'detective', + 'detector', + 'detention', + 'detergent', + 'detest', + 'detonate', + 'detonator', + 'detoxify', + 'detract', + 'deuce', + 'devalue', + 'deviancy', + 'deviant', + 'deviate', + 'deviation', + 'deviator', + 'device', + 'devious', + 'devotedly', + 'devotee', + 'devotion', + 'devourer', + 'devouring', + 'devoutly', + 'dexterity', + 'dexterous', + 'diabetes', + 'diabetic', + 'diabolic', + 'diagnoses', + 'diagnosis', + 'diagram', + 'dial', + 'diameter', + 'diaper', + 'diaphragm', + 'diary', + 'dice', + 'dicing', + 'dictate', + 'dictation', + 'dictator', + 'difficult', + 'diffused', + 'diffuser', + 'diffusion', + 'diffusive', + 'dig', + 'dilation', + 'diligence', + 'diligent', + 'dill', + 'dilute', + 'dime', + 'diminish', + 'dimly', + 'dimmed', + 'dimmer', + 'dimness', + 'dimple', + 'diner', + 'dingbat', + 'dinghy', + 'dinginess', + 'dingo', + 'dingy', + 'dining', + 'dinner', + 'diocese', + 'dioxide', + 'diploma', + 'dipped', + 'dipper', + 'dipping', + 'directed', + 'direction', + 'directive', + 'directly', + 'directory', + 'direness', + 'dirtiness', + 'disabled', + 'disagree', + 'disallow', + 'disarm', + 'disarray', + 'disaster', + 'disband', + 'disbelief', + 'disburse', + 'discard', + 'discern', + 'discharge', + 'disclose', + 'discolor', + 'discount', + 'discourse', + 'discover', + 'discuss', + 'disdain', + 'disengage', + 'disfigure', + 'disgrace', + 'dish', + 'disinfect', + 'disjoin', + 'disk', + 'dislike', + 'disliking', + 'dislocate', + 'dislodge', + 'disloyal', + 'dismantle', + 'dismay', + 'dismiss', + 'dismount', + 'disobey', + 'disorder', + 'disown', + 'disparate', + 'disparity', + 'dispatch', + 'dispense', + 'dispersal', + 'dispersed', + 'disperser', + 'displace', + 'display', + 'displease', + 'disposal', + 'dispose', + 'disprove', + 'dispute', + 'disregard', + 'disrupt', + 'dissuade', + 'distance', + 'distant', + 'distaste', + 'distill', + 'distinct', + 'distort', + 'distract', + 'distress', + 'district', + 'distrust', + 'ditch', + 'ditto', + 'ditzy', + 'dividable', + 'divided', + 'dividend', + 'dividers', + 'dividing', + 'divinely', + 'diving', + 'divinity', + 'divisible', + 'divisibly', + 'division', + 'divisive', + 'divorcee', + 'dizziness', + 'dizzy', + 'doable', + 'docile', + 'dock', + 'doctrine', + 'document', + 'dodge', + 'dodgy', + 'doily', + 'doing', + 'dole', + 'dollar', + 'dollhouse', + 'dollop', + 'dolly', + 'dolphin', + 'domain', + 'domelike', + 'domestic', + 'dominion', + 'dominoes', + 'donated', + 'donation', + 'donator', + 'donor', + 'donut', + 'doodle', + 'doorbell', + 'doorframe', + 'doorknob', + 'doorman', + 'doormat', + 'doornail', + 'doorpost', + 'doorstep', + 'doorstop', + 'doorway', + 'doozy', + 'dork', + 'dormitory', + 'dorsal', + 'dosage', + 'dose', + 'dotted', + 'doubling', + 'douche', + 'dove', + 'down', + 'dowry', + 'doze', + 'drab', + 'dragging', + 'dragonfly', + 'dragonish', + 'dragster', + 'drainable', + 'drainage', + 'drained', + 'drainer', + 'drainpipe', + 'dramatic', + 'dramatize', + 'drank', + 'drapery', + 'drastic', + 'draw', + 'dreaded', + 'dreadful', + 'dreadlock', + 'dreamboat', + 'dreamily', + 'dreamland', + 'dreamless', + 'dreamlike', + 'dreamt', + 'dreamy', + 'drearily', + 'dreary', + 'drench', + 'dress', + 'drew', + 'dribble', + 'dried', + 'drier', + 'drift', + 'driller', + 'drilling', + 'drinkable', + 'drinking', + 'dripping', + 'drippy', + 'drivable', + 'driven', + 'driver', + 'driveway', + 'driving', + 'drizzle', + 'drizzly', + 'drone', + 'drool', + 'droop', + 'drop-down', + 'dropbox', + 'dropkick', + 'droplet', + 'dropout', + 'dropper', + 'drove', + 'drown', + 'drowsily', + 'drudge', + 'drum', + 'dry', + 'dubbed', + 'dubiously', + 'duchess', + 'duckbill', + 'ducking', + 'duckling', + 'ducktail', + 'ducky', + 'duct', + 'dude', + 'duffel', + 'dugout', + 'duh', + 'duke', + 'duller', + 'dullness', + 'duly', + 'dumping', + 'dumpling', + 'dumpster', + 'duo', + 'dupe', + 'duplex', + 'duplicate', + 'duplicity', + 'durable', + 'durably', + 'duration', + 'duress', + 'during', + 'dusk', + 'dust', + 'dutiful', + 'duty', + 'duvet', + 'dwarf', + 'dweeb', + 'dwelled', + 'dweller', + 'dwelling', + 'dwindle', + 'dwindling', + 'dynamic', + 'dynamite', + 'dynasty', + 'dyslexia', + 'dyslexic', + 'each', + 'eagle', + 'earache', + 'eardrum', + 'earflap', + 'earful', + 'earlobe', + 'early', + 'earmark', + 'earmuff', + 'earphone', + 'earpiece', + 'earplugs', + 'earring', + 'earshot', + 'earthen', + 'earthlike', + 'earthling', + 'earthly', + 'earthworm', + 'earthy', + 'earwig', + 'easeful', + 'easel', + 'easiest', + 'easily', + 'easiness', + 'easing', + 'eastbound', + 'eastcoast', + 'easter', + 'eastward', + 'eatable', + 'eaten', + 'eatery', + 'eating', + 'eats', + 'ebay', + 'ebony', + 'ebook', + 'ecard', + 'eccentric', + 'echo', + 'eclair', + 'eclipse', + 'ecologist', + 'ecology', + 'economic', + 'economist', + 'economy', + 'ecosphere', + 'ecosystem', + 'edge', + 'edginess', + 'edging', + 'edgy', + 'edition', + 'editor', + 'educated', + 'education', + 'educator', + 'eel', + 'effective', + 'effects', + 'efficient', + 'effort', + 'eggbeater', + 'egging', + 'eggnog', + 'eggplant', + 'eggshell', + 'egomaniac', + 'egotism', + 'egotistic', + 'either', + 'eject', + 'elaborate', + 'elastic', + 'elated', + 'elbow', + 'eldercare', + 'elderly', + 'eldest', + 'electable', + 'election', + 'elective', + 'elephant', + 'elevate', + 'elevating', + 'elevation', + 'elevator', + 'eleven', + 'elf', + 'eligible', + 'eligibly', + 'eliminate', + 'elite', + 'elitism', + 'elixir', + 'elk', + 'ellipse', + 'elliptic', + 'elm', + 'elongated', + 'elope', + 'eloquence', + 'eloquent', + 'elsewhere', + 'elude', + 'elusive', + 'elves', + 'email', + 'embargo', + 'embark', + 'embassy', + 'embattled', + 'embellish', + 'ember', + 'embezzle', + 'emblaze', + 'emblem', + 'embody', + 'embolism', + 'emboss', + 'embroider', + 'emcee', + 'emerald', + 'emergency', + 'emission', + 'emit', + 'emote', + 'emoticon', + 'emotion', + 'empathic', + 'empathy', + 'emperor', + 'emphases', + 'emphasis', + 'emphasize', + 'emphatic', + 'empirical', + 'employed', + 'employee', + 'employer', + 'emporium', + 'empower', + 'emptier', + 'emptiness', + 'empty', + 'emu', + 'enable', + 'enactment', + 'enamel', + 'enchanted', + 'enchilada', + 'encircle', + 'enclose', + 'enclosure', + 'encode', + 'encore', + 'encounter', + 'encourage', + 'encroach', + 'encrust', + 'encrypt', + 'endanger', + 'endeared', + 'endearing', + 'ended', + 'ending', + 'endless', + 'endnote', + 'endocrine', + 'endorphin', + 'endorse', + 'endowment', + 'endpoint', + 'endurable', + 'endurance', + 'enduring', + 'energetic', + 'energize', + 'energy', + 'enforced', + 'enforcer', + 'engaged', + 'engaging', + 'engine', + 'engorge', + 'engraved', + 'engraver', + 'engraving', + 'engross', + 'engulf', + 'enhance', + 'enigmatic', + 'enjoyable', + 'enjoyably', + 'enjoyer', + 'enjoying', + 'enjoyment', + 'enlarged', + 'enlarging', + 'enlighten', + 'enlisted', + 'enquirer', + 'enrage', + 'enrich', + 'enroll', + 'enslave', + 'ensnare', + 'ensure', + 'entail', + 'entangled', + 'entering', + 'entertain', + 'enticing', + 'entire', + 'entitle', + 'entity', + 'entomb', + 'entourage', + 'entrap', + 'entree', + 'entrench', + 'entrust', + 'entryway', + 'entwine', + 'enunciate', + 'envelope', + 'enviable', + 'enviably', + 'envious', + 'envision', + 'envoy', + 'envy', + 'enzyme', + 'epic', + 'epidemic', + 'epidermal', + 'epidermis', + 'epidural', + 'epilepsy', + 'epileptic', + 'epilogue', + 'epiphany', + 'episode', + 'equal', + 'equate', + 'equation', + 'equator', + 'equinox', + 'equipment', + 'equity', + 'equivocal', + 'eradicate', + 'erasable', + 'erased', + 'eraser', + 'erasure', + 'ergonomic', + 'errand', + 'errant', + 'erratic', + 'error', + 'erupt', + 'escalate', + 'escalator', + 'escapable', + 'escapade', + 'escapist', + 'escargot', + 'eskimo', + 'esophagus', + 'espionage', + 'espresso', + 'esquire', + 'essay', + 'essence', + 'essential', + 'establish', + 'estate', + 'esteemed', + 'estimate', + 'estimator', + 'estranged', + 'estrogen', + 'etching', + 'eternal', + 'eternity', + 'ethanol', + 'ether', + 'ethically', + 'ethics', + 'euphemism', + 'evacuate', + 'evacuee', + 'evade', + 'evaluate', + 'evaluator', + 'evaporate', + 'evasion', + 'evasive', + 'even', + 'everglade', + 'evergreen', + 'everybody', + 'everyday', + 'everyone', + 'evict', + 'evidence', + 'evident', + 'evil', + 'evoke', + 'evolution', + 'evolve', + 'exact', + 'exalted', + 'example', + 'excavate', + 'excavator', + 'exceeding', + 'exception', + 'excess', + 'exchange', + 'excitable', + 'exciting', + 'exclaim', + 'exclude', + 'excluding', + 'exclusion', + 'exclusive', + 'excretion', + 'excretory', + 'excursion', + 'excusable', + 'excusably', + 'excuse', + 'exemplary', + 'exemplify', + 'exemption', + 'exerciser', + 'exert', + 'exes', + 'exfoliate', + 'exhale', + 'exhaust', + 'exhume', + 'exile', + 'existing', + 'exit', + 'exodus', + 'exonerate', + 'exorcism', + 'exorcist', + 'expand', + 'expanse', + 'expansion', + 'expansive', + 'expectant', + 'expedited', + 'expediter', + 'expel', + 'expend', + 'expenses', + 'expensive', + 'expert', + 'expire', + 'expiring', + 'explain', + 'expletive', + 'explicit', + 'explode', + 'exploit', + 'explore', + 'exploring', + 'exponent', + 'exporter', + 'exposable', + 'expose', + 'exposure', + 'express', + 'expulsion', + 'exquisite', + 'extended', + 'extending', + 'extent', + 'extenuate', + 'exterior', + 'external', + 'extinct', + 'extortion', + 'extradite', + 'extras', + 'extrovert', + 'extrude', + 'extruding', + 'exuberant', + 'fable', + 'fabric', + 'fabulous', + 'facebook', + 'facecloth', + 'facedown', + 'faceless', + 'facelift', + 'faceplate', + 'faceted', + 'facial', + 'facility', + 'facing', + 'facsimile', + 'faction', + 'factoid', + 'factor', + 'factsheet', + 'factual', + 'faculty', + 'fade', + 'fading', + 'failing', + 'falcon', + 'fall', + 'false', + 'falsify', + 'fame', + 'familiar', + 'family', + 'famine', + 'famished', + 'fanatic', + 'fancied', + 'fanciness', + 'fancy', + 'fanfare', + 'fang', + 'fanning', + 'fantasize', + 'fantastic', + 'fantasy', + 'fascism', + 'fastball', + 'faster', + 'fasting', + 'fastness', + 'faucet', + 'favorable', + 'favorably', + 'favored', + 'favoring', + 'favorite', + 'fax', + 'feast', + 'federal', + 'fedora', + 'feeble', + 'feed', + 'feel', + 'feisty', + 'feline', + 'felt-tip', + 'feminine', + 'feminism', + 'feminist', + 'feminize', + 'femur', + 'fence', + 'fencing', + 'fender', + 'ferment', + 'fernlike', + 'ferocious', + 'ferocity', + 'ferret', + 'ferris', + 'ferry', + 'fervor', + 'fester', + 'festival', + 'festive', + 'festivity', + 'fetal', + 'fetch', + 'fever', + 'fiber', + 'fiction', + 'fiddle', + 'fiddling', + 'fidelity', + 'fidgeting', + 'fidgety', + 'fifteen', + 'fifth', + 'fiftieth', + 'fifty', + 'figment', + 'figure', + 'figurine', + 'filing', + 'filled', + 'filler', + 'filling', + 'film', + 'filter', + 'filth', + 'filtrate', + 'finale', + 'finalist', + 'finalize', + 'finally', + 'finance', + 'financial', + 'finch', + 'fineness', + 'finer', + 'finicky', + 'finished', + 'finisher', + 'finishing', + 'finite', + 'finless', + 'finlike', + 'fiscally', + 'fit', + 'five', + 'flaccid', + 'flagman', + 'flagpole', + 'flagship', + 'flagstick', + 'flagstone', + 'flail', + 'flakily', + 'flaky', + 'flame', + 'flammable', + 'flanked', + 'flanking', + 'flannels', + 'flap', + 'flaring', + 'flashback', + 'flashbulb', + 'flashcard', + 'flashily', + 'flashing', + 'flashy', + 'flask', + 'flatbed', + 'flatfoot', + 'flatly', + 'flatness', + 'flatten', + 'flattered', + 'flatterer', + 'flattery', + 'flattop', + 'flatware', + 'flatworm', + 'flavored', + 'flavorful', + 'flavoring', + 'flaxseed', + 'fled', + 'fleshed', + 'fleshy', + 'flick', + 'flier', + 'flight', + 'flinch', + 'fling', + 'flint', + 'flip', + 'flirt', + 'float', + 'flock', + 'flogging', + 'flop', + 'floral', + 'florist', + 'floss', + 'flounder', + 'flyable', + 'flyaway', + 'flyer', + 'flying', + 'flyover', + 'flypaper', + 'foam', + 'foe', + 'fog', + 'foil', + 'folic', + 'folk', + 'follicle', + 'follow', + 'fondling', + 'fondly', + 'fondness', + 'fondue', + 'font', + 'food', + 'fool', + 'footage', + 'football', + 'footbath', + 'footboard', + 'footer', + 'footgear', + 'foothill', + 'foothold', + 'footing', + 'footless', + 'footman', + 'footnote', + 'footpad', + 'footpath', + 'footprint', + 'footrest', + 'footsie', + 'footsore', + 'footwear', + 'footwork', + 'fossil', + 'foster', + 'founder', + 'founding', + 'fountain', + 'fox', + 'foyer', + 'fraction', + 'fracture', + 'fragile', + 'fragility', + 'fragment', + 'fragrance', + 'fragrant', + 'frail', + 'frame', + 'framing', + 'frantic', + 'fraternal', + 'frayed', + 'fraying', + 'frays', + 'freckled', + 'freckles', + 'freebase', + 'freebee', + 'freebie', + 'freedom', + 'freefall', + 'freehand', + 'freeing', + 'freeload', + 'freely', + 'freemason', + 'freeness', + 'freestyle', + 'freeware', + 'freeway', + 'freewill', + 'freezable', + 'freezing', + 'freight', + 'french', + 'frenzied', + 'frenzy', + 'frequency', + 'frequent', + 'fresh', + 'fretful', + 'fretted', + 'friction', + 'friday', + 'fridge', + 'fried', + 'friend', + 'frighten', + 'frightful', + 'frigidity', + 'frigidly', + 'frill', + 'fringe', + 'frisbee', + 'frisk', + 'fritter', + 'frivolous', + 'frolic', + 'from', + 'front', + 'frostbite', + 'frosted', + 'frostily', + 'frosting', + 'frostlike', + 'frosty', + 'froth', + 'frown', + 'frozen', + 'fructose', + 'frugality', + 'frugally', + 'fruit', + 'frustrate', + 'frying', + 'gab', + 'gaffe', + 'gag', + 'gainfully', + 'gaining', + 'gains', + 'gala', + 'gallantly', + 'galleria', + 'gallery', + 'galley', + 'gallon', + 'gallows', + 'gallstone', + 'galore', + 'galvanize', + 'gambling', + 'game', + 'gaming', + 'gamma', + 'gander', + 'gangly', + 'gangrene', + 'gangway', + 'gap', + 'garage', + 'garbage', + 'garden', + 'gargle', + 'garland', + 'garlic', + 'garment', + 'garnet', + 'garnish', + 'garter', + 'gas', + 'gatherer', + 'gathering', + 'gating', + 'gauging', + 'gauntlet', + 'gauze', + 'gave', + 'gawk', + 'gazing', + 'gear', + 'gecko', + 'geek', + 'geiger', + 'gem', + 'gender', + 'generic', + 'generous', + 'genetics', + 'genre', + 'gentile', + 'gentleman', + 'gently', + 'gents', + 'geography', + 'geologic', + 'geologist', + 'geology', + 'geometric', + 'geometry', + 'geranium', + 'gerbil', + 'geriatric', + 'germicide', + 'germinate', + 'germless', + 'germproof', + 'gestate', + 'gestation', + 'gesture', + 'getaway', + 'getting', + 'getup', + 'giant', + 'gibberish', + 'giblet', + 'giddily', + 'giddiness', + 'giddy', + 'gift', + 'gigabyte', + 'gigahertz', + 'gigantic', + 'giggle', + 'giggling', + 'giggly', + 'gigolo', + 'gilled', + 'gills', + 'gimmick', + 'girdle', + 'giveaway', + 'given', + 'giver', + 'giving', + 'gizmo', + 'gizzard', + 'glacial', + 'glacier', + 'glade', + 'gladiator', + 'gladly', + 'glamorous', + 'glamour', + 'glance', + 'glancing', + 'glandular', + 'glare', + 'glaring', + 'glass', + 'glaucoma', + 'glazing', + 'gleaming', + 'gleeful', + 'glider', + 'gliding', + 'glimmer', + 'glimpse', + 'glisten', + 'glitch', + 'glitter', + 'glitzy', + 'gloater', + 'gloating', + 'gloomily', + 'gloomy', + 'glorified', + 'glorifier', + 'glorify', + 'glorious', + 'glory', + 'gloss', + 'glove', + 'glowing', + 'glowworm', + 'glucose', + 'glue', + 'gluten', + 'glutinous', + 'glutton', + 'gnarly', + 'gnat', + 'goal', + 'goatskin', + 'goes', + 'goggles', + 'going', + 'goldfish', + 'goldmine', + 'goldsmith', + 'golf', + 'goliath', + 'gonad', + 'gondola', + 'gone', + 'gong', + 'good', + 'gooey', + 'goofball', + 'goofiness', + 'goofy', + 'google', + 'goon', + 'gopher', + 'gore', + 'gorged', + 'gorgeous', + 'gory', + 'gosling', + 'gossip', + 'gothic', + 'gotten', + 'gout', + 'gown', + 'grab', + 'graceful', + 'graceless', + 'gracious', + 'gradation', + 'graded', + 'grader', + 'gradient', + 'grading', + 'gradually', + 'graduate', + 'graffiti', + 'grafted', + 'grafting', + 'grain', + 'granddad', + 'grandkid', + 'grandly', + 'grandma', + 'grandpa', + 'grandson', + 'granite', + 'granny', + 'granola', + 'grant', + 'granular', + 'grape', + 'graph', + 'grapple', + 'grappling', + 'grasp', + 'grass', + 'gratified', + 'gratify', + 'grating', + 'gratitude', + 'gratuity', + 'gravel', + 'graveness', + 'graves', + 'graveyard', + 'gravitate', + 'gravity', + 'gravy', + 'gray', + 'grazing', + 'greasily', + 'greedily', + 'greedless', + 'greedy', + 'green', + 'greeter', + 'greeting', + 'grew', + 'greyhound', + 'grid', + 'grief', + 'grievance', + 'grieving', + 'grievous', + 'grill', + 'grimace', + 'grimacing', + 'grime', + 'griminess', + 'grimy', + 'grinch', + 'grinning', + 'grip', + 'gristle', + 'grit', + 'groggily', + 'groggy', + 'groin', + 'groom', + 'groove', + 'grooving', + 'groovy', + 'grope', + 'ground', + 'grouped', + 'grout', + 'grove', + 'grower', + 'growing', + 'growl', + 'grub', + 'grudge', + 'grudging', + 'grueling', + 'gruffly', + 'grumble', + 'grumbling', + 'grumbly', + 'grumpily', + 'grunge', + 'grunt', + 'guacamole', + 'guidable', + 'guidance', + 'guide', + 'guiding', + 'guileless', + 'guise', + 'gulf', + 'gullible', + 'gully', + 'gulp', + 'gumball', + 'gumdrop', + 'gumminess', + 'gumming', + 'gummy', + 'gurgle', + 'gurgling', + 'guru', + 'gush', + 'gusto', + 'gusty', + 'gutless', + 'guts', + 'gutter', + 'guy', + 'guzzler', + 'gyration', + 'habitable', + 'habitant', + 'habitat', + 'habitual', + 'hacked', + 'hacker', + 'hacking', + 'hacksaw', + 'had', + 'haggler', + 'haiku', + 'half', + 'halogen', + 'halt', + 'halved', + 'halves', + 'hamburger', + 'hamlet', + 'hammock', + 'hamper', + 'hamster', + 'hamstring', + 'handbag', + 'handball', + 'handbook', + 'handbrake', + 'handcart', + 'handclap', + 'handclasp', + 'handcraft', + 'handcuff', + 'handed', + 'handful', + 'handgrip', + 'handgun', + 'handheld', + 'handiness', + 'handiwork', + 'handlebar', + 'handled', + 'handler', + 'handling', + 'handmade', + 'handoff', + 'handpick', + 'handprint', + 'handrail', + 'handsaw', + 'handset', + 'handsfree', + 'handshake', + 'handstand', + 'handwash', + 'handwork', + 'handwoven', + 'handwrite', + 'handyman', + 'hangnail', + 'hangout', + 'hangover', + 'hangup', + 'hankering', + 'hankie', + 'hanky', + 'haphazard', + 'happening', + 'happier', + 'happiest', + 'happily', + 'happiness', + 'happy', + 'harbor', + 'hardcopy', + 'hardcore', + 'hardcover', + 'harddisk', + 'hardened', + 'hardener', + 'hardening', + 'hardhat', + 'hardhead', + 'hardiness', + 'hardly', + 'hardness', + 'hardship', + 'hardware', + 'hardwired', + 'hardwood', + 'hardy', + 'harmful', + 'harmless', + 'harmonica', + 'harmonics', + 'harmonize', + 'harmony', + 'harness', + 'harpist', + 'harsh', + 'harvest', + 'hash', + 'hassle', + 'haste', + 'hastily', + 'hastiness', + 'hasty', + 'hatbox', + 'hatchback', + 'hatchery', + 'hatchet', + 'hatching', + 'hatchling', + 'hate', + 'hatless', + 'hatred', + 'haunt', + 'haven', + 'hazard', + 'hazelnut', + 'hazily', + 'haziness', + 'hazing', + 'hazy', + 'headache', + 'headband', + 'headboard', + 'headcount', + 'headdress', + 'headed', + 'header', + 'headfirst', + 'headgear', + 'heading', + 'headlamp', + 'headless', + 'headlock', + 'headphone', + 'headpiece', + 'headrest', + 'headroom', + 'headscarf', + 'headset', + 'headsman', + 'headstand', + 'headstone', + 'headway', + 'headwear', + 'heap', + 'heat', + 'heave', + 'heavily', + 'heaviness', + 'heaving', + 'hedge', + 'hedging', + 'heftiness', + 'hefty', + 'helium', + 'helmet', + 'helper', + 'helpful', + 'helping', + 'helpless', + 'helpline', + 'hemlock', + 'hemstitch', + 'hence', + 'henchman', + 'henna', + 'herald', + 'herbal', + 'herbicide', + 'herbs', + 'heritage', + 'hermit', + 'heroics', + 'heroism', + 'herring', + 'herself', + 'hertz', + 'hesitancy', + 'hesitant', + 'hesitate', + 'hexagon', + 'hexagram', + 'hubcap', + 'huddle', + 'huddling', + 'huff', + 'hug', + 'hula', + 'hulk', + 'hull', + 'human', + 'humble', + 'humbling', + 'humbly', + 'humid', + 'humiliate', + 'humility', + 'humming', + 'hummus', + 'humongous', + 'humorist', + 'humorless', + 'humorous', + 'humpback', + 'humped', + 'humvee', + 'hunchback', + 'hundredth', + 'hunger', + 'hungrily', + 'hungry', + 'hunk', + 'hunter', + 'hunting', + 'huntress', + 'huntsman', + 'hurdle', + 'hurled', + 'hurler', + 'hurling', + 'hurray', + 'hurricane', + 'hurried', + 'hurry', + 'hurt', + 'husband', + 'hush', + 'husked', + 'huskiness', + 'hut', + 'hybrid', + 'hydrant', + 'hydrated', + 'hydration', + 'hydrogen', + 'hydroxide', + 'hyperlink', + 'hypertext', + 'hyphen', + 'hypnoses', + 'hypnosis', + 'hypnotic', + 'hypnotism', + 'hypnotist', + 'hypnotize', + 'hypocrisy', + 'hypocrite', + 'ibuprofen', + 'ice', + 'iciness', + 'icing', + 'icky', + 'icon', + 'icy', + 'idealism', + 'idealist', + 'idealize', + 'ideally', + 'idealness', + 'identical', + 'identify', + 'identity', + 'ideology', + 'idiocy', + 'idiom', + 'idly', + 'igloo', + 'ignition', + 'ignore', + 'iguana', + 'illicitly', + 'illusion', + 'illusive', + 'image', + 'imaginary', + 'imagines', + 'imaging', + 'imbecile', + 'imitate', + 'imitation', + 'immature', + 'immerse', + 'immersion', + 'imminent', + 'immobile', + 'immodest', + 'immorally', + 'immortal', + 'immovable', + 'immovably', + 'immunity', + 'immunize', + 'impaired', + 'impale', + 'impart', + 'impatient', + 'impeach', + 'impeding', + 'impending', + 'imperfect', + 'imperial', + 'impish', + 'implant', + 'implement', + 'implicate', + 'implicit', + 'implode', + 'implosion', + 'implosive', + 'imply', + 'impolite', + 'important', + 'importer', + 'impose', + 'imposing', + 'impotence', + 'impotency', + 'impotent', + 'impound', + 'imprecise', + 'imprint', + 'imprison', + 'impromptu', + 'improper', + 'improve', + 'improving', + 'improvise', + 'imprudent', + 'impulse', + 'impulsive', + 'impure', + 'impurity', + 'iodine', + 'iodize', + 'ion', + 'ipad', + 'iphone', + 'ipod', + 'irate', + 'irk', + 'iron', + 'irregular', + 'irrigate', + 'irritable', + 'irritably', + 'irritant', + 'irritate', + 'islamic', + 'islamist', + 'isolated', + 'isolating', + 'isolation', + 'isotope', + 'issue', + 'issuing', + 'italicize', + 'italics', + 'item', + 'itinerary', + 'itunes', + 'ivory', + 'ivy', + 'jab', + 'jackal', + 'jacket', + 'jackknife', + 'jackpot', + 'jailbird', + 'jailbreak', + 'jailer', + 'jailhouse', + 'jalapeno', + 'jam', + 'janitor', + 'january', + 'jargon', + 'jarring', + 'jasmine', + 'jaundice', + 'jaunt', + 'java', + 'jawed', + 'jawless', + 'jawline', + 'jaws', + 'jaybird', + 'jaywalker', + 'jazz', + 'jeep', + 'jeeringly', + 'jellied', + 'jelly', + 'jersey', + 'jester', + 'jet', + 'jiffy', + 'jigsaw', + 'jimmy', + 'jingle', + 'jingling', + 'jinx', + 'jitters', + 'jittery', + 'job', + 'jockey', + 'jockstrap', + 'jogger', + 'jogging', + 'john', + 'joining', + 'jokester', + 'jokingly', + 'jolliness', + 'jolly', + 'jolt', + 'jot', + 'jovial', + 'joyfully', + 'joylessly', + 'joyous', + 'joyride', + 'joystick', + 'jubilance', + 'jubilant', + 'judge', + 'judgingly', + 'judicial', + 'judiciary', + 'judo', + 'juggle', + 'juggling', + 'jugular', + 'juice', + 'juiciness', + 'juicy', + 'jujitsu', + 'jukebox', + 'july', + 'jumble', + 'jumbo', + 'jump', + 'junction', + 'juncture', + 'june', + 'junior', + 'juniper', + 'junkie', + 'junkman', + 'junkyard', + 'jurist', + 'juror', + 'jury', + 'justice', + 'justifier', + 'justify', + 'justly', + 'justness', + 'juvenile', + 'kabob', + 'kangaroo', + 'karaoke', + 'karate', + 'karma', + 'kebab', + 'keenly', + 'keenness', + 'keep', + 'keg', + 'kelp', + 'kennel', + 'kept', + 'kerchief', + 'kerosene', + 'kettle', + 'kick', + 'kiln', + 'kilobyte', + 'kilogram', + 'kilometer', + 'kilowatt', + 'kilt', + 'kimono', + 'kindle', + 'kindling', + 'kindly', + 'kindness', + 'kindred', + 'kinetic', + 'kinfolk', + 'king', + 'kinship', + 'kinsman', + 'kinswoman', + 'kissable', + 'kisser', + 'kissing', + 'kitchen', + 'kite', + 'kitten', + 'kitty', + 'kiwi', + 'kleenex', + 'knapsack', + 'knee', + 'knelt', + 'knickers', + 'knoll', + 'koala', + 'kooky', + 'kosher', + 'krypton', + 'kudos', + 'kung', + 'labored', + 'laborer', + 'laboring', + 'laborious', + 'labrador', + 'ladder', + 'ladies', + 'ladle', + 'ladybug', + 'ladylike', + 'lagged', + 'lagging', + 'lagoon', + 'lair', + 'lake', + 'lance', + 'landed', + 'landfall', + 'landfill', + 'landing', + 'landlady', + 'landless', + 'landline', + 'landlord', + 'landmark', + 'landmass', + 'landmine', + 'landowner', + 'landscape', + 'landside', + 'landslide', + 'language', + 'lankiness', + 'lanky', + 'lantern', + 'lapdog', + 'lapel', + 'lapped', + 'lapping', + 'laptop', + 'lard', + 'large', + 'lark', + 'lash', + 'lasso', + 'last', + 'latch', + 'late', + 'lather', + 'latitude', + 'latrine', + 'latter', + 'latticed', + 'launch', + 'launder', + 'laundry', + 'laurel', + 'lavender', + 'lavish', + 'laxative', + 'lazily', + 'laziness', + 'lazy', + 'lecturer', + 'left', + 'legacy', + 'legal', + 'legend', + 'legged', + 'leggings', + 'legible', + 'legibly', + 'legislate', + 'lego', + 'legroom', + 'legume', + 'legwarmer', + 'legwork', + 'lemon', + 'lend', + 'length', + 'lens', + 'lent', + 'leotard', + 'lesser', + 'letdown', + 'lethargic', + 'lethargy', + 'letter', + 'lettuce', + 'level', + 'leverage', + 'levers', + 'levitate', + 'levitator', + 'liability', + 'liable', + 'liberty', + 'librarian', + 'library', + 'licking', + 'licorice', + 'lid', + 'life', + 'lifter', + 'lifting', + 'liftoff', + 'ligament', + 'likely', + 'likeness', + 'likewise', + 'liking', + 'lilac', + 'lilly', + 'lily', + 'limb', + 'limeade', + 'limelight', + 'limes', + 'limit', + 'limping', + 'limpness', + 'line', + 'lingo', + 'linguini', + 'linguist', + 'lining', + 'linked', + 'linoleum', + 'linseed', + 'lint', + 'lion', + 'lip', + 'liquefy', + 'liqueur', + 'liquid', + 'lisp', + 'list', + 'litigate', + 'litigator', + 'litmus', + 'litter', + 'little', + 'livable', + 'lived', + 'lively', + 'liver', + 'livestock', + 'lividly', + 'living', + 'lizard', + 'lubricant', + 'lubricate', + 'lucid', + 'luckily', + 'luckiness', + 'luckless', + 'lucrative', + 'ludicrous', + 'lugged', + 'lukewarm', + 'lullaby', + 'lumber', + 'luminance', + 'luminous', + 'lumpiness', + 'lumping', + 'lumpish', + 'lunacy', + 'lunar', + 'lunchbox', + 'luncheon', + 'lunchroom', + 'lunchtime', + 'lung', + 'lurch', + 'lure', + 'luridness', + 'lurk', + 'lushly', + 'lushness', + 'luster', + 'lustfully', + 'lustily', + 'lustiness', + 'lustrous', + 'lusty', + 'luxurious', + 'luxury', + 'lying', + 'lyrically', + 'lyricism', + 'lyricist', + 'lyrics', + 'macarena', + 'macaroni', + 'macaw', + 'mace', + 'machine', + 'machinist', + 'magazine', + 'magenta', + 'maggot', + 'magical', + 'magician', + 'magma', + 'magnesium', + 'magnetic', + 'magnetism', + 'magnetize', + 'magnifier', + 'magnify', + 'magnitude', + 'magnolia', + 'mahogany', + 'maimed', + 'majestic', + 'majesty', + 'majorette', + 'majority', + 'makeover', + 'maker', + 'makeshift', + 'making', + 'malformed', + 'malt', + 'mama', + 'mammal', + 'mammary', + 'mammogram', + 'manager', + 'managing', + 'manatee', + 'mandarin', + 'mandate', + 'mandatory', + 'mandolin', + 'manger', + 'mangle', + 'mango', + 'mangy', + 'manhandle', + 'manhole', + 'manhood', + 'manhunt', + 'manicotti', + 'manicure', + 'manifesto', + 'manila', + 'mankind', + 'manlike', + 'manliness', + 'manly', + 'manmade', + 'manned', + 'mannish', + 'manor', + 'manpower', + 'mantis', + 'mantra', + 'manual', + 'many', + 'map', + 'marathon', + 'marauding', + 'marbled', + 'marbles', + 'marbling', + 'march', + 'mardi', + 'margarine', + 'margarita', + 'margin', + 'marigold', + 'marina', + 'marine', + 'marital', + 'maritime', + 'marlin', + 'marmalade', + 'maroon', + 'married', + 'marrow', + 'marry', + 'marshland', + 'marshy', + 'marsupial', + 'marvelous', + 'marxism', + 'mascot', + 'masculine', + 'mashed', + 'mashing', + 'massager', + 'masses', + 'massive', + 'mastiff', + 'matador', + 'matchbook', + 'matchbox', + 'matcher', + 'matching', + 'matchless', + 'material', + 'maternal', + 'maternity', + 'math', + 'mating', + 'matriarch', + 'matrimony', + 'matrix', + 'matron', + 'matted', + 'matter', + 'maturely', + 'maturing', + 'maturity', + 'mauve', + 'maverick', + 'maximize', + 'maximum', + 'maybe', + 'mayday', + 'mayflower', + 'moaner', + 'moaning', + 'mobile', + 'mobility', + 'mobilize', + 'mobster', + 'mocha', + 'mocker', + 'mockup', + 'modified', + 'modify', + 'modular', + 'modulator', + 'module', + 'moisten', + 'moistness', + 'moisture', + 'molar', + 'molasses', + 'mold', + 'molecular', + 'molecule', + 'molehill', + 'mollusk', + 'mom', + 'monastery', + 'monday', + 'monetary', + 'monetize', + 'moneybags', + 'moneyless', + 'moneywise', + 'mongoose', + 'mongrel', + 'monitor', + 'monkhood', + 'monogamy', + 'monogram', + 'monologue', + 'monopoly', + 'monorail', + 'monotone', + 'monotype', + 'monoxide', + 'monsieur', + 'monsoon', + 'monstrous', + 'monthly', + 'monument', + 'moocher', + 'moodiness', + 'moody', + 'mooing', + 'moonbeam', + 'mooned', + 'moonlight', + 'moonlike', + 'moonlit', + 'moonrise', + 'moonscape', + 'moonshine', + 'moonstone', + 'moonwalk', + 'mop', + 'morale', + 'morality', + 'morally', + 'morbidity', + 'morbidly', + 'morphine', + 'morphing', + 'morse', + 'mortality', + 'mortally', + 'mortician', + 'mortified', + 'mortify', + 'mortuary', + 'mosaic', + 'mossy', + 'most', + 'mothball', + 'mothproof', + 'motion', + 'motivate', + 'motivator', + 'motive', + 'motocross', + 'motor', + 'motto', + 'mountable', + 'mountain', + 'mounted', + 'mounting', + 'mourner', + 'mournful', + 'mouse', + 'mousiness', + 'moustache', + 'mousy', + 'mouth', + 'movable', + 'move', + 'movie', + 'moving', + 'mower', + 'mowing', + 'much', + 'muck', + 'mud', + 'mug', + 'mulberry', + 'mulch', + 'mule', + 'mulled', + 'mullets', + 'multiple', + 'multiply', + 'multitask', + 'multitude', + 'mumble', + 'mumbling', + 'mumbo', + 'mummified', + 'mummify', + 'mummy', + 'mumps', + 'munchkin', + 'mundane', + 'municipal', + 'muppet', + 'mural', + 'murkiness', + 'murky', + 'murmuring', + 'muscular', + 'museum', + 'mushily', + 'mushiness', + 'mushroom', + 'mushy', + 'music', + 'musket', + 'muskiness', + 'musky', + 'mustang', + 'mustard', + 'muster', + 'mustiness', + 'musty', + 'mutable', + 'mutate', + 'mutation', + 'mute', + 'mutilated', + 'mutilator', + 'mutiny', + 'mutt', + 'mutual', + 'muzzle', + 'myself', + 'myspace', + 'mystified', + 'mystify', + 'myth', + 'nacho', + 'nag', + 'nail', + 'name', + 'naming', + 'nanny', + 'nanometer', + 'nape', + 'napkin', + 'napped', + 'napping', + 'nappy', + 'narrow', + 'nastily', + 'nastiness', + 'national', + 'native', + 'nativity', + 'natural', + 'nature', + 'naturist', + 'nautical', + 'navigate', + 'navigator', + 'navy', + 'nearby', + 'nearest', + 'nearly', + 'nearness', + 'neatly', + 'neatness', + 'nebula', + 'nebulizer', + 'nectar', + 'negate', + 'negation', + 'negative', + 'neglector', + 'negligee', + 'negligent', + 'negotiate', + 'nemeses', + 'nemesis', + 'neon', + 'nephew', + 'nerd', + 'nervous', + 'nervy', + 'nest', + 'net', + 'neurology', + 'neuron', + 'neurosis', + 'neurotic', + 'neuter', + 'neutron', + 'never', + 'next', + 'nibble', + 'nickname', + 'nicotine', + 'niece', + 'nifty', + 'nimble', + 'nimbly', + 'nineteen', + 'ninetieth', + 'ninja', + 'nintendo', + 'ninth', + 'nuclear', + 'nuclei', + 'nucleus', + 'nugget', + 'nullify', + 'number', + 'numbing', + 'numbly', + 'numbness', + 'numeral', + 'numerate', + 'numerator', + 'numeric', + 'numerous', + 'nuptials', + 'nursery', + 'nursing', + 'nurture', + 'nutcase', + 'nutlike', + 'nutmeg', + 'nutrient', + 'nutshell', + 'nuttiness', + 'nutty', + 'nuzzle', + 'nylon', + 'oaf', + 'oak', + 'oasis', + 'oat', + 'obedience', + 'obedient', + 'obituary', + 'object', + 'obligate', + 'obliged', + 'oblivion', + 'oblivious', + 'oblong', + 'obnoxious', + 'oboe', + 'obscure', + 'obscurity', + 'observant', + 'observer', + 'observing', + 'obsessed', + 'obsession', + 'obsessive', + 'obsolete', + 'obstacle', + 'obstinate', + 'obstruct', + 'obtain', + 'obtrusive', + 'obtuse', + 'obvious', + 'occultist', + 'occupancy', + 'occupant', + 'occupier', + 'occupy', + 'ocean', + 'ocelot', + 'octagon', + 'octane', + 'october', + 'octopus', + 'ogle', + 'oil', + 'oink', + 'ointment', + 'okay', + 'old', + 'olive', + 'olympics', + 'omega', + 'omen', + 'ominous', + 'omission', + 'omit', + 'omnivore', + 'onboard', + 'oncoming', + 'ongoing', + 'onion', + 'online', + 'onlooker', + 'only', + 'onscreen', + 'onset', + 'onshore', + 'onslaught', + 'onstage', + 'onto', + 'onward', + 'onyx', + 'oops', + 'ooze', + 'oozy', + 'opacity', + 'opal', + 'open', + 'operable', + 'operate', + 'operating', + 'operation', + 'operative', + 'operator', + 'opium', + 'opossum', + 'opponent', + 'oppose', + 'opposing', + 'opposite', + 'oppressed', + 'oppressor', + 'opt', + 'opulently', + 'osmosis', + 'other', + 'otter', + 'ouch', + 'ought', + 'ounce', + 'outage', + 'outback', + 'outbid', + 'outboard', + 'outbound', + 'outbreak', + 'outburst', + 'outcast', + 'outclass', + 'outcome', + 'outdated', + 'outdoors', + 'outer', + 'outfield', + 'outfit', + 'outflank', + 'outgoing', + 'outgrow', + 'outhouse', + 'outing', + 'outlast', + 'outlet', + 'outline', + 'outlook', + 'outlying', + 'outmatch', + 'outmost', + 'outnumber', + 'outplayed', + 'outpost', + 'outpour', + 'output', + 'outrage', + 'outrank', + 'outreach', + 'outright', + 'outscore', + 'outsell', + 'outshine', + 'outshoot', + 'outsider', + 'outskirts', + 'outsmart', + 'outsource', + 'outspoken', + 'outtakes', + 'outthink', + 'outward', + 'outweigh', + 'outwit', + 'oval', + 'ovary', + 'oven', + 'overact', + 'overall', + 'overarch', + 'overbid', + 'overbill', + 'overbite', + 'overblown', + 'overboard', + 'overbook', + 'overbuilt', + 'overcast', + 'overcoat', + 'overcome', + 'overcook', + 'overcrowd', + 'overdraft', + 'overdrawn', + 'overdress', + 'overdrive', + 'overdue', + 'overeager', + 'overeater', + 'overexert', + 'overfed', + 'overfeed', + 'overfill', + 'overflow', + 'overfull', + 'overgrown', + 'overhand', + 'overhang', + 'overhaul', + 'overhead', + 'overhear', + 'overheat', + 'overhung', + 'overjoyed', + 'overkill', + 'overlabor', + 'overlaid', + 'overlap', + 'overlay', + 'overload', + 'overlook', + 'overlord', + 'overlying', + 'overnight', + 'overpass', + 'overpay', + 'overplant', + 'overplay', + 'overpower', + 'overprice', + 'overrate', + 'overreach', + 'overreact', + 'override', + 'overripe', + 'overrule', + 'overrun', + 'overshoot', + 'overshot', + 'oversight', + 'oversized', + 'oversleep', + 'oversold', + 'overspend', + 'overstate', + 'overstay', + 'overstep', + 'overstock', + 'overstuff', + 'oversweet', + 'overtake', + 'overthrow', + 'overtime', + 'overtly', + 'overtone', + 'overture', + 'overturn', + 'overuse', + 'overvalue', + 'overview', + 'overwrite', + 'owl', + 'oxford', + 'oxidant', + 'oxidation', + 'oxidize', + 'oxidizing', + 'oxygen', + 'oxymoron', + 'oyster', + 'ozone', + 'paced', + 'pacemaker', + 'pacific', + 'pacifier', + 'pacifism', + 'pacifist', + 'pacify', + 'padded', + 'padding', + 'paddle', + 'paddling', + 'padlock', + 'pagan', + 'pager', + 'paging', + 'pajamas', + 'palace', + 'palatable', + 'palm', + 'palpable', + 'palpitate', + 'paltry', + 'pampered', + 'pamperer', + 'pampers', + 'pamphlet', + 'panama', + 'pancake', + 'pancreas', + 'panda', + 'pandemic', + 'pang', + 'panhandle', + 'panic', + 'panning', + 'panorama', + 'panoramic', + 'panther', + 'pantomime', + 'pantry', + 'pants', + 'pantyhose', + 'paparazzi', + 'papaya', + 'paper', + 'paprika', + 'papyrus', + 'parabola', + 'parachute', + 'parade', + 'paradox', + 'paragraph', + 'parakeet', + 'paralegal', + 'paralyses', + 'paralysis', + 'paralyze', + 'paramedic', + 'parameter', + 'paramount', + 'parasail', + 'parasite', + 'parasitic', + 'parcel', + 'parched', + 'parchment', + 'pardon', + 'parish', + 'parka', + 'parking', + 'parkway', + 'parlor', + 'parmesan', + 'parole', + 'parrot', + 'parsley', + 'parsnip', + 'partake', + 'parted', + 'parting', + 'partition', + 'partly', + 'partner', + 'partridge', + 'party', + 'passable', + 'passably', + 'passage', + 'passcode', + 'passenger', + 'passerby', + 'passing', + 'passion', + 'passive', + 'passivism', + 'passover', + 'passport', + 'password', + 'pasta', + 'pasted', + 'pastel', + 'pastime', + 'pastor', + 'pastrami', + 'pasture', + 'pasty', + 'patchwork', + 'patchy', + 'paternal', + 'paternity', + 'path', + 'patience', + 'patient', + 'patio', + 'patriarch', + 'patriot', + 'patrol', + 'patronage', + 'patronize', + 'pauper', + 'pavement', + 'paver', + 'pavestone', + 'pavilion', + 'paving', + 'pawing', + 'payable', + 'payback', + 'paycheck', + 'payday', + 'payee', + 'payer', + 'paying', + 'payment', + 'payphone', + 'payroll', + 'pebble', + 'pebbly', + 'pecan', + 'pectin', + 'peculiar', + 'peddling', + 'pediatric', + 'pedicure', + 'pedigree', + 'pedometer', + 'pegboard', + 'pelican', + 'pellet', + 'pelt', + 'pelvis', + 'penalize', + 'penalty', + 'pencil', + 'pendant', + 'pending', + 'penholder', + 'penknife', + 'pennant', + 'penniless', + 'penny', + 'penpal', + 'pension', + 'pentagon', + 'pentagram', + 'pep', + 'perceive', + 'percent', + 'perch', + 'percolate', + 'perennial', + 'perfected', + 'perfectly', + 'perfume', + 'periscope', + 'perish', + 'perjurer', + 'perjury', + 'perkiness', + 'perky', + 'perm', + 'peroxide', + 'perpetual', + 'perplexed', + 'persecute', + 'persevere', + 'persuaded', + 'persuader', + 'pesky', + 'peso', + 'pessimism', + 'pessimist', + 'pester', + 'pesticide', + 'petal', + 'petite', + 'petition', + 'petri', + 'petroleum', + 'petted', + 'petticoat', + 'pettiness', + 'petty', + 'petunia', + 'phantom', + 'phobia', + 'phoenix', + 'phonebook', + 'phoney', + 'phonics', + 'phoniness', + 'phony', + 'phosphate', + 'photo', + 'phrase', + 'phrasing', + 'placard', + 'placate', + 'placidly', + 'plank', + 'planner', + 'plant', + 'plasma', + 'plaster', + 'plastic', + 'plated', + 'platform', + 'plating', + 'platinum', + 'platonic', + 'platter', + 'platypus', + 'plausible', + 'plausibly', + 'playable', + 'playback', + 'player', + 'playful', + 'playgroup', + 'playhouse', + 'playing', + 'playlist', + 'playmaker', + 'playmate', + 'playoff', + 'playpen', + 'playroom', + 'playset', + 'plaything', + 'playtime', + 'plaza', + 'pleading', + 'pleat', + 'pledge', + 'plentiful', + 'plenty', + 'plethora', + 'plexiglas', + 'pliable', + 'plod', + 'plop', + 'plot', + 'plow', + 'ploy', + 'pluck', + 'plug', + 'plunder', + 'plunging', + 'plural', + 'plus', + 'plutonium', + 'plywood', + 'poach', + 'pod', + 'poem', + 'poet', + 'pogo', + 'pointed', + 'pointer', + 'pointing', + 'pointless', + 'pointy', + 'poise', + 'poison', + 'poker', + 'poking', + 'polar', + 'police', + 'policy', + 'polio', + 'polish', + 'politely', + 'polka', + 'polo', + 'polyester', + 'polygon', + 'polygraph', + 'polymer', + 'poncho', + 'pond', + 'pony', + 'popcorn', + 'pope', + 'poplar', + 'popper', + 'poppy', + 'popsicle', + 'populace', + 'popular', + 'populate', + 'porcupine', + 'pork', + 'porous', + 'porridge', + 'portable', + 'portal', + 'portfolio', + 'porthole', + 'portion', + 'portly', + 'portside', + 'poser', + 'posh', + 'posing', + 'possible', + 'possibly', + 'possum', + 'postage', + 'postal', + 'postbox', + 'postcard', + 'posted', + 'poster', + 'posting', + 'postnasal', + 'posture', + 'postwar', + 'pouch', + 'pounce', + 'pouncing', + 'pound', + 'pouring', + 'pout', + 'powdered', + 'powdering', + 'powdery', + 'power', + 'powwow', + 'pox', + 'praising', + 'prance', + 'prancing', + 'pranker', + 'prankish', + 'prankster', + 'prayer', + 'praying', + 'preacher', + 'preaching', + 'preachy', + 'preamble', + 'precinct', + 'precise', + 'precision', + 'precook', + 'precut', + 'predator', + 'predefine', + 'predict', + 'preface', + 'prefix', + 'preflight', + 'preformed', + 'pregame', + 'pregnancy', + 'pregnant', + 'preheated', + 'prelaunch', + 'prelaw', + 'prelude', + 'premiere', + 'premises', + 'premium', + 'prenatal', + 'preoccupy', + 'preorder', + 'prepaid', + 'prepay', + 'preplan', + 'preppy', + 'preschool', + 'prescribe', + 'preseason', + 'preset', + 'preshow', + 'president', + 'presoak', + 'press', + 'presume', + 'presuming', + 'preteen', + 'pretended', + 'pretender', + 'pretense', + 'pretext', + 'pretty', + 'pretzel', + 'prevail', + 'prevalent', + 'prevent', + 'preview', + 'previous', + 'prewar', + 'prewashed', + 'prideful', + 'pried', + 'primal', + 'primarily', + 'primary', + 'primate', + 'primer', + 'primp', + 'princess', + 'print', + 'prior', + 'prism', + 'prison', + 'prissy', + 'pristine', + 'privacy', + 'private', + 'privatize', + 'prize', + 'proactive', + 'probable', + 'probably', + 'probation', + 'probe', + 'probing', + 'probiotic', + 'problem', + 'procedure', + 'process', + 'proclaim', + 'procreate', + 'procurer', + 'prodigal', + 'prodigy', + 'produce', + 'product', + 'profane', + 'profanity', + 'professed', + 'professor', + 'profile', + 'profound', + 'profusely', + 'progeny', + 'prognosis', + 'program', + 'progress', + 'projector', + 'prologue', + 'prolonged', + 'promenade', + 'prominent', + 'promoter', + 'promotion', + 'prompter', + 'promptly', + 'prone', + 'prong', + 'pronounce', + 'pronto', + 'proofing', + 'proofread', + 'proofs', + 'propeller', + 'properly', + 'property', + 'proponent', + 'proposal', + 'propose', + 'props', + 'prorate', + 'protector', + 'protegee', + 'proton', + 'prototype', + 'protozoan', + 'protract', + 'protrude', + 'proud', + 'provable', + 'proved', + 'proven', + 'provided', + 'provider', + 'providing', + 'province', + 'proving', + 'provoke', + 'provoking', + 'provolone', + 'prowess', + 'prowler', + 'prowling', + 'proximity', + 'proxy', + 'prozac', + 'prude', + 'prudishly', + 'prune', + 'pruning', + 'pry', + 'psychic', + 'public', + 'publisher', + 'pucker', + 'pueblo', + 'pug', + 'pull', + 'pulmonary', + 'pulp', + 'pulsate', + 'pulse', + 'pulverize', + 'puma', + 'pumice', + 'pummel', + 'punch', + 'punctual', + 'punctuate', + 'punctured', + 'pungent', + 'punisher', + 'punk', + 'pupil', + 'puppet', + 'puppy', + 'purchase', + 'pureblood', + 'purebred', + 'purely', + 'pureness', + 'purgatory', + 'purge', + 'purging', + 'purifier', + 'purify', + 'purist', + 'puritan', + 'purity', + 'purple', + 'purplish', + 'purposely', + 'purr', + 'purse', + 'pursuable', + 'pursuant', + 'pursuit', + 'purveyor', + 'pushcart', + 'pushchair', + 'pusher', + 'pushiness', + 'pushing', + 'pushover', + 'pushpin', + 'pushup', + 'pushy', + 'putdown', + 'putt', + 'puzzle', + 'puzzling', + 'pyramid', + 'pyromania', + 'python', + 'quack', + 'quadrant', + 'quail', + 'quaintly', + 'quake', + 'quaking', + 'qualified', + 'qualifier', + 'qualify', + 'quality', + 'qualm', + 'quantum', + 'quarrel', + 'quarry', + 'quartered', + 'quarterly', + 'quarters', + 'quartet', + 'quench', + 'query', + 'quicken', + 'quickly', + 'quickness', + 'quicksand', + 'quickstep', + 'quiet', + 'quill', + 'quilt', + 'quintet', + 'quintuple', + 'quirk', + 'quit', + 'quiver', + 'quizzical', + 'quotable', + 'quotation', + 'quote', + 'rabid', + 'race', + 'racing', + 'racism', + 'rack', + 'racoon', + 'radar', + 'radial', + 'radiance', + 'radiantly', + 'radiated', + 'radiation', + 'radiator', + 'radio', + 'radish', + 'raffle', + 'raft', + 'rage', + 'ragged', + 'raging', + 'ragweed', + 'raider', + 'railcar', + 'railing', + 'railroad', + 'railway', + 'raisin', + 'rake', + 'raking', + 'rally', + 'ramble', + 'rambling', + 'ramp', + 'ramrod', + 'ranch', + 'rancidity', + 'random', + 'ranged', + 'ranger', + 'ranging', + 'ranked', + 'ranking', + 'ransack', + 'ranting', + 'rants', + 'rare', + 'rarity', + 'rascal', + 'rash', + 'rasping', + 'ravage', + 'raven', + 'ravine', + 'raving', + 'ravioli', + 'ravishing', + 'reabsorb', + 'reach', + 'reacquire', + 'reaction', + 'reactive', + 'reactor', + 'reaffirm', + 'ream', + 'reanalyze', + 'reappear', + 'reapply', + 'reappoint', + 'reapprove', + 'rearrange', + 'rearview', + 'reason', + 'reassign', + 'reassure', + 'reattach', + 'reawake', + 'rebalance', + 'rebate', + 'rebel', + 'rebirth', + 'reboot', + 'reborn', + 'rebound', + 'rebuff', + 'rebuild', + 'rebuilt', + 'reburial', + 'rebuttal', + 'recall', + 'recant', + 'recapture', + 'recast', + 'recede', + 'recent', + 'recess', + 'recharger', + 'recipient', + 'recital', + 'recite', + 'reckless', + 'reclaim', + 'recliner', + 'reclining', + 'recluse', + 'reclusive', + 'recognize', + 'recoil', + 'recollect', + 'recolor', + 'reconcile', + 'reconfirm', + 'reconvene', + 'recopy', + 'record', + 'recount', + 'recoup', + 'recovery', + 'recreate', + 'rectal', + 'rectangle', + 'rectified', + 'rectify', + 'recycled', + 'recycler', + 'recycling', + 'reemerge', + 'reenact', + 'reenter', + 'reentry', + 'reexamine', + 'referable', + 'referee', + 'reference', + 'refill', + 'refinance', + 'refined', + 'refinery', + 'refining', + 'refinish', + 'reflected', + 'reflector', + 'reflex', + 'reflux', + 'refocus', + 'refold', + 'reforest', + 'reformat', + 'reformed', + 'reformer', + 'reformist', + 'refract', + 'refrain', + 'refreeze', + 'refresh', + 'refried', + 'refueling', + 'refund', + 'refurbish', + 'refurnish', + 'refusal', + 'refuse', + 'refusing', + 'refutable', + 'refute', + 'regain', + 'regalia', + 'regally', + 'reggae', + 'regime', + 'region', + 'register', + 'registrar', + 'registry', + 'regress', + 'regretful', + 'regroup', + 'regular', + 'regulate', + 'regulator', + 'rehab', + 'reheat', + 'rehire', + 'rehydrate', + 'reimburse', + 'reissue', + 'reiterate', + 'rejoice', + 'rejoicing', + 'rejoin', + 'rekindle', + 'relapse', + 'relapsing', + 'relatable', + 'related', + 'relation', + 'relative', + 'relax', + 'relay', + 'relearn', + 'release', + 'relenting', + 'reliable', + 'reliably', + 'reliance', + 'reliant', + 'relic', + 'relieve', + 'relieving', + 'relight', + 'relish', + 'relive', + 'reload', + 'relocate', + 'relock', + 'reluctant', + 'rely', + 'remake', + 'remark', + 'remarry', + 'rematch', + 'remedial', + 'remedy', + 'remember', + 'reminder', + 'remindful', + 'remission', + 'remix', + 'remnant', + 'remodeler', + 'remold', + 'remorse', + 'remote', + 'removable', + 'removal', + 'removed', + 'remover', + 'removing', + 'rename', + 'renderer', + 'rendering', + 'rendition', + 'renegade', + 'renewable', + 'renewably', + 'renewal', + 'renewed', + 'renounce', + 'renovate', + 'renovator', + 'rentable', + 'rental', + 'rented', + 'renter', + 'reoccupy', + 'reoccur', + 'reopen', + 'reorder', + 'repackage', + 'repacking', + 'repaint', + 'repair', + 'repave', + 'repaying', + 'repayment', + 'repeal', + 'repeated', + 'repeater', + 'repent', + 'rephrase', + 'replace', + 'replay', + 'replica', + 'reply', + 'reporter', + 'repose', + 'repossess', + 'repost', + 'repressed', + 'reprimand', + 'reprint', + 'reprise', + 'reproach', + 'reprocess', + 'reproduce', + 'reprogram', + 'reps', + 'reptile', + 'reptilian', + 'repugnant', + 'repulsion', + 'repulsive', + 'repurpose', + 'reputable', + 'reputably', + 'request', + 'require', + 'requisite', + 'reroute', + 'rerun', + 'resale', + 'resample', + 'rescuer', + 'reseal', + 'research', + 'reselect', + 'reseller', + 'resemble', + 'resend', + 'resent', + 'reset', + 'reshape', + 'reshoot', + 'reshuffle', + 'residence', + 'residency', + 'resident', + 'residual', + 'residue', + 'resigned', + 'resilient', + 'resistant', + 'resisting', + 'resize', + 'resolute', + 'resolved', + 'resonant', + 'resonate', + 'resort', + 'resource', + 'respect', + 'resubmit', + 'result', + 'resume', + 'resupply', + 'resurface', + 'resurrect', + 'retail', + 'retainer', + 'retaining', + 'retake', + 'retaliate', + 'retention', + 'rethink', + 'retinal', + 'retired', + 'retiree', + 'retiring', + 'retold', + 'retool', + 'retorted', + 'retouch', + 'retrace', + 'retract', + 'retrain', + 'retread', + 'retreat', + 'retrial', + 'retrieval', + 'retriever', + 'retry', + 'return', + 'retying', + 'retype', + 'reunion', + 'reunite', + 'reusable', + 'reuse', + 'reveal', + 'reveler', + 'revenge', + 'revenue', + 'reverb', + 'revered', + 'reverence', + 'reverend', + 'reversal', + 'reverse', + 'reversing', + 'reversion', + 'revert', + 'revisable', + 'revise', + 'revision', + 'revisit', + 'revivable', + 'revival', + 'reviver', + 'reviving', + 'revocable', + 'revoke', + 'revolt', + 'revolver', + 'revolving', + 'reward', + 'rewash', + 'rewind', + 'rewire', + 'reword', + 'rework', + 'rewrap', + 'rewrite', + 'rhyme', + 'ribbon', + 'ribcage', + 'rice', + 'riches', + 'richly', + 'richness', + 'rickety', + 'ricotta', + 'riddance', + 'ridden', + 'ride', + 'riding', + 'rifling', + 'rift', + 'rigging', + 'rigid', + 'rigor', + 'rimless', + 'rimmed', + 'rind', + 'rink', + 'rinse', + 'rinsing', + 'riot', + 'ripcord', + 'ripeness', + 'ripening', + 'ripping', + 'ripple', + 'rippling', + 'riptide', + 'rise', + 'rising', + 'risk', + 'risotto', + 'ritalin', + 'ritzy', + 'rival', + 'riverbank', + 'riverbed', + 'riverboat', + 'riverside', + 'riveter', + 'riveting', + 'roamer', + 'roaming', + 'roast', + 'robbing', + 'robe', + 'robin', + 'robotics', + 'robust', + 'rockband', + 'rocker', + 'rocket', + 'rockfish', + 'rockiness', + 'rocking', + 'rocklike', + 'rockslide', + 'rockstar', + 'rocky', + 'rogue', + 'roman', + 'romp', + 'rope', + 'roping', + 'roster', + 'rosy', + 'rotten', + 'rotting', + 'rotunda', + 'roulette', + 'rounding', + 'roundish', + 'roundness', + 'roundup', + 'roundworm', + 'routine', + 'routing', + 'rover', + 'roving', + 'royal', + 'rubbed', + 'rubber', + 'rubbing', + 'rubble', + 'rubdown', + 'ruby', + 'ruckus', + 'rudder', + 'rug', + 'ruined', + 'rule', + 'rumble', + 'rumbling', + 'rummage', + 'rumor', + 'runaround', + 'rundown', + 'runner', + 'running', + 'runny', + 'runt', + 'runway', + 'rupture', + 'rural', + 'ruse', + 'rush', + 'rust', + 'rut', + 'sabbath', + 'sabotage', + 'sacrament', + 'sacred', + 'sacrifice', + 'sadden', + 'saddlebag', + 'saddled', + 'saddling', + 'sadly', + 'sadness', + 'safari', + 'safeguard', + 'safehouse', + 'safely', + 'safeness', + 'saffron', + 'saga', + 'sage', + 'sagging', + 'saggy', + 'said', + 'saint', + 'sake', + 'salad', + 'salami', + 'salaried', + 'salary', + 'saline', + 'salon', + 'saloon', + 'salsa', + 'salt', + 'salutary', + 'salute', + 'salvage', + 'salvaging', + 'salvation', + 'same', + 'sample', + 'sampling', + 'sanction', + 'sanctity', + 'sanctuary', + 'sandal', + 'sandbag', + 'sandbank', + 'sandbar', + 'sandblast', + 'sandbox', + 'sanded', + 'sandfish', + 'sanding', + 'sandlot', + 'sandpaper', + 'sandpit', + 'sandstone', + 'sandstorm', + 'sandworm', + 'sandy', + 'sanitary', + 'sanitizer', + 'sank', + 'santa', + 'sapling', + 'sappiness', + 'sappy', + 'sarcasm', + 'sarcastic', + 'sardine', + 'sash', + 'sasquatch', + 'sassy', + 'satchel', + 'satiable', + 'satin', + 'satirical', + 'satisfied', + 'satisfy', + 'saturate', + 'saturday', + 'sauciness', + 'saucy', + 'sauna', + 'savage', + 'savanna', + 'saved', + 'savings', + 'savior', + 'savor', + 'saxophone', + 'say', + 'scabbed', + 'scabby', + 'scalded', + 'scalding', + 'scale', + 'scaling', + 'scallion', + 'scallop', + 'scalping', + 'scam', + 'scandal', + 'scanner', + 'scanning', + 'scant', + 'scapegoat', + 'scarce', + 'scarcity', + 'scarecrow', + 'scared', + 'scarf', + 'scarily', + 'scariness', + 'scarring', + 'scary', + 'scavenger', + 'scenic', + 'schedule', + 'schematic', + 'scheme', + 'scheming', + 'schilling', + 'schnapps', + 'scholar', + 'science', + 'scientist', + 'scion', + 'scoff', + 'scolding', + 'scone', + 'scoop', + 'scooter', + 'scope', + 'scorch', + 'scorebook', + 'scorecard', + 'scored', + 'scoreless', + 'scorer', + 'scoring', + 'scorn', + 'scorpion', + 'scotch', + 'scoundrel', + 'scoured', + 'scouring', + 'scouting', + 'scouts', + 'scowling', + 'scrabble', + 'scraggly', + 'scrambled', + 'scrambler', + 'scrap', + 'scratch', + 'scrawny', + 'screen', + 'scribble', + 'scribe', + 'scribing', + 'scrimmage', + 'script', + 'scroll', + 'scrooge', + 'scrounger', + 'scrubbed', + 'scrubber', + 'scruffy', + 'scrunch', + 'scrutiny', + 'scuba', + 'scuff', + 'sculptor', + 'sculpture', + 'scurvy', + 'scuttle', + 'secluded', + 'secluding', + 'seclusion', + 'second', + 'secrecy', + 'secret', + 'sectional', + 'sector', + 'secular', + 'securely', + 'security', + 'sedan', + 'sedate', + 'sedation', + 'sedative', + 'sediment', + 'seduce', + 'seducing', + 'segment', + 'seismic', + 'seizing', + 'seldom', + 'selected', + 'selection', + 'selective', + 'selector', + 'self', + 'seltzer', + 'semantic', + 'semester', + 'semicolon', + 'semifinal', + 'seminar', + 'semisoft', + 'semisweet', + 'senate', + 'senator', + 'send', + 'senior', + 'senorita', + 'sensation', + 'sensitive', + 'sensitize', + 'sensually', + 'sensuous', + 'sepia', + 'september', + 'septic', + 'septum', + 'sequel', + 'sequence', + 'sequester', + 'series', + 'sermon', + 'serotonin', + 'serpent', + 'serrated', + 'serve', + 'service', + 'serving', + 'sesame', + 'sessions', + 'setback', + 'setting', + 'settle', + 'settling', + 'setup', + 'sevenfold', + 'seventeen', + 'seventh', + 'seventy', + 'severity', + 'shabby', + 'shack', + 'shaded', + 'shadily', + 'shadiness', + 'shading', + 'shadow', + 'shady', + 'shaft', + 'shakable', + 'shakily', + 'shakiness', + 'shaking', + 'shaky', + 'shale', + 'shallot', + 'shallow', + 'shame', + 'shampoo', + 'shamrock', + 'shank', + 'shanty', + 'shape', + 'shaping', + 'share', + 'sharpener', + 'sharper', + 'sharpie', + 'sharply', + 'sharpness', + 'shawl', + 'sheath', + 'shed', + 'sheep', + 'sheet', + 'shelf', + 'shell', + 'shelter', + 'shelve', + 'shelving', + 'sherry', + 'shield', + 'shifter', + 'shifting', + 'shiftless', + 'shifty', + 'shimmer', + 'shimmy', + 'shindig', + 'shine', + 'shingle', + 'shininess', + 'shining', + 'shiny', + 'ship', + 'shirt', + 'shivering', + 'shock', + 'shone', + 'shoplift', + 'shopper', + 'shopping', + 'shoptalk', + 'shore', + 'shortage', + 'shortcake', + 'shortcut', + 'shorten', + 'shorter', + 'shorthand', + 'shortlist', + 'shortly', + 'shortness', + 'shorts', + 'shortwave', + 'shorty', + 'shout', + 'shove', + 'showbiz', + 'showcase', + 'showdown', + 'shower', + 'showgirl', + 'showing', + 'showman', + 'shown', + 'showoff', + 'showpiece', + 'showplace', + 'showroom', + 'showy', + 'shrank', + 'shrapnel', + 'shredder', + 'shredding', + 'shrewdly', + 'shriek', + 'shrill', + 'shrimp', + 'shrine', + 'shrink', + 'shrivel', + 'shrouded', + 'shrubbery', + 'shrubs', + 'shrug', + 'shrunk', + 'shucking', + 'shudder', + 'shuffle', + 'shuffling', + 'shun', + 'shush', + 'shut', + 'shy', + 'siamese', + 'siberian', + 'sibling', + 'siding', + 'sierra', + 'siesta', + 'sift', + 'sighing', + 'silenced', + 'silencer', + 'silent', + 'silica', + 'silicon', + 'silk', + 'silliness', + 'silly', + 'silo', + 'silt', + 'silver', + 'similarly', + 'simile', + 'simmering', + 'simple', + 'simplify', + 'simply', + 'sincere', + 'sincerity', + 'singer', + 'singing', + 'single', + 'singular', + 'sinister', + 'sinless', + 'sinner', + 'sinuous', + 'sip', + 'siren', + 'sister', + 'sitcom', + 'sitter', + 'sitting', + 'situated', + 'situation', + 'sixfold', + 'sixteen', + 'sixth', + 'sixties', + 'sixtieth', + 'sixtyfold', + 'sizable', + 'sizably', + 'size', + 'sizing', + 'sizzle', + 'sizzling', + 'skater', + 'skating', + 'skedaddle', + 'skeletal', + 'skeleton', + 'skeptic', + 'sketch', + 'skewed', + 'skewer', + 'skid', + 'skied', + 'skier', + 'skies', + 'skiing', + 'skilled', + 'skillet', + 'skillful', + 'skimmed', + 'skimmer', + 'skimming', + 'skimpily', + 'skincare', + 'skinhead', + 'skinless', + 'skinning', + 'skinny', + 'skintight', + 'skipper', + 'skipping', + 'skirmish', + 'skirt', + 'skittle', + 'skydiver', + 'skylight', + 'skyline', + 'skype', + 'skyrocket', + 'skyward', + 'slab', + 'slacked', + 'slacker', + 'slacking', + 'slackness', + 'slacks', + 'slain', + 'slam', + 'slander', + 'slang', + 'slapping', + 'slapstick', + 'slashed', + 'slashing', + 'slate', + 'slather', + 'slaw', + 'sled', + 'sleek', + 'sleep', + 'sleet', + 'sleeve', + 'slept', + 'sliceable', + 'sliced', + 'slicer', + 'slicing', + 'slick', + 'slider', + 'slideshow', + 'sliding', + 'slighted', + 'slighting', + 'slightly', + 'slimness', + 'slimy', + 'slinging', + 'slingshot', + 'slinky', + 'slip', + 'slit', + 'sliver', + 'slobbery', + 'slogan', + 'sloped', + 'sloping', + 'sloppily', + 'sloppy', + 'slot', + 'slouching', + 'slouchy', + 'sludge', + 'slug', + 'slum', + 'slurp', + 'slush', + 'sly', + 'small', + 'smartly', + 'smartness', + 'smasher', + 'smashing', + 'smashup', + 'smell', + 'smelting', + 'smile', + 'smilingly', + 'smirk', + 'smite', + 'smith', + 'smitten', + 'smock', + 'smog', + 'smoked', + 'smokeless', + 'smokiness', + 'smoking', + 'smoky', + 'smolder', + 'smooth', + 'smother', + 'smudge', + 'smudgy', + 'smuggler', + 'smuggling', + 'smugly', + 'smugness', + 'snack', + 'snagged', + 'snaking', + 'snap', + 'snare', + 'snarl', + 'snazzy', + 'sneak', + 'sneer', + 'sneeze', + 'sneezing', + 'snide', + 'sniff', + 'snippet', + 'snipping', + 'snitch', + 'snooper', + 'snooze', + 'snore', + 'snoring', + 'snorkel', + 'snort', + 'snout', + 'snowbird', + 'snowboard', + 'snowbound', + 'snowcap', + 'snowdrift', + 'snowdrop', + 'snowfall', + 'snowfield', + 'snowflake', + 'snowiness', + 'snowless', + 'snowman', + 'snowplow', + 'snowshoe', + 'snowstorm', + 'snowsuit', + 'snowy', + 'snub', + 'snuff', + 'snuggle', + 'snugly', + 'snugness', + 'speak', + 'spearfish', + 'spearhead', + 'spearman', + 'spearmint', + 'species', + 'specimen', + 'specked', + 'speckled', + 'specks', + 'spectacle', + 'spectator', + 'spectrum', + 'speculate', + 'speech', + 'speed', + 'spellbind', + 'speller', + 'spelling', + 'spendable', + 'spender', + 'spending', + 'spent', + 'spew', + 'sphere', + 'spherical', + 'sphinx', + 'spider', + 'spied', + 'spiffy', + 'spill', + 'spilt', + 'spinach', + 'spinal', + 'spindle', + 'spinner', + 'spinning', + 'spinout', + 'spinster', + 'spiny', + 'spiral', + 'spirited', + 'spiritism', + 'spirits', + 'spiritual', + 'splashed', + 'splashing', + 'splashy', + 'splatter', + 'spleen', + 'splendid', + 'splendor', + 'splice', + 'splicing', + 'splinter', + 'splotchy', + 'splurge', + 'spoilage', + 'spoiled', + 'spoiler', + 'spoiling', + 'spoils', + 'spoken', + 'spokesman', + 'sponge', + 'spongy', + 'sponsor', + 'spoof', + 'spookily', + 'spooky', + 'spool', + 'spoon', + 'spore', + 'sporting', + 'sports', + 'sporty', + 'spotless', + 'spotlight', + 'spotted', + 'spotter', + 'spotting', + 'spotty', + 'spousal', + 'spouse', + 'spout', + 'sprain', + 'sprang', + 'sprawl', + 'spray', + 'spree', + 'sprig', + 'spring', + 'sprinkled', + 'sprinkler', + 'sprint', + 'sprite', + 'sprout', + 'spruce', + 'sprung', + 'spry', + 'spud', + 'spur', + 'sputter', + 'spyglass', + 'squabble', + 'squad', + 'squall', + 'squander', + 'squash', + 'squatted', + 'squatter', + 'squatting', + 'squeak', + 'squealer', + 'squealing', + 'squeamish', + 'squeegee', + 'squeeze', + 'squeezing', + 'squid', + 'squiggle', + 'squiggly', + 'squint', + 'squire', + 'squirt', + 'squishier', + 'squishy', + 'stability', + 'stabilize', + 'stable', + 'stack', + 'stadium', + 'staff', + 'stage', + 'staging', + 'stagnant', + 'stagnate', + 'stainable', + 'stained', + 'staining', + 'stainless', + 'stalemate', + 'staleness', + 'stalling', + 'stallion', + 'stamina', + 'stammer', + 'stamp', + 'stand', + 'stank', + 'staple', + 'stapling', + 'starboard', + 'starch', + 'stardom', + 'stardust', + 'starfish', + 'stargazer', + 'staring', + 'stark', + 'starless', + 'starlet', + 'starlight', + 'starlit', + 'starring', + 'starry', + 'starship', + 'starter', + 'starting', + 'startle', + 'startling', + 'startup', + 'starved', + 'starving', + 'stash', + 'state', + 'static', + 'statistic', + 'statue', + 'stature', + 'status', + 'statute', + 'statutory', + 'staunch', + 'stays', + 'steadfast', + 'steadier', + 'steadily', + 'steadying', + 'steam', + 'steed', + 'steep', + 'steerable', + 'steering', + 'steersman', + 'stegosaur', + 'stellar', + 'stem', + 'stench', + 'stencil', + 'step', + 'stereo', + 'sterile', + 'sterility', + 'sterilize', + 'sterling', + 'sternness', + 'sternum', + 'stew', + 'stick', + 'stiffen', + 'stiffly', + 'stiffness', + 'stifle', + 'stifling', + 'stillness', + 'stilt', + 'stimulant', + 'stimulate', + 'stimuli', + 'stimulus', + 'stinger', + 'stingily', + 'stinging', + 'stingray', + 'stingy', + 'stinking', + 'stinky', + 'stipend', + 'stipulate', + 'stir', + 'stitch', + 'stock', + 'stoic', + 'stoke', + 'stole', + 'stomp', + 'stonewall', + 'stoneware', + 'stonework', + 'stoning', + 'stony', + 'stood', + 'stooge', + 'stool', + 'stoop', + 'stoplight', + 'stoppable', + 'stoppage', + 'stopped', + 'stopper', + 'stopping', + 'stopwatch', + 'storable', + 'storage', + 'storeroom', + 'storewide', + 'storm', + 'stout', + 'stove', + 'stowaway', + 'stowing', + 'straddle', + 'straggler', + 'strained', + 'strainer', + 'straining', + 'strangely', + 'stranger', + 'strangle', + 'strategic', + 'strategy', + 'stratus', + 'straw', + 'stray', + 'streak', + 'stream', + 'street', + 'strength', + 'strenuous', + 'strep', + 'stress', + 'stretch', + 'strewn', + 'stricken', + 'strict', + 'stride', + 'strife', + 'strike', + 'striking', + 'strive', + 'striving', + 'strobe', + 'strode', + 'stroller', + 'strongbox', + 'strongly', + 'strongman', + 'struck', + 'structure', + 'strudel', + 'struggle', + 'strum', + 'strung', + 'strut', + 'stubbed', + 'stubble', + 'stubbly', + 'stubborn', + 'stucco', + 'stuck', + 'student', + 'studied', + 'studio', + 'study', + 'stuffed', + 'stuffing', + 'stuffy', + 'stumble', + 'stumbling', + 'stump', + 'stung', + 'stunned', + 'stunner', + 'stunning', + 'stunt', + 'stupor', + 'sturdily', + 'sturdy', + 'styling', + 'stylishly', + 'stylist', + 'stylized', + 'stylus', + 'suave', + 'subarctic', + 'subatomic', + 'subdivide', + 'subdued', + 'subduing', + 'subfloor', + 'subgroup', + 'subheader', + 'subject', + 'sublease', + 'sublet', + 'sublevel', + 'sublime', + 'submarine', + 'submerge', + 'submersed', + 'submitter', + 'subpanel', + 'subpar', + 'subplot', + 'subprime', + 'subscribe', + 'subscript', + 'subsector', + 'subside', + 'subsiding', + 'subsidize', + 'subsidy', + 'subsoil', + 'subsonic', + 'substance', + 'subsystem', + 'subtext', + 'subtitle', + 'subtly', + 'subtotal', + 'subtract', + 'subtype', + 'suburb', + 'subway', + 'subwoofer', + 'subzero', + 'succulent', + 'such', + 'suction', + 'sudden', + 'sudoku', + 'suds', + 'sufferer', + 'suffering', + 'suffice', + 'suffix', + 'suffocate', + 'suffrage', + 'sugar', + 'suggest', + 'suing', + 'suitable', + 'suitably', + 'suitcase', + 'suitor', + 'sulfate', + 'sulfide', + 'sulfite', + 'sulfur', + 'sulk', + 'sullen', + 'sulphate', + 'sulphuric', + 'sultry', + 'superbowl', + 'superglue', + 'superhero', + 'superior', + 'superjet', + 'superman', + 'supermom', + 'supernova', + 'supervise', + 'supper', + 'supplier', + 'supply', + 'support', + 'supremacy', + 'supreme', + 'surcharge', + 'surely', + 'sureness', + 'surface', + 'surfacing', + 'surfboard', + 'surfer', + 'surgery', + 'surgical', + 'surging', + 'surname', + 'surpass', + 'surplus', + 'surprise', + 'surreal', + 'surrender', + 'surrogate', + 'surround', + 'survey', + 'survival', + 'survive', + 'surviving', + 'survivor', + 'sushi', + 'suspect', + 'suspend', + 'suspense', + 'sustained', + 'sustainer', + 'swab', + 'swaddling', + 'swagger', + 'swampland', + 'swan', + 'swapping', + 'swarm', + 'sway', + 'swear', + 'sweat', + 'sweep', + 'swell', + 'swept', + 'swerve', + 'swifter', + 'swiftly', + 'swiftness', + 'swimmable', + 'swimmer', + 'swimming', + 'swimsuit', + 'swimwear', + 'swinger', + 'swinging', + 'swipe', + 'swirl', + 'switch', + 'swivel', + 'swizzle', + 'swooned', + 'swoop', + 'swoosh', + 'swore', + 'sworn', + 'swung', + 'sycamore', + 'sympathy', + 'symphonic', + 'symphony', + 'symptom', + 'synapse', + 'syndrome', + 'synergy', + 'synopses', + 'synopsis', + 'synthesis', + 'synthetic', + 'syrup', + 'system', + 't-shirt', + 'tabasco', + 'tabby', + 'tableful', + 'tables', + 'tablet', + 'tableware', + 'tabloid', + 'tackiness', + 'tacking', + 'tackle', + 'tackling', + 'tacky', + 'taco', + 'tactful', + 'tactical', + 'tactics', + 'tactile', + 'tactless', + 'tadpole', + 'taekwondo', + 'tag', + 'tainted', + 'take', + 'taking', + 'talcum', + 'talisman', + 'tall', + 'talon', + 'tamale', + 'tameness', + 'tamer', + 'tamper', + 'tank', + 'tanned', + 'tannery', + 'tanning', + 'tantrum', + 'tapeless', + 'tapered', + 'tapering', + 'tapestry', + 'tapioca', + 'tapping', + 'taps', + 'tarantula', + 'target', + 'tarmac', + 'tarnish', + 'tarot', + 'tartar', + 'tartly', + 'tartness', + 'task', + 'tassel', + 'taste', + 'tastiness', + 'tasting', + 'tasty', + 'tattered', + 'tattle', + 'tattling', + 'tattoo', + 'taunt', + 'tavern', + 'thank', + 'that', + 'thaw', + 'theater', + 'theatrics', + 'thee', + 'theft', + 'theme', + 'theology', + 'theorize', + 'thermal', + 'thermos', + 'thesaurus', + 'these', + 'thesis', + 'thespian', + 'thicken', + 'thicket', + 'thickness', + 'thieving', + 'thievish', + 'thigh', + 'thimble', + 'thing', + 'think', + 'thinly', + 'thinner', + 'thinness', + 'thinning', + 'thirstily', + 'thirsting', + 'thirsty', + 'thirteen', + 'thirty', + 'thong', + 'thorn', + 'those', + 'thousand', + 'thrash', + 'thread', + 'threaten', + 'threefold', + 'thrift', + 'thrill', + 'thrive', + 'thriving', + 'throat', + 'throbbing', + 'throng', + 'throttle', + 'throwaway', + 'throwback', + 'thrower', + 'throwing', + 'thud', + 'thumb', + 'thumping', + 'thursday', + 'thus', + 'thwarting', + 'thyself', + 'tiara', + 'tibia', + 'tidal', + 'tidbit', + 'tidiness', + 'tidings', + 'tidy', + 'tiger', + 'tighten', + 'tightly', + 'tightness', + 'tightrope', + 'tightwad', + 'tigress', + 'tile', + 'tiling', + 'till', + 'tilt', + 'timid', + 'timing', + 'timothy', + 'tinderbox', + 'tinfoil', + 'tingle', + 'tingling', + 'tingly', + 'tinker', + 'tinkling', + 'tinsel', + 'tinsmith', + 'tint', + 'tinwork', + 'tiny', + 'tipoff', + 'tipped', + 'tipper', + 'tipping', + 'tiptoeing', + 'tiptop', + 'tiring', + 'tissue', + 'trace', + 'tracing', + 'track', + 'traction', + 'tractor', + 'trade', + 'trading', + 'tradition', + 'traffic', + 'tragedy', + 'trailing', + 'trailside', + 'train', + 'traitor', + 'trance', + 'tranquil', + 'transfer', + 'transform', + 'translate', + 'transpire', + 'transport', + 'transpose', + 'trapdoor', + 'trapeze', + 'trapezoid', + 'trapped', + 'trapper', + 'trapping', + 'traps', + 'trash', + 'travel', + 'traverse', + 'travesty', + 'tray', + 'treachery', + 'treading', + 'treadmill', + 'treason', + 'treat', + 'treble', + 'tree', + 'trekker', + 'tremble', + 'trembling', + 'tremor', + 'trench', + 'trend', + 'trespass', + 'triage', + 'trial', + 'triangle', + 'tribesman', + 'tribunal', + 'tribune', + 'tributary', + 'tribute', + 'triceps', + 'trickery', + 'trickily', + 'tricking', + 'trickle', + 'trickster', + 'tricky', + 'tricolor', + 'tricycle', + 'trident', + 'tried', + 'trifle', + 'trifocals', + 'trillion', + 'trilogy', + 'trimester', + 'trimmer', + 'trimming', + 'trimness', + 'trinity', + 'trio', + 'tripod', + 'tripping', + 'triumph', + 'trivial', + 'trodden', + 'trolling', + 'trombone', + 'trophy', + 'tropical', + 'tropics', + 'trouble', + 'troubling', + 'trough', + 'trousers', + 'trout', + 'trowel', + 'truce', + 'truck', + 'truffle', + 'trump', + 'trunks', + 'trustable', + 'trustee', + 'trustful', + 'trusting', + 'trustless', + 'truth', + 'try', + 'tubby', + 'tubeless', + 'tubular', + 'tucking', + 'tuesday', + 'tug', + 'tuition', + 'tulip', + 'tumble', + 'tumbling', + 'tummy', + 'turban', + 'turbine', + 'turbofan', + 'turbojet', + 'turbulent', + 'turf', + 'turkey', + 'turmoil', + 'turret', + 'turtle', + 'tusk', + 'tutor', + 'tutu', + 'tux', + 'tweak', + 'tweed', + 'tweet', + 'tweezers', + 'twelve', + 'twentieth', + 'twenty', + 'twerp', + 'twice', + 'twiddle', + 'twiddling', + 'twig', + 'twilight', + 'twine', + 'twins', + 'twirl', + 'twistable', + 'twisted', + 'twister', + 'twisting', + 'twisty', + 'twitch', + 'twitter', + 'tycoon', + 'tying', + 'tyke', + 'udder', + 'ultimate', + 'ultimatum', + 'ultra', + 'umbilical', + 'umbrella', + 'umpire', + 'unabashed', + 'unable', + 'unadorned', + 'unadvised', + 'unafraid', + 'unaired', + 'unaligned', + 'unaltered', + 'unarmored', + 'unashamed', + 'unaudited', + 'unawake', + 'unaware', + 'unbaked', + 'unbalance', + 'unbeaten', + 'unbend', + 'unbent', + 'unbiased', + 'unbitten', + 'unblended', + 'unblessed', + 'unblock', + 'unbolted', + 'unbounded', + 'unboxed', + 'unbraided', + 'unbridle', + 'unbroken', + 'unbuckled', + 'unbundle', + 'unburned', + 'unbutton', + 'uncanny', + 'uncapped', + 'uncaring', + 'uncertain', + 'unchain', + 'unchanged', + 'uncharted', + 'uncheck', + 'uncivil', + 'unclad', + 'unclaimed', + 'unclamped', + 'unclasp', + 'uncle', + 'unclip', + 'uncloak', + 'unclog', + 'unclothed', + 'uncoated', + 'uncoiled', + 'uncolored', + 'uncombed', + 'uncommon', + 'uncooked', + 'uncork', + 'uncorrupt', + 'uncounted', + 'uncouple', + 'uncouth', + 'uncover', + 'uncross', + 'uncrown', + 'uncrushed', + 'uncured', + 'uncurious', + 'uncurled', + 'uncut', + 'undamaged', + 'undated', + 'undaunted', + 'undead', + 'undecided', + 'undefined', + 'underage', + 'underarm', + 'undercoat', + 'undercook', + 'undercut', + 'underdog', + 'underdone', + 'underfed', + 'underfeed', + 'underfoot', + 'undergo', + 'undergrad', + 'underhand', + 'underline', + 'underling', + 'undermine', + 'undermost', + 'underpaid', + 'underpass', + 'underpay', + 'underrate', + 'undertake', + 'undertone', + 'undertook', + 'undertow', + 'underuse', + 'underwear', + 'underwent', + 'underwire', + 'undesired', + 'undiluted', + 'undivided', + 'undocked', + 'undoing', + 'undone', + 'undrafted', + 'undress', + 'undrilled', + 'undusted', + 'undying', + 'unearned', + 'unearth', + 'unease', + 'uneasily', + 'uneasy', + 'uneatable', + 'uneaten', + 'unedited', + 'unelected', + 'unending', + 'unengaged', + 'unenvied', + 'unequal', + 'unethical', + 'uneven', + 'unexpired', + 'unexposed', + 'unfailing', + 'unfair', + 'unfasten', + 'unfazed', + 'unfeeling', + 'unfiled', + 'unfilled', + 'unfitted', + 'unfitting', + 'unfixable', + 'unfixed', + 'unflawed', + 'unfocused', + 'unfold', + 'unfounded', + 'unframed', + 'unfreeze', + 'unfrosted', + 'unfrozen', + 'unfunded', + 'unglazed', + 'ungloved', + 'unglue', + 'ungodly', + 'ungraded', + 'ungreased', + 'unguarded', + 'unguided', + 'unhappily', + 'unhappy', + 'unharmed', + 'unhealthy', + 'unheard', + 'unhearing', + 'unheated', + 'unhelpful', + 'unhidden', + 'unhinge', + 'unhitched', + 'unholy', + 'unhook', + 'unicorn', + 'unicycle', + 'unified', + 'unifier', + 'uniformed', + 'uniformly', + 'unify', + 'unimpeded', + 'uninjured', + 'uninstall', + 'uninsured', + 'uninvited', + 'union', + 'uniquely', + 'unisexual', + 'unison', + 'unissued', + 'unit', + 'universal', + 'universe', + 'unjustly', + 'unkempt', + 'unkind', + 'unknotted', + 'unknowing', + 'unknown', + 'unlaced', + 'unlatch', + 'unlawful', + 'unleaded', + 'unlearned', + 'unleash', + 'unless', + 'unleveled', + 'unlighted', + 'unlikable', + 'unlimited', + 'unlined', + 'unlinked', + 'unlisted', + 'unlit', + 'unlivable', + 'unloaded', + 'unloader', + 'unlocked', + 'unlocking', + 'unlovable', + 'unloved', + 'unlovely', + 'unloving', + 'unluckily', + 'unlucky', + 'unmade', + 'unmanaged', + 'unmanned', + 'unmapped', + 'unmarked', + 'unmasked', + 'unmasking', + 'unmatched', + 'unmindful', + 'unmixable', + 'unmixed', + 'unmolded', + 'unmoral', + 'unmovable', + 'unmoved', + 'unmoving', + 'unnamable', + 'unnamed', + 'unnatural', + 'unneeded', + 'unnerve', + 'unnerving', + 'unnoticed', + 'unopened', + 'unopposed', + 'unpack', + 'unpadded', + 'unpaid', + 'unpainted', + 'unpaired', + 'unpaved', + 'unpeeled', + 'unpicked', + 'unpiloted', + 'unpinned', + 'unplanned', + 'unplanted', + 'unpleased', + 'unpledged', + 'unplowed', + 'unplug', + 'unpopular', + 'unproven', + 'unquote', + 'unranked', + 'unrated', + 'unraveled', + 'unreached', + 'unread', + 'unreal', + 'unreeling', + 'unrefined', + 'unrelated', + 'unrented', + 'unrest', + 'unretired', + 'unrevised', + 'unrigged', + 'unripe', + 'unrivaled', + 'unroasted', + 'unrobed', + 'unroll', + 'unruffled', + 'unruly', + 'unrushed', + 'unsaddle', + 'unsafe', + 'unsaid', + 'unsalted', + 'unsaved', + 'unsavory', + 'unscathed', + 'unscented', + 'unscrew', + 'unsealed', + 'unseated', + 'unsecured', + 'unseeing', + 'unseemly', + 'unseen', + 'unselect', + 'unselfish', + 'unsent', + 'unsettled', + 'unshackle', + 'unshaken', + 'unshaved', + 'unshaven', + 'unsheathe', + 'unshipped', + 'unsightly', + 'unsigned', + 'unskilled', + 'unsliced', + 'unsmooth', + 'unsnap', + 'unsocial', + 'unsoiled', + 'unsold', + 'unsolved', + 'unsorted', + 'unspoiled', + 'unspoken', + 'unstable', + 'unstaffed', + 'unstamped', + 'unsteady', + 'unsterile', + 'unstirred', + 'unstitch', + 'unstopped', + 'unstuck', + 'unstuffed', + 'unstylish', + 'unsubtle', + 'unsubtly', + 'unsuited', + 'unsure', + 'unsworn', + 'untagged', + 'untainted', + 'untaken', + 'untamed', + 'untangled', + 'untapped', + 'untaxed', + 'unthawed', + 'unthread', + 'untidy', + 'untie', + 'until', + 'untimed', + 'untimely', + 'untitled', + 'untoasted', + 'untold', + 'untouched', + 'untracked', + 'untrained', + 'untreated', + 'untried', + 'untrimmed', + 'untrue', + 'untruth', + 'unturned', + 'untwist', + 'untying', + 'unusable', + 'unused', + 'unusual', + 'unvalued', + 'unvaried', + 'unvarying', + 'unveiled', + 'unveiling', + 'unvented', + 'unviable', + 'unvisited', + 'unvocal', + 'unwanted', + 'unwarlike', + 'unwary', + 'unwashed', + 'unwatched', + 'unweave', + 'unwed', + 'unwelcome', + 'unwell', + 'unwieldy', + 'unwilling', + 'unwind', + 'unwired', + 'unwitting', + 'unwomanly', + 'unworldly', + 'unworn', + 'unworried', + 'unworthy', + 'unwound', + 'unwoven', + 'unwrapped', + 'unwritten', + 'unzip', + 'upbeat', + 'upchuck', + 'upcoming', + 'upcountry', + 'update', + 'upfront', + 'upgrade', + 'upheaval', + 'upheld', + 'uphill', + 'uphold', + 'uplifted', + 'uplifting', + 'upload', + 'upon', + 'upper', + 'upright', + 'uprising', + 'upriver', + 'uproar', + 'uproot', + 'upscale', + 'upside', + 'upstage', + 'upstairs', + 'upstart', + 'upstate', + 'upstream', + 'upstroke', + 'upswing', + 'uptake', + 'uptight', + 'uptown', + 'upturned', + 'upward', + 'upwind', + 'uranium', + 'urban', + 'urchin', + 'urethane', + 'urgency', + 'urgent', + 'urging', + 'urologist', + 'urology', + 'usable', + 'usage', + 'useable', + 'used', + 'uselessly', + 'user', + 'usher', + 'usual', + 'utensil', + 'utility', + 'utilize', + 'utmost', + 'utopia', + 'utter', + 'vacancy', + 'vacant', + 'vacate', + 'vacation', + 'vagabond', + 'vagrancy', + 'vagrantly', + 'vaguely', + 'vagueness', + 'valiant', + 'valid', + 'valium', + 'valley', + 'valuables', + 'value', + 'vanilla', + 'vanish', + 'vanity', + 'vanquish', + 'vantage', + 'vaporizer', + 'variable', + 'variably', + 'varied', + 'variety', + 'various', + 'varmint', + 'varnish', + 'varsity', + 'varying', + 'vascular', + 'vaseline', + 'vastly', + 'vastness', + 'veal', + 'vegan', + 'veggie', + 'vehicular', + 'velcro', + 'velocity', + 'velvet', + 'vendetta', + 'vending', + 'vendor', + 'veneering', + 'vengeful', + 'venomous', + 'ventricle', + 'venture', + 'venue', + 'venus', + 'verbalize', + 'verbally', + 'verbose', + 'verdict', + 'verify', + 'verse', + 'version', + 'versus', + 'vertebrae', + 'vertical', + 'vertigo', + 'very', + 'vessel', + 'vest', + 'veteran', + 'veto', + 'vexingly', + 'viability', + 'viable', + 'vibes', + 'vice', + 'vicinity', + 'victory', + 'video', + 'viewable', + 'viewer', + 'viewing', + 'viewless', + 'viewpoint', + 'vigorous', + 'village', + 'villain', + 'vindicate', + 'vineyard', + 'vintage', + 'violate', + 'violation', + 'violator', + 'violet', + 'violin', + 'viper', + 'viral', + 'virtual', + 'virtuous', + 'virus', + 'visa', + 'viscosity', + 'viscous', + 'viselike', + 'visible', + 'visibly', + 'vision', + 'visiting', + 'visitor', + 'visor', + 'vista', + 'vitality', + 'vitalize', + 'vitally', + 'vitamins', + 'vivacious', + 'vividly', + 'vividness', + 'vixen', + 'vocalist', + 'vocalize', + 'vocally', + 'vocation', + 'voice', + 'voicing', + 'void', + 'volatile', + 'volley', + 'voltage', + 'volumes', + 'voter', + 'voting', + 'voucher', + 'vowed', + 'vowel', + 'voyage', + 'wackiness', + 'wad', + 'wafer', + 'waffle', + 'waged', + 'wager', + 'wages', + 'waggle', + 'wagon', + 'wake', + 'waking', + 'walk', + 'walmart', + 'walnut', + 'walrus', + 'waltz', + 'wand', + 'wannabe', + 'wanted', + 'wanting', + 'wasabi', + 'washable', + 'washbasin', + 'washboard', + 'washbowl', + 'washcloth', + 'washday', + 'washed', + 'washer', + 'washhouse', + 'washing', + 'washout', + 'washroom', + 'washstand', + 'washtub', + 'wasp', + 'wasting', + 'watch', + 'water', + 'waviness', + 'waving', + 'wavy', + 'whacking', + 'whacky', + 'wham', + 'wharf', + 'wheat', + 'whenever', + 'whiff', + 'whimsical', + 'whinny', + 'whiny', + 'whisking', + 'whoever', + 'whole', + 'whomever', + 'whoopee', + 'whooping', + 'whoops', + 'why', + 'wick', + 'widely', + 'widen', + 'widget', + 'widow', + 'width', + 'wieldable', + 'wielder', + 'wife', + 'wifi', + 'wikipedia', + 'wildcard', + 'wildcat', + 'wilder', + 'wildfire', + 'wildfowl', + 'wildland', + 'wildlife', + 'wildly', + 'wildness', + 'willed', + 'willfully', + 'willing', + 'willow', + 'willpower', + 'wilt', + 'wimp', + 'wince', + 'wincing', + 'wind', + 'wing', + 'winking', + 'winner', + 'winnings', + 'winter', + 'wipe', + 'wired', + 'wireless', + 'wiring', + 'wiry', + 'wisdom', + 'wise', + 'wish', + 'wisplike', + 'wispy', + 'wistful', + 'wizard', + 'wobble', + 'wobbling', + 'wobbly', + 'wok', + 'wolf', + 'wolverine', + 'womanhood', + 'womankind', + 'womanless', + 'womanlike', + 'womanly', + 'womb', + 'woof', + 'wooing', + 'wool', + 'woozy', + 'word', + 'work', + 'worried', + 'worrier', + 'worrisome', + 'worry', + 'worsening', + 'worshiper', + 'worst', + 'wound', + 'woven', + 'wow', + 'wrangle', + 'wrath', + 'wreath', + 'wreckage', + 'wrecker', + 'wrecking', + 'wrench', + 'wriggle', + 'wriggly', + 'wrinkle', + 'wrinkly', + 'wrist', + 'writing', + 'written', + 'wrongdoer', + 'wronged', + 'wrongful', + 'wrongly', + 'wrongness', + 'wrought', + 'xbox', + 'xerox', + 'yahoo', + 'yam', + 'yanking', + 'yapping', + 'yard', + 'yarn', + 'yeah', + 'yearbook', + 'yearling', + 'yearly', + 'yearning', + 'yeast', + 'yelling', + 'yelp', + 'yen', + 'yesterday', + 'yiddish', + 'yield', + 'yin', + 'yippee', + 'yo-yo', + 'yodel', + 'yoga', + 'yogurt', + 'yonder', + 'yoyo', + 'yummy', + 'zap', + 'zealous', + 'zebra', + 'zen', + 'zeppelin', + 'zero', + 'zestfully', + 'zesty', + 'zigzagged', + 'zipfile', + 'zipping', + 'zippy', + 'zips', + 'zit', + 'zodiac', + 'zombie', + 'zone', + 'zoning', + 'zookeeper', + 'zoologist', + 'zoology', + 'zoom', +] + +export const BIP39_WORDS: readonly string[] = [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'account', + 'accuse', + 'achieve', + 'acid', + 'acoustic', + 'acquire', + 'across', + 'act', + 'action', + 'actor', + 'actress', + 'actual', + 'adapt', + 'add', + 'addict', + 'address', + 'adjust', + 'admit', + 'adult', + 'advance', + 'advice', + 'aerobic', + 'affair', + 'afford', + 'afraid', + 'again', + 'age', + 'agent', + 'agree', + 'ahead', + 'aim', + 'air', + 'airport', + 'aisle', + 'alarm', + 'album', + 'alcohol', + 'alert', + 'alien', + 'all', + 'alley', + 'allow', + 'almost', + 'alone', + 'alpha', + 'already', + 'also', + 'alter', + 'always', + 'amateur', + 'amazing', + 'among', + 'amount', + 'amused', + 'analyst', + 'anchor', + 'ancient', + 'anger', + 'angle', + 'angry', + 'animal', + 'ankle', + 'announce', + 'annual', + 'another', + 'answer', + 'antenna', + 'antique', + 'anxiety', + 'any', + 'apart', + 'apology', + 'appear', + 'apple', + 'approve', + 'april', + 'arch', + 'arctic', + 'area', + 'arena', + 'argue', + 'arm', + 'armed', + 'armor', + 'army', + 'around', + 'arrange', + 'arrest', + 'arrive', + 'arrow', + 'art', + 'artefact', + 'artist', + 'artwork', + 'ask', + 'aspect', + 'assault', + 'asset', + 'assist', + 'assume', + 'asthma', + 'athlete', + 'atom', + 'attack', + 'attend', + 'attitude', + 'attract', + 'auction', + 'audit', + 'august', + 'aunt', + 'author', + 'auto', + 'autumn', + 'average', + 'avocado', + 'avoid', + 'awake', + 'aware', + 'away', + 'awesome', + 'awful', + 'awkward', + 'axis', + 'baby', + 'bachelor', + 'bacon', + 'badge', + 'bag', + 'balance', + 'balcony', + 'ball', + 'bamboo', + 'banana', + 'banner', + 'bar', + 'barely', + 'bargain', + 'barrel', + 'base', + 'basic', + 'basket', + 'battle', + 'beach', + 'bean', + 'beauty', + 'because', + 'become', + 'beef', + 'before', + 'begin', + 'behave', + 'behind', + 'believe', + 'below', + 'belt', + 'bench', + 'benefit', + 'best', + 'betray', + 'better', + 'between', + 'beyond', + 'bicycle', + 'bid', + 'bike', + 'bind', + 'biology', + 'bird', + 'birth', + 'bitter', + 'black', + 'blade', + 'blame', + 'blanket', + 'blast', + 'bleak', + 'bless', + 'blind', + 'blood', + 'blossom', + 'blouse', + 'blue', + 'blur', + 'blush', + 'board', + 'boat', + 'body', + 'boil', + 'bomb', + 'bone', + 'bonus', + 'book', + 'boost', + 'border', + 'boring', + 'borrow', + 'boss', + 'bottom', + 'bounce', + 'box', + 'boy', + 'bracket', + 'brain', + 'brand', + 'brass', + 'brave', + 'bread', + 'breeze', + 'brick', + 'bridge', + 'brief', + 'bright', + 'bring', + 'brisk', + 'broccoli', + 'broken', + 'bronze', + 'broom', + 'brother', + 'brown', + 'brush', + 'bubble', + 'buddy', + 'budget', + 'buffalo', + 'build', + 'bulb', + 'bulk', + 'bullet', + 'bundle', + 'bunker', + 'burden', + 'burger', + 'burst', + 'bus', + 'business', + 'busy', + 'butter', + 'buyer', + 'buzz', + 'cabbage', + 'cabin', + 'cable', + 'cactus', + 'cage', + 'cake', + 'call', + 'calm', + 'camera', + 'camp', + 'can', + 'canal', + 'cancel', + 'candy', + 'cannon', + 'canoe', + 'canvas', + 'canyon', + 'capable', + 'capital', + 'captain', + 'car', + 'carbon', + 'card', + 'cargo', + 'carpet', + 'carry', + 'cart', + 'case', + 'cash', + 'casino', + 'castle', + 'casual', + 'cat', + 'catalog', + 'catch', + 'category', + 'cattle', + 'caught', + 'cause', + 'caution', + 'cave', + 'ceiling', + 'celery', + 'cement', + 'census', + 'century', + 'cereal', + 'certain', + 'chair', + 'chalk', + 'champion', + 'change', + 'chaos', + 'chapter', + 'charge', + 'chase', + 'chat', + 'cheap', + 'check', + 'cheese', + 'chef', + 'cherry', + 'chest', + 'chicken', + 'chief', + 'child', + 'chimney', + 'choice', + 'choose', + 'chronic', + 'chuckle', + 'chunk', + 'churn', + 'cigar', + 'cinnamon', + 'circle', + 'citizen', + 'city', + 'civil', + 'claim', + 'clap', + 'clarify', + 'claw', + 'clay', + 'clean', + 'clerk', + 'clever', + 'click', + 'client', + 'cliff', + 'climb', + 'clinic', + 'clip', + 'clock', + 'clog', + 'close', + 'cloth', + 'cloud', + 'clown', + 'club', + 'clump', + 'cluster', + 'clutch', + 'coach', + 'coast', + 'coconut', + 'code', + 'coffee', + 'coil', + 'coin', + 'collect', + 'color', + 'column', + 'combine', + 'come', + 'comfort', + 'comic', + 'common', + 'company', + 'concert', + 'conduct', + 'confirm', + 'congress', + 'connect', + 'consider', + 'control', + 'convince', + 'cook', + 'cool', + 'copper', + 'copy', + 'coral', + 'core', + 'corn', + 'correct', + 'cost', + 'cotton', + 'couch', + 'country', + 'couple', + 'course', + 'cousin', + 'cover', + 'coyote', + 'crack', + 'cradle', + 'craft', + 'cram', + 'crane', + 'crash', + 'crater', + 'crawl', + 'crazy', + 'cream', + 'credit', + 'creek', + 'crew', + 'cricket', + 'crime', + 'crisp', + 'critic', + 'crop', + 'cross', + 'crouch', + 'crowd', + 'crucial', + 'cruel', + 'cruise', + 'crumble', + 'crunch', + 'crush', + 'cry', + 'crystal', + 'cube', + 'culture', + 'cup', + 'cupboard', + 'curious', + 'current', + 'curtain', + 'curve', + 'cushion', + 'custom', + 'cute', + 'cycle', + 'dad', + 'damage', + 'damp', + 'dance', + 'danger', + 'daring', + 'dash', + 'daughter', + 'dawn', + 'day', + 'deal', + 'debate', + 'debris', + 'decade', + 'december', + 'decide', + 'decline', + 'decorate', + 'decrease', + 'deer', + 'defense', + 'define', + 'defy', + 'degree', + 'delay', + 'deliver', + 'demand', + 'demise', + 'denial', + 'dentist', + 'deny', + 'depart', + 'depend', + 'deposit', + 'depth', + 'deputy', + 'derive', + 'describe', + 'desert', + 'design', + 'desk', + 'despair', + 'destroy', + 'detail', + 'detect', + 'develop', + 'device', + 'devote', + 'diagram', + 'dial', + 'diamond', + 'diary', + 'dice', + 'diesel', + 'diet', + 'differ', + 'digital', + 'dignity', + 'dilemma', + 'dinner', + 'dinosaur', + 'direct', + 'dirt', + 'disagree', + 'discover', + 'disease', + 'dish', + 'dismiss', + 'disorder', + 'display', + 'distance', + 'divert', + 'divide', + 'divorce', + 'dizzy', + 'doctor', + 'document', + 'dog', + 'doll', + 'dolphin', + 'domain', + 'donate', + 'donkey', + 'donor', + 'door', + 'dose', + 'double', + 'dove', + 'draft', + 'dragon', + 'drama', + 'drastic', + 'draw', + 'dream', + 'dress', + 'drift', + 'drill', + 'drink', + 'drip', + 'drive', + 'drop', + 'drum', + 'dry', + 'duck', + 'dumb', + 'dune', + 'during', + 'dust', + 'dutch', + 'duty', + 'dwarf', + 'dynamic', + 'eager', + 'eagle', + 'early', + 'earn', + 'earth', + 'easily', + 'east', + 'easy', + 'echo', + 'ecology', + 'economy', + 'edge', + 'edit', + 'educate', + 'effort', + 'egg', + 'eight', + 'either', + 'elbow', + 'elder', + 'electric', + 'elegant', + 'element', + 'elephant', + 'elevator', + 'elite', + 'else', + 'embark', + 'embody', + 'embrace', + 'emerge', + 'emotion', + 'employ', + 'empower', + 'empty', + 'enable', + 'enact', + 'end', + 'endless', + 'endorse', + 'enemy', + 'energy', + 'enforce', + 'engage', + 'engine', + 'enhance', + 'enjoy', + 'enlist', + 'enough', + 'enrich', + 'enroll', + 'ensure', + 'enter', + 'entire', + 'entry', + 'envelope', + 'episode', + 'equal', + 'equip', + 'era', + 'erase', + 'erode', + 'erosion', + 'error', + 'erupt', + 'escape', + 'essay', + 'essence', + 'estate', + 'eternal', + 'ethics', + 'evidence', + 'evil', + 'evoke', + 'evolve', + 'exact', + 'example', + 'excess', + 'exchange', + 'excite', + 'exclude', + 'excuse', + 'execute', + 'exercise', + 'exhaust', + 'exhibit', + 'exile', + 'exist', + 'exit', + 'exotic', + 'expand', + 'expect', + 'expire', + 'explain', + 'expose', + 'express', + 'extend', + 'extra', + 'eye', + 'eyebrow', + 'fabric', + 'face', + 'faculty', + 'fade', + 'faint', + 'faith', + 'fall', + 'false', + 'fame', + 'family', + 'famous', + 'fan', + 'fancy', + 'fantasy', + 'farm', + 'fashion', + 'fat', + 'fatal', + 'father', + 'fatigue', + 'fault', + 'favorite', + 'feature', + 'february', + 'federal', + 'fee', + 'feed', + 'feel', + 'female', + 'fence', + 'festival', + 'fetch', + 'fever', + 'few', + 'fiber', + 'fiction', + 'field', + 'figure', + 'file', + 'film', + 'filter', + 'final', + 'find', + 'fine', + 'finger', + 'finish', + 'fire', + 'firm', + 'first', + 'fiscal', + 'fish', + 'fit', + 'fitness', + 'fix', + 'flag', + 'flame', + 'flash', + 'flat', + 'flavor', + 'flee', + 'flight', + 'flip', + 'float', + 'flock', + 'floor', + 'flower', + 'fluid', + 'flush', + 'fly', + 'foam', + 'focus', + 'fog', + 'foil', + 'fold', + 'follow', + 'food', + 'foot', + 'force', + 'forest', + 'forget', + 'fork', + 'fortune', + 'forum', + 'forward', + 'fossil', + 'foster', + 'found', + 'fox', + 'fragile', + 'frame', + 'frequent', + 'fresh', + 'friend', + 'fringe', + 'frog', + 'front', + 'frost', + 'frown', + 'frozen', + 'fruit', + 'fuel', + 'fun', + 'funny', + 'furnace', + 'fury', + 'future', + 'gadget', + 'gain', + 'galaxy', + 'gallery', + 'game', + 'gap', + 'garage', + 'garbage', + 'garden', + 'garlic', + 'garment', + 'gas', + 'gasp', + 'gate', + 'gather', + 'gauge', + 'gaze', + 'general', + 'genius', + 'genre', + 'gentle', + 'genuine', + 'gesture', + 'ghost', + 'giant', + 'gift', + 'giggle', + 'ginger', + 'giraffe', + 'girl', + 'give', + 'glad', + 'glance', + 'glare', + 'glass', + 'glide', + 'glimpse', + 'globe', + 'gloom', + 'glory', + 'glove', + 'glow', + 'glue', + 'goat', + 'goddess', + 'gold', + 'good', + 'goose', + 'gorilla', + 'gospel', + 'gossip', + 'govern', + 'gown', + 'grab', + 'grace', + 'grain', + 'grant', + 'grape', + 'grass', + 'gravity', + 'great', + 'green', + 'grid', + 'grief', + 'grit', + 'grocery', + 'group', + 'grow', + 'grunt', + 'guard', + 'guess', + 'guide', + 'guilt', + 'guitar', + 'gun', + 'gym', + 'habit', + 'hair', + 'half', + 'hammer', + 'hamster', + 'hand', + 'happy', + 'harbor', + 'hard', + 'harsh', + 'harvest', + 'hat', + 'have', + 'hawk', + 'hazard', + 'head', + 'health', + 'heart', + 'heavy', + 'hedgehog', + 'height', + 'hello', + 'helmet', + 'help', + 'hen', + 'hero', + 'hidden', + 'high', + 'hill', + 'hint', + 'hip', + 'hire', + 'history', + 'hobby', + 'hockey', + 'hold', + 'hole', + 'holiday', + 'hollow', + 'home', + 'honey', + 'hood', + 'hope', + 'horn', + 'horror', + 'horse', + 'hospital', + 'host', + 'hotel', + 'hour', + 'hover', + 'hub', + 'huge', + 'human', + 'humble', + 'humor', + 'hundred', + 'hungry', + 'hunt', + 'hurdle', + 'hurry', + 'hurt', + 'husband', + 'hybrid', + 'ice', + 'icon', + 'idea', + 'identify', + 'idle', + 'ignore', + 'ill', + 'illegal', + 'illness', + 'image', + 'imitate', + 'immense', + 'immune', + 'impact', + 'impose', + 'improve', + 'impulse', + 'inch', + 'include', + 'income', + 'increase', + 'index', + 'indicate', + 'indoor', + 'industry', + 'infant', + 'inflict', + 'inform', + 'inhale', + 'inherit', + 'initial', + 'inject', + 'injury', + 'inmate', + 'inner', + 'innocent', + 'input', + 'inquiry', + 'insane', + 'insect', + 'inside', + 'inspire', + 'install', + 'intact', + 'interest', + 'into', + 'invest', + 'invite', + 'involve', + 'iron', + 'island', + 'isolate', + 'issue', + 'item', + 'ivory', + 'jacket', + 'jaguar', + 'jar', + 'jazz', + 'jealous', + 'jeans', + 'jelly', + 'jewel', + 'job', + 'join', + 'joke', + 'journey', + 'joy', + 'judge', + 'juice', + 'jump', + 'jungle', + 'junior', + 'junk', + 'just', + 'kangaroo', + 'keen', + 'keep', + 'ketchup', + 'key', + 'kick', + 'kid', + 'kidney', + 'kind', + 'kingdom', + 'kiss', + 'kit', + 'kitchen', + 'kite', + 'kitten', + 'kiwi', + 'knee', + 'knife', + 'knock', + 'know', + 'lab', + 'label', + 'labor', + 'ladder', + 'lady', + 'lake', + 'lamp', + 'language', + 'laptop', + 'large', + 'later', + 'latin', + 'laugh', + 'laundry', + 'lava', + 'law', + 'lawn', + 'lawsuit', + 'layer', + 'lazy', + 'leader', + 'leaf', + 'learn', + 'leave', + 'lecture', + 'left', + 'leg', + 'legal', + 'legend', + 'leisure', + 'lemon', + 'lend', + 'length', + 'lens', + 'leopard', + 'lesson', + 'letter', + 'level', + 'liar', + 'liberty', + 'library', + 'license', + 'life', + 'lift', + 'light', + 'like', + 'limb', + 'limit', + 'link', + 'lion', + 'liquid', + 'list', + 'little', + 'live', + 'lizard', + 'load', + 'loan', + 'lobster', + 'local', + 'lock', + 'logic', + 'lonely', + 'long', + 'loop', + 'lottery', + 'loud', + 'lounge', + 'love', + 'loyal', + 'lucky', + 'luggage', + 'lumber', + 'lunar', + 'lunch', + 'luxury', + 'lyrics', + 'machine', + 'mad', + 'magic', + 'magnet', + 'maid', + 'mail', + 'main', + 'major', + 'make', + 'mammal', + 'man', + 'manage', + 'mandate', + 'mango', + 'mansion', + 'manual', + 'maple', + 'marble', + 'march', + 'margin', + 'marine', + 'market', + 'marriage', + 'mask', + 'mass', + 'master', + 'match', + 'material', + 'math', + 'matrix', + 'matter', + 'maximum', + 'maze', + 'meadow', + 'mean', + 'measure', + 'meat', + 'mechanic', + 'medal', + 'media', + 'melody', + 'melt', + 'member', + 'memory', + 'mention', + 'menu', + 'mercy', + 'merge', + 'merit', + 'merry', + 'mesh', + 'message', + 'metal', + 'method', + 'middle', + 'midnight', + 'milk', + 'million', + 'mimic', + 'mind', + 'minimum', + 'minor', + 'minute', + 'miracle', + 'mirror', + 'misery', + 'miss', + 'mistake', + 'mix', + 'mixed', + 'mixture', + 'mobile', + 'model', + 'modify', + 'mom', + 'moment', + 'monitor', + 'monkey', + 'monster', + 'month', + 'moon', + 'moral', + 'more', + 'morning', + 'mosquito', + 'mother', + 'motion', + 'motor', + 'mountain', + 'mouse', + 'move', + 'movie', + 'much', + 'muffin', + 'mule', + 'multiply', + 'muscle', + 'museum', + 'mushroom', + 'music', + 'must', + 'mutual', + 'myself', + 'mystery', + 'myth', + 'naive', + 'name', + 'napkin', + 'narrow', + 'nasty', + 'nation', + 'nature', + 'near', + 'neck', + 'need', + 'negative', + 'neglect', + 'neither', + 'nephew', + 'nerve', + 'nest', + 'net', + 'network', + 'neutral', + 'never', + 'news', + 'next', + 'nice', + 'night', + 'noble', + 'noise', + 'nominee', + 'noodle', + 'normal', + 'north', + 'nose', + 'notable', + 'note', + 'nothing', + 'notice', + 'novel', + 'now', + 'nuclear', + 'number', + 'nurse', + 'nut', + 'oak', + 'obey', + 'object', + 'oblige', + 'obscure', + 'observe', + 'obtain', + 'obvious', + 'occur', + 'ocean', + 'october', + 'odor', + 'off', + 'offer', + 'office', + 'often', + 'oil', + 'okay', + 'old', + 'olive', + 'olympic', + 'omit', + 'once', + 'one', + 'onion', + 'online', + 'only', + 'open', + 'opera', + 'opinion', + 'oppose', + 'option', + 'orange', + 'orbit', + 'orchard', + 'order', + 'ordinary', + 'organ', + 'orient', + 'original', + 'orphan', + 'ostrich', + 'other', + 'outdoor', + 'outer', + 'output', + 'outside', + 'oval', + 'oven', + 'over', + 'own', + 'owner', + 'oxygen', + 'oyster', + 'ozone', + 'pact', + 'paddle', + 'page', + 'pair', + 'palace', + 'palm', + 'panda', + 'panel', + 'panic', + 'panther', + 'paper', + 'parade', + 'parent', + 'park', + 'parrot', + 'party', + 'pass', + 'patch', + 'path', + 'patient', + 'patrol', + 'pattern', + 'pause', + 'pave', + 'payment', + 'peace', + 'peanut', + 'pear', + 'peasant', + 'pelican', + 'pen', + 'penalty', + 'pencil', + 'people', + 'pepper', + 'perfect', + 'permit', + 'person', + 'pet', + 'phone', + 'photo', + 'phrase', + 'physical', + 'piano', + 'picnic', + 'picture', + 'piece', + 'pig', + 'pigeon', + 'pill', + 'pilot', + 'pink', + 'pioneer', + 'pipe', + 'pistol', + 'pitch', + 'pizza', + 'place', + 'planet', + 'plastic', + 'plate', + 'play', + 'please', + 'pledge', + 'pluck', + 'plug', + 'plunge', + 'poem', + 'poet', + 'point', + 'polar', + 'pole', + 'police', + 'pond', + 'pony', + 'pool', + 'popular', + 'portion', + 'position', + 'possible', + 'post', + 'potato', + 'pottery', + 'poverty', + 'powder', + 'power', + 'practice', + 'praise', + 'predict', + 'prefer', + 'prepare', + 'present', + 'pretty', + 'prevent', + 'price', + 'pride', + 'primary', + 'print', + 'priority', + 'prison', + 'private', + 'prize', + 'problem', + 'process', + 'produce', + 'profit', + 'program', + 'project', + 'promote', + 'proof', + 'property', + 'prosper', + 'protect', + 'proud', + 'provide', + 'public', + 'pudding', + 'pull', + 'pulp', + 'pulse', + 'pumpkin', + 'punch', + 'pupil', + 'puppy', + 'purchase', + 'purity', + 'purpose', + 'purse', + 'push', + 'put', + 'puzzle', + 'pyramid', + 'quality', + 'quantum', + 'quarter', + 'question', + 'quick', + 'quit', + 'quiz', + 'quote', + 'rabbit', + 'raccoon', + 'race', + 'rack', + 'radar', + 'radio', + 'rail', + 'rain', + 'raise', + 'rally', + 'ramp', + 'ranch', + 'random', + 'range', + 'rapid', + 'rare', + 'rate', + 'rather', + 'raven', + 'raw', + 'razor', + 'ready', + 'real', + 'reason', + 'rebel', + 'rebuild', + 'recall', + 'receive', + 'recipe', + 'record', + 'recycle', + 'reduce', + 'reflect', + 'reform', + 'refuse', + 'region', + 'regret', + 'regular', + 'reject', + 'relax', + 'release', + 'relief', + 'rely', + 'remain', + 'remember', + 'remind', + 'remove', + 'render', + 'renew', + 'rent', + 'reopen', + 'repair', + 'repeat', + 'replace', + 'report', + 'require', + 'rescue', + 'resemble', + 'resist', + 'resource', + 'response', + 'result', + 'retire', + 'retreat', + 'return', + 'reunion', + 'reveal', + 'review', + 'reward', + 'rhythm', + 'rib', + 'ribbon', + 'rice', + 'rich', + 'ride', + 'ridge', + 'rifle', + 'right', + 'rigid', + 'ring', + 'riot', + 'ripple', + 'risk', + 'ritual', + 'rival', + 'river', + 'road', + 'roast', + 'robot', + 'robust', + 'rocket', + 'romance', + 'roof', + 'rookie', + 'room', + 'rose', + 'rotate', + 'rough', + 'round', + 'route', + 'royal', + 'rubber', + 'rude', + 'rug', + 'rule', + 'run', + 'runway', + 'rural', + 'sad', + 'saddle', + 'sadness', + 'safe', + 'sail', + 'salad', + 'salmon', + 'salon', + 'salt', + 'salute', + 'same', + 'sample', + 'sand', + 'satisfy', + 'satoshi', + 'sauce', + 'sausage', + 'save', + 'say', + 'scale', + 'scan', + 'scare', + 'scatter', + 'scene', + 'scheme', + 'school', + 'science', + 'scissors', + 'scorpion', + 'scout', + 'scrap', + 'screen', + 'script', + 'scrub', + 'sea', + 'search', + 'season', + 'seat', + 'second', + 'secret', + 'section', + 'security', + 'seed', + 'seek', + 'segment', + 'select', + 'sell', + 'seminar', + 'senior', + 'sense', + 'sentence', + 'series', + 'service', + 'session', + 'settle', + 'setup', + 'seven', + 'shadow', + 'shaft', + 'shallow', + 'share', + 'shed', + 'shell', + 'sheriff', + 'shield', + 'shift', + 'shine', + 'ship', + 'shiver', + 'shock', + 'shoe', + 'shoot', + 'shop', + 'short', + 'shoulder', + 'shove', + 'shrimp', + 'shrug', + 'shuffle', + 'shy', + 'sibling', + 'sick', + 'side', + 'siege', + 'sight', + 'sign', + 'silent', + 'silk', + 'silly', + 'silver', + 'similar', + 'simple', + 'since', + 'sing', + 'siren', + 'sister', + 'situate', + 'six', + 'size', + 'skate', + 'sketch', + 'ski', + 'skill', + 'skin', + 'skirt', + 'skull', + 'slab', + 'slam', + 'sleep', + 'slender', + 'slice', + 'slide', + 'slight', + 'slim', + 'slogan', + 'slot', + 'slow', + 'slush', + 'small', + 'smart', + 'smile', + 'smoke', + 'smooth', + 'snack', + 'snake', + 'snap', + 'sniff', + 'snow', + 'soap', + 'soccer', + 'social', + 'sock', + 'soda', + 'soft', + 'solar', + 'soldier', + 'solid', + 'solution', + 'solve', + 'someone', + 'song', + 'soon', + 'sorry', + 'sort', + 'soul', + 'sound', + 'soup', + 'source', + 'south', + 'space', + 'spare', + 'spatial', + 'spawn', + 'speak', + 'special', + 'speed', + 'spell', + 'spend', + 'sphere', + 'spice', + 'spider', + 'spike', + 'spin', + 'spirit', + 'split', + 'spoil', + 'sponsor', + 'spoon', + 'sport', + 'spot', + 'spray', + 'spread', + 'spring', + 'spy', + 'square', + 'squeeze', + 'squirrel', + 'stable', + 'stadium', + 'staff', + 'stage', + 'stairs', + 'stamp', + 'stand', + 'start', + 'state', + 'stay', + 'steak', + 'steel', + 'stem', + 'step', + 'stereo', + 'stick', + 'still', + 'sting', + 'stock', + 'stomach', + 'stone', + 'stool', + 'story', + 'stove', + 'strategy', + 'street', + 'strike', + 'strong', + 'struggle', + 'student', + 'stuff', + 'stumble', + 'style', + 'subject', + 'submit', + 'subway', + 'success', + 'such', + 'sudden', + 'suffer', + 'sugar', + 'suggest', + 'suit', + 'summer', + 'sun', + 'sunny', + 'sunset', + 'super', + 'supply', + 'supreme', + 'sure', + 'surface', + 'surge', + 'surprise', + 'surround', + 'survey', + 'suspect', + 'sustain', + 'swallow', + 'swamp', + 'swap', + 'swarm', + 'swear', + 'sweet', + 'swift', + 'swim', + 'swing', + 'switch', + 'sword', + 'symbol', + 'symptom', + 'syrup', + 'system', + 'table', + 'tackle', + 'tag', + 'tail', + 'talent', + 'talk', + 'tank', + 'tape', + 'target', + 'task', + 'taste', + 'tattoo', + 'taxi', + 'teach', + 'team', + 'tell', + 'ten', + 'tenant', + 'tennis', + 'tent', + 'term', + 'test', + 'text', + 'thank', + 'that', + 'theme', + 'then', + 'theory', + 'there', + 'they', + 'thing', + 'this', + 'thought', + 'three', + 'thrive', + 'throw', + 'thumb', + 'thunder', + 'ticket', + 'tide', + 'tiger', + 'tilt', + 'timber', + 'time', + 'tiny', + 'tip', + 'tired', + 'tissue', + 'title', + 'toast', + 'tobacco', + 'today', + 'toddler', + 'toe', + 'together', + 'toilet', + 'token', + 'tomato', + 'tomorrow', + 'tone', + 'tongue', + 'tonight', + 'tool', + 'tooth', + 'top', + 'topic', + 'topple', + 'torch', + 'tornado', + 'tortoise', + 'toss', + 'total', + 'tourist', + 'toward', + 'tower', + 'town', + 'toy', + 'track', + 'trade', + 'traffic', + 'tragic', + 'train', + 'transfer', + 'trap', + 'trash', + 'travel', + 'tray', + 'treat', + 'tree', + 'trend', + 'trial', + 'tribe', + 'trick', + 'trigger', + 'trim', + 'trip', + 'trophy', + 'trouble', + 'truck', + 'true', + 'truly', + 'trumpet', + 'trust', + 'truth', + 'try', + 'tube', + 'tuition', + 'tumble', + 'tuna', + 'tunnel', + 'turkey', + 'turn', + 'turtle', + 'twelve', + 'twenty', + 'twice', + 'twin', + 'twist', + 'two', + 'type', + 'typical', + 'ugly', + 'umbrella', + 'unable', + 'unaware', + 'uncle', + 'uncover', + 'under', + 'undo', + 'unfair', + 'unfold', + 'unhappy', + 'uniform', + 'unique', + 'unit', + 'universe', + 'unknown', + 'unlock', + 'until', + 'unusual', + 'unveil', + 'update', + 'upgrade', + 'uphold', + 'upon', + 'upper', + 'upset', + 'urban', + 'urge', + 'usage', + 'use', + 'used', + 'useful', + 'useless', + 'usual', + 'utility', + 'vacant', + 'vacuum', + 'vague', + 'valid', + 'valley', + 'valve', + 'van', + 'vanish', + 'vapor', + 'various', + 'vast', + 'vault', + 'vehicle', + 'velvet', + 'vendor', + 'venture', + 'venue', + 'verb', + 'verify', + 'version', + 'very', + 'vessel', + 'veteran', + 'viable', + 'vibrant', + 'vicious', + 'victory', + 'video', + 'view', + 'village', + 'vintage', + 'violin', + 'virtual', + 'virus', + 'visa', + 'visit', + 'visual', + 'vital', + 'vivid', + 'vocal', + 'voice', + 'void', + 'volcano', + 'volume', + 'vote', + 'voyage', + 'wage', + 'wagon', + 'wait', + 'walk', + 'wall', + 'walnut', + 'want', + 'warfare', + 'warm', + 'warrior', + 'wash', + 'wasp', + 'waste', + 'water', + 'wave', + 'way', + 'wealth', + 'weapon', + 'wear', + 'weasel', + 'weather', + 'web', + 'wedding', + 'weekend', + 'weird', + 'welcome', + 'west', + 'wet', + 'whale', + 'what', + 'wheat', + 'wheel', + 'when', + 'where', + 'whip', + 'whisper', + 'wide', + 'width', + 'wife', + 'wild', + 'will', + 'win', + 'window', + 'wine', + 'wing', + 'wink', + 'winner', + 'winter', + 'wire', + 'wisdom', + 'wise', + 'wish', + 'witness', + 'wolf', + 'woman', + 'wonder', + 'wood', + 'wool', + 'word', + 'work', + 'world', + 'worry', + 'worth', + 'wrap', + 'wreck', + 'wrestle', + 'wrist', + 'write', + 'wrong', + 'yard', + 'year', + 'yellow', + 'you', + 'young', + 'youth', + 'zebra', + 'zero', + 'zone', + 'zoo', +] diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 7f8f81a4..d640bee6 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -8,11 +8,14 @@ import { DTeam, DUserFolder, Authentication, + normal64Bytes, + platform, } from '@keeper-security/keeperapi' import type { SyncResult, SyncLogFormat, VaultStorage, SessionStorage, AuthUI3 } from '@keeper-security/keeperapi' import { InMemoryStorage } from '../storage/InMemoryStorage' import { SessionManager } from '../auth/SessionManager' -import { ConsoleAuthUI } from '../auth/ConsoleAuthUI' +import { getSdkPlatform } from '../platform' +import { toSessionParams, type SessionRestoreInput } from '../auth/sessionRestore' import { searchRecords, formatRecord, getRecordTitle, getRecordType } from '../records/RecordUtils' import { addRecord as addRecordOp, @@ -21,6 +24,16 @@ import { getRecordHistory as getRecordHistoryOp, moveRecord as moveRecordOp, } from '../records/RecordOperations' +import { + EnterpriseReportManager, + runPasswordReport, + type AuditReportOptions, + type AuditReportResult, + type ActionReportOptions, + type ActionReportResult, + type PasswordReportOptions, + type PasswordReportResult, +} from '../enterpriseReport' import type { NewRecordInput, TypedRecordData, @@ -45,7 +58,7 @@ 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 type { AddFolderInput, AddFolderResult, MkdirOptions } from '../folders/addFolder' import type { UpdateFolderInput, UpdateFolderResult, RenameFolderResult } from '../folders/updateFolder' import type { DeleteFolderResult, RmdirOptions } from '../folders/deleteFolder' @@ -53,6 +66,12 @@ import type { FolderTreeBuildOptions } from '../folders/folderTree' import type { GetFolderOptions, GetFolderResult } from '../folders/getFolder' import type { ListSharedFolderRow, ListSharedFoldersOptions } from '../sharedFolders/listSharedFolders' import type { ShareFolderInput, ShareFolderResult } from '../sharedFolders/shareFolder' +import type { DownloadMembershipOptions, MembershipData } from '../sharedFolders/downloadMembership' +import type { + ApplyMembershipInput, + ApplyMembershipOptions, + ApplyMembershipResult, +} from '../sharedFolders/applyMembership' import { FolderManager } from '../folders/FolderManager' import type { SharedFolderPermissionsInput } from '../folders/FolderManager' import { SharedFolderManager } from '../sharedFolders/SharedFolderManager' @@ -62,6 +81,7 @@ import type { TeamView } from '../teams/viewTeam' import type { AddTeamInput, AddTeamResult } from '../teams/addTeam' import type { UpdateTeamInput, UpdateTeamResult } from '../teams/updateTeam' import type { DeleteTeamInput, DeleteTeamResult } from '../teams/deleteTeam' +import type { ChangeTeamRolesInput, TeamRoleResult } from '../teams/teamRole' import { RoleManager, type AddRoleInput, @@ -73,40 +93,44 @@ import { type RoleView, type UpdateRoleInput, type UpdateRoleResult, + type SetRoleEnforcementsInput, + type SetRoleEnforcementsResult, + type FormattedRoleEnforcementTable, + type CopyRoleInput, + type CopyRoleResult, + type FormattedCopyRoleTable, + type AddUsersToRolesInput, + type RemoveUsersFromRolesInput, + type RoleUserResult, + type FormattedRoleUserTable, + type ManageRoleNodesInput, + type ManageRoleNodesResult, + type FormattedManageRoleNodesTable, + type ChangeRolePrivilegesInput, + type ChangeRolePrivilegesResult, + type FormattedRolePrivilegeTable, } from '../roles' import { - EnterpriseReportManager, - runPasswordReport, - type AuditReportOptions, - type AuditReportResult, - type ActionReportOptions, - type ActionReportResult, - type PasswordReportOptions, - type PasswordReportResult, -} from '../enterpriseReport' + NodeManager, + type AddNodeInput, + type AddNodeResult, + type DeleteNodeInput, + type DeleteNodeResult, + type ListNodeRow, + type ListNodesOptions, + type NodeView, + type UpdateNodeInput, + type UpdateNodeResult, +} from '../nodes' import { UserManager } from '../users/UserManager' import { NestedShareFolderManager } from '../nestedShareFolders/NestedShareFolderManager' import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' -import { formatListNsfTable, renderListNsfAsciiTable, formatListNsfOutput } from '../nestedShareFolders/listNsf' -import { formatNsfDetail } from '../nestedShareFolders/getNsf' -import { formatRemoveNsfPreview } from '../nestedShareFolders/removeNsfRecord' -import { - formatNsfRecordSharePlan, - formatNsfRecordShareResults, - formatNsfFolderShareResults, -} from '../nestedShareFolders/nsfShare' -import { - formatNsfRecordPermissionPlan, - formatNsfRecordPermissionRequestHeader, - formatNsfRecordPermissionFailures, -} from '../nestedShareFolders/nsfRecordPermission' -import { formatNsfShortcutOutput, formatKeepNsfShortcutPlan } from '../nestedShareFolders/nsfShortcut' -import { formatTransferNestedShareRecordResults } from '../nestedShareFolders/nsfTransferRecord' import type { AddNsfRecordInput, AddNsfRecordResult, AddNsfRecordsInput, AddNsfRecordsResult, + FormattedListNsfTable, GetNsfOptions, GetNsfResult, GetNsfRecordDetailsInput, @@ -117,7 +141,6 @@ import type { ListNsfFormatInput, ListNsfOptions, ListNsfRow, - FormattedListNsfTable, ListNsfShortcutsOptions, MkdirNsfInput, MkdirNsfResult, @@ -132,8 +155,6 @@ import type { ShareNestedShareRecordResult, TransferNestedShareRecordInput, TransferNestedShareRecordResult, - UpdateNsfFolderInput, - UpdateNsfFolderResult, UpdateNsfRecordInput, UpdateNsfRecordItemInput, UpdateNsfRecordsInput, @@ -142,6 +163,12 @@ import type { UpdateNsfRecordResult, UpdateNsfRecordResultItem, } from '../nestedShareFolders/nsfTypes' +import type { + UpdateNsfFolderBatchItem, + UpdateNsfFolderInput, + UpdateNsfFolderResult, + UpdateNsfFoldersResult, +} from '../nestedShareFolders/updateNsfFolder' import type { ListUserRow, ListUsersOptions, @@ -166,7 +193,21 @@ import type { TeamUserResult, FormattedTeamUserTable, } from '../users/userTypes' -import { ConsoleLogger, LogLevel, KeeperSdkError, extractErrorMessage, SdkDefaults, ResultCodes } from '../utils' +import type { + UpdateUsersOnTeamsInput, + UpdateUsersOnTeamsResult, + FormattedUpdateTeamUserTable, +} from '../users/updateTeamUser' +import { buildWhoamiInfo, type WhoamiInfo } from '../account/whoamiInfo' +import { + ConsoleLogger, + LogLevel, + KeeperSdkError, + extractErrorMessage, + extractResultCode, + SdkDefaults, + ResultCodes, +} from '../utils' import type { ILogger } from '../utils' enum VaultStatus { @@ -203,11 +244,13 @@ export class KeeperVault { private readonly log: ILogger private synced = false private batchDepth = 0 + private restoredAccountUid: string | null = null private readonly folderSession: VaultFolderSession = FolderManager.createSession() private readonly folderManager: FolderManager private readonly sharedFolderManager: SharedFolderManager private readonly teamManager: TeamManager private readonly roleManager: RoleManager + private readonly nodeManager: NodeManager private readonly enterpriseReportManager: EnterpriseReportManager private readonly userManager: UserManager private readonly nestedShareFolderManager: NestedShareFolderManager @@ -226,13 +269,14 @@ export class KeeperVault { this.log = new ConsoleLogger(this.config.logLevel) this.storage = config?.storage || new InMemoryStorage() this.sessionManager = config?.sessionStorage || new SessionManager(this.config.configDir || undefined) - this.authUI = config?.authUI || new ConsoleAuthUI() + this.authUI = config?.authUI ?? getSdkPlatform().createAuthUI(this.config.useConsoleAuth) const authProvider = () => this.getAuthOrThrow() this.folderManager = new FolderManager(this.storage, this.folderSession, authProvider) this.sharedFolderManager = new SharedFolderManager(this.storage, authProvider) this.teamManager = new TeamManager(authProvider) this.roleManager = new RoleManager(authProvider) + this.nodeManager = new NodeManager(authProvider) this.enterpriseReportManager = new EnterpriseReportManager(authProvider) this.userManager = new UserManager(authProvider) this.nestedShareFolderManager = new NestedShareFolderManager(this.storage, authProvider) @@ -354,10 +398,106 @@ export class KeeperVault { this.log.info(`Logged in as ${username} (via session token)`) } + /** + * Persist device token and private key for this vault host in session storage so + * {@link loginWithSessionToken} and {@link resumeSession} work without a prior password login on this machine. + * Values use the same base64 / base64url decoding as {@link SessionManager} (`normal64Bytes`). + */ + public async registerDevice( + deviceToken: string, + privateKey: string, + options?: { username?: string } + ): Promise { + const host = this.config.host + const save = this.sessionManager.createOnDeviceConfig(host) + await save({ + deviceToken: normal64Bytes(deviceToken), + privateKey: normal64Bytes(privateKey), + }) + if (options?.username) { + this.sessionManager.setLastUsername(options.username) + } + this.log.info(`Device credentials stored for host ${host}`) + } + public getSessionToken(): string | undefined { return this.auth?.sessionToken || undefined } + /** + * Resume a session from extension-exported {@link SessionRestoreInput}. + * Verifies the token with a lightweight server call so an expired session + * fails here, not later from `sync()`. + */ + public async restoreSession(input: SessionRestoreInput): Promise { + const params = toSessionParams(input) + await this.sessionManager.saveSessionParameters(params) + this.sessionManager.setLastUsername(params.username) + + this.auth = await this.createAuth() + await this.auth.continueSession() + + if (!this.auth.sessionToken) { + throw new KeeperSdkError( + 'Session restore failed — session token may be expired or invalid.', + ResultCodes.SESSION_TOKEN_EXPIRED + ) + } + + try { + await this.auth.loadAccountSummary() + } catch (err) { + const code = extractResultCode(err) + const msg = extractErrorMessage(err) + this.disconnect() + const isExpired = code === ResultCodes.SESSION_TOKEN_EXPIRED + throw new KeeperSdkError( + isExpired + ? `Session token rejected by server (${code}): ${msg}. Re-export session JSON and try again.` + : `Session restore failed: ${msg}`, + code ?? ResultCodes.SESSION_TOKEN_EXPIRED + ) + } + + const accountUid = input.accountUid.trim() + if (this.restoredAccountUid && accountUid && this.restoredAccountUid !== accountUid) { + await this.storage.clear() + platform.unloadKeys() + } else { + // Preserve vault data and continuation token for incremental sync on the same account. + platform.unloadNonUserKeys() + } + if (accountUid) { + this.restoredAccountUid = accountUid + } + this.synced = false + this.log.info(`Session restored for ${params.username}`) + } + + 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 async resumeSession(): Promise { const username = await this.sessionManager.getLastUsername() if (!username) { @@ -406,14 +546,32 @@ export class KeeperVault { public async sync(): Promise { const auth = this.getAuthOrThrow() - const result = await syncDown({ - auth, - storage: this.storage, - logFormat: this.config.logFormat, - }) + try { + const result = await syncDown({ + auth, + storage: this.storage, + logFormat: this.config.logFormat, + }) + if (result.error) { + this.log.error('Sync error:', result.error) + throw new KeeperSdkError(`Sync failed: ${result.error}`, ResultCodes.SYNC_FAILED) + } + this.synced = true + return result + } catch (e) { + this.log.error('Sync failed:', extractErrorMessage(e)) + throw e + } + } - this.synced = true - return result + /** Drop incremental sync cursor and crypto caches so the next sync can restart cleanly. */ + public async clearSyncCheckpoint(): Promise { + const checkpoint = await this.storage.get('continuationToken') + if (checkpoint?.token) { + await this.storage.delete('continuationToken', checkpoint.token) + } + platform.unloadNonUserKeys() + this.synced = false } public async batch(fn: () => Promise): Promise { @@ -434,7 +592,12 @@ export class KeeperVault { return } if (this.config.autoSync) { - await this.sync() + try { + await this.sync() + } catch (err) { + this.synced = false + this.log.warn(`Post-mutation sync failed: ${extractErrorMessage(err)}`) + } } else { this.synced = false } @@ -445,7 +608,10 @@ export class KeeperVault { } public getRecordByUid(uid: string): DRecord | undefined { - return this.storage.getByUid(VaultObjectKind.Record, uid) + const direct = this.storage.getByUid(VaultObjectKind.Record, uid) + if (direct) return direct + const lower = uid.toLowerCase() + return this.getRecords().find((record) => record.uid?.toLowerCase() === lower) } public findRecord(uidOrTitle: string): DRecord | undefined { @@ -552,6 +718,24 @@ export class KeeperVault { return this.userManager.removeUsersFromTeams(input) } + public async updateUsersOnTeams(input: UpdateUsersOnTeamsInput): Promise { + return this.userManager.updateUsersOnTeams(input) + } + + public formatUpdateTeamUserResult(result: UpdateUsersOnTeamsResult): FormattedUpdateTeamUserTable { + return this.userManager.formatUpdateTeamUserResult(result) + } + + public renderUpdateTeamUserAsciiTable(table: FormattedUpdateTeamUserTable): string { + return this.userManager.renderUpdateTeamUserAsciiTable(table) + } + + public async changeTeamRoles(input: ChangeTeamRolesInput): Promise { + const result = await this.teamManager.changeTeamRoles(input) + if (result.succeeded > 0) await this.syncIfNeeded() + return result + } + public formatTeamUserResult(result: TeamUserResult): FormattedTeamUserTable { return this.userManager.formatTeamUserResult(result) } @@ -604,6 +788,92 @@ export class KeeperVault { return result } + public async setRoleEnforcements(input: SetRoleEnforcementsInput): Promise { + return this.roleManager.setRoleEnforcements(input) + } + + public formatSetRoleEnforcementsResult(result: SetRoleEnforcementsResult): FormattedRoleEnforcementTable { + return this.roleManager.formatSetRoleEnforcementsResult(result) + } + + public renderRoleEnforcementAsciiTable(table: FormattedRoleEnforcementTable): string { + return this.roleManager.renderRoleEnforcementAsciiTable(table) + } + + public async copyRoles(input: CopyRoleInput): Promise { + const result = await this.roleManager.copyRoles(input) + if (result.success) await this.syncIfNeeded() + return result + } + + public formatCopyRoleResult(result: CopyRoleResult): FormattedCopyRoleTable { + return this.roleManager.formatCopyRoleResult(result) + } + + public renderCopyRoleAsciiTable(table: FormattedCopyRoleTable): string { + return this.roleManager.renderCopyRoleAsciiTable(table) + } + + public async addUsersToRoles(input: AddUsersToRolesInput): Promise { + return this.roleManager.addUsersToRoles(input) + } + + public async removeUsersFromRoles(input: RemoveUsersFromRolesInput): Promise { + return this.roleManager.removeUsersFromRoles(input) + } + + public formatRoleUserResult(result: RoleUserResult): FormattedRoleUserTable { + return this.roleManager.formatRoleUserResult(result) + } + + public renderRoleUserAsciiTable(table: FormattedRoleUserTable): string { + return this.roleManager.renderRoleUserAsciiTable(table) + } + + public async manageRoleNodes(input: ManageRoleNodesInput): Promise { + return this.roleManager.manageRoleNodes(input) + } + + public formatManageRoleNodesResult(result: ManageRoleNodesResult): FormattedManageRoleNodesTable { + return this.roleManager.formatManageRoleNodesResult(result) + } + + public renderManageRoleNodesAsciiTable(table: FormattedManageRoleNodesTable): string { + return this.roleManager.renderManageRoleNodesAsciiTable(table) + } + + public async changeRolePrivileges(input: ChangeRolePrivilegesInput): Promise { + return this.roleManager.changeRolePrivileges(input) + } + + public formatChangeRolePrivilegesResult(result: ChangeRolePrivilegesResult): FormattedRolePrivilegeTable { + return this.roleManager.formatChangeRolePrivilegesResult(result) + } + + public renderChangeRolePrivilegesAsciiTable(table: FormattedRolePrivilegeTable): string { + return this.roleManager.renderChangeRolePrivilegesAsciiTable(table) + } + + public async listNodes(options?: ListNodesOptions): Promise { + return this.nodeManager.listNodes(options ?? {}) + } + + public async viewNode(identifier: string): Promise { + return this.nodeManager.viewNode(identifier) + } + + public async addNodes(input: AddNodeInput): Promise { + return this.nodeManager.addNodes(input) + } + + public async updateNodes(input: UpdateNodeInput): Promise { + return this.nodeManager.updateNodes(input) + } + + public async deleteNodes(input: DeleteNodeInput): Promise { + return this.nodeManager.deleteNodes(input) + } + public async runAuditReport(options?: AuditReportOptions): Promise { return this.enterpriseReportManager.runAuditReport(options ?? {}) } @@ -620,6 +890,10 @@ export class KeeperVault { return this.folderManager.changeDirectory(path) } + public async tryResolvePath(path: string): Promise { + return this.folderManager.tryResolvePath(path) + } + public getCurrentFolderUid(): string | null { return this.folderManager.getCurrentFolderUid() } @@ -673,13 +947,19 @@ export class KeeperVault { confirm?: (summary: string) => boolean | Promise ): Promise { const result = await this.folderManager.deleteFolder(folderRefs, confirm) - if (result.success) await this.syncIfNeeded() + if (result.success) { + await this.clearSyncCheckpoint() + await this.syncIfNeeded() + } return result } public async rmdir(patterns: string[], options?: RmdirOptions): Promise { const result = await this.folderManager.rmdir(patterns, options ?? {}) - if (result.success) await this.syncIfNeeded() + if (result.success) { + await this.clearSyncCheckpoint() + await this.syncIfNeeded() + } return result } @@ -738,9 +1018,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 } @@ -803,15 +1091,15 @@ export class KeeperVault { } public formatListNsfTable(rows: ListNsfRow[], options?: { columnWidth?: number }): FormattedListNsfTable { - return formatListNsfTable(rows, options ?? {}) + return this.nestedShareFolderManager.formatListNsfTable(rows, options ?? {}) } public renderListNsfAsciiTable(table: FormattedListNsfTable, options?: { minColWidth?: number }): string { - return renderListNsfAsciiTable(table, options ?? {}) + return this.nestedShareFolderManager.renderListNsfAsciiTable(table, options ?? {}) } public formatListNsfOutput(rows: ListNsfRow[], format?: ListNsfFormatInput): string { - return formatListNsfOutput(rows, format) + return this.nestedShareFolderManager.formatListNsfOutput(rows, format) } public async getNestedShareFolder(identifier: string, options?: GetNsfOptions): Promise { @@ -819,7 +1107,7 @@ export class KeeperVault { } public formatNsfDetail(result: GetNsfResult, verbose?: boolean): string { - return formatNsfDetail(result, verbose ?? false) + return this.nestedShareFolderManager.formatNsfDetail(result, verbose ?? false) } public async linkNestedShareRecord( @@ -838,7 +1126,7 @@ export class KeeperVault { } public formatRemoveNsfPreview(preview: RemoveNsfRecordResult['preview']): string { - return formatRemoveNsfPreview(preview) + return this.nestedShareFolderManager.formatRemoveNsfPreview(preview) } public async mkdirNestedShareFolder(input: Omit): Promise { @@ -852,48 +1140,18 @@ export class KeeperVault { return result } - public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { - const result = await this.nestedShareFolderManager.removeNestedShareFolders(input) - if (result.confirmed) await this.syncIfNeeded() - return result - } - - public async getNestedShareRecordDetails(input: GetNsfRecordDetailsInput): Promise { - return this.nestedShareFolderManager.getNestedShareRecordDetails(input) - } - - public async updateNestedShareRecords( - input: UpdateNsfRecordInput | UpdateNsfRecordsInput - ): Promise { - const result = await this.nestedShareFolderManager.updateNestedShareRecords(input) - if (result.updated.some((item) => item.success)) await this.syncIfNeeded() - return result - } - - public async updateNestedShareRecord(input: UpdateNsfRecordItemInput): Promise { - const result = await this.nestedShareFolderManager.updateNestedShareRecord(input) - if (result.success) await this.syncIfNeeded() - return result - } - - public async addNestedShareRecords(input: AddNsfRecordsInput): Promise { - const result = await this.nestedShareFolderManager.addNestedShareRecords(input) - if (result.added.some((item) => item.success)) await this.syncIfNeeded() - return result - } - - public async addNestedShareRecord(input: AddNsfRecordInput): Promise { - const result = await this.nestedShareFolderManager.addNestedShareRecord(input) - if (result.success) await this.syncIfNeeded() - return result - } - public async updateNestedShareFolder(input: UpdateNsfFolderInput): Promise { const result = await this.nestedShareFolderManager.updateNestedShareFolder(input) if (result.updated) await this.syncIfNeeded() return result } + public async updateNestedShareFolders(updates: UpdateNsfFolderBatchItem[]): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareFolders(updates) + if (result.anyUpdated) await this.syncIfNeeded() + return result + } + public async shareNestedShareFolder(input: ShareNestedShareFolderInput): Promise { const result = await this.nestedShareFolderManager.shareNestedShareFolder(input) if (result.results.some((item) => item.success)) await this.syncIfNeeded() @@ -902,53 +1160,103 @@ export class KeeperVault { public async shareNestedShareRecord(input: ShareNestedShareRecordInput): Promise { const result = await this.nestedShareFolderManager.shareNestedShareRecord(input) - if (!result.dryRun && result.results.some((item) => item.success)) await this.syncIfNeeded() + if (!result.dryRun && result.results.some((item) => item.success)) { + await this.syncIfNeeded() + } return result } public formatNsfRecordSharePlan(result: ShareNestedShareRecordResult): string { - return formatNsfRecordSharePlan(result.plan, result.dryRun) + return this.nestedShareFolderManager.formatNsfRecordSharePlan(result) } public formatNsfRecordShareResults(results: ShareNestedShareRecordResult['results']): string { - return formatNsfRecordShareResults(results) - } - - public formatNsfFolderShareResults(results: ShareNestedShareFolderResult['results']): string { - return formatNsfFolderShareResults(results) + return this.nestedShareFolderManager.formatNsfRecordShareResults(results) } - public listNsfShortcuts(options?: ListNsfShortcutsOptions): NsfShortcutRow[] { - this.getAuthOrThrow() - return this.nestedShareFolderManager.listNsfShortcuts(options ?? {}) + public listNsfShortcuts(options: ListNsfShortcutsOptions = {}): NsfShortcutRow[] { + return this.nestedShareFolderManager.listNsfShortcuts(options) } public formatNsfShortcutOutput(rows: NsfShortcutRow[], format?: ListNsfShortcutsOptions['format']): string { - return formatNsfShortcutOutput(rows, format) + return this.nestedShareFolderManager.formatNsfShortcutOutput(rows, format) } public async keepNsfShortcut(input: KeepNsfShortcutInput): Promise { const current = this.folderSession.currentFolderUid const defaultFolderUid = current && isNestedShareFolder(this.storage, current) ? current : undefined const result = await this.nestedShareFolderManager.keepNsfShortcut(input, defaultFolderUid) - if (!result.dryRun && result.results.some((item) => item.success)) await this.syncIfNeeded() + if (!result.dryRun && !result.nothingToDo && result.results.some((item) => item.success)) { + await this.syncIfNeeded() + } return result } public formatKeepNsfShortcutPlan(result: KeepNsfShortcutResult): string { - return formatKeepNsfShortcutPlan(result.plan) + return this.nestedShareFolderManager.formatKeepNsfShortcutPlan(result) } public async transferNestedShareRecords( input: TransferNestedShareRecordInput ): Promise { const result = await this.nestedShareFolderManager.transferNestedShareRecords(input) - if (result.results.some((item) => item.success)) await this.syncIfNeeded() + if (result.success) await this.syncIfNeeded() return result } public formatTransferNestedShareRecordResults(results: TransferNestedShareRecordResult['results']): string { - return formatTransferNestedShareRecordResults(results) + return this.nestedShareFolderManager.formatTransferNestedShareRecordResults(results) + } + + public async removeNestedShareFolders(input: RemoveNsfFolderInput): Promise { + const result = await this.nestedShareFolderManager.removeNestedShareFolders(input) + if (result.confirmed) await this.syncIfNeeded() + return result + } + + public formatRemoveNsfFolderPreview( + preview: RemoveNsfFolderResult['preview'], + operation: RemoveNsfFolderResult['operation'], + quiet?: boolean + ): string { + return this.nestedShareFolderManager.formatRemoveNsfFolderPreview(preview, operation, quiet) + } + + public async getNestedShareRecordDetails(input: GetNsfRecordDetailsInput): Promise { + return this.nestedShareFolderManager.getNestedShareRecordDetails(input) + } + + public formatNsfRecordDetailsOutput( + result: GetNsfRecordDetailsResult, + format?: GetNsfRecordDetailsInput['format'] + ): string { + return this.nestedShareFolderManager.formatNsfRecordDetailsOutput(result, format) + } + + public async updateNestedShareRecords( + input: UpdateNsfRecordInput | UpdateNsfRecordsInput + ): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareRecords(input) + if (result.updated.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public async updateNestedShareRecord(input: UpdateNsfRecordItemInput): Promise { + const result = await this.nestedShareFolderManager.updateNestedShareRecord(input) + if (result.success) await this.syncIfNeeded() + return result + } + + public async addNestedShareRecords(input: AddNsfRecordsInput): Promise { + const result = await this.nestedShareFolderManager.addNestedShareRecords(input) + if (result.added.some((item) => item.success)) await this.syncIfNeeded() + return result + } + + public async addNestedShareRecord(input: AddNsfRecordInput): Promise { + const result = await this.nestedShareFolderManager.addNestedShareRecord(input) + if (result.success) await this.syncIfNeeded() + return result } public async updateNestedShareRecordPermissions( @@ -960,23 +1268,14 @@ export class KeeperVault { } public formatNsfRecordPermissionPlan(result: UpdateNsfRecordPermissionResult): string { - return formatNsfRecordPermissionPlan(result.plan) - } - - public formatNsfRecordPermissionRequestHeader( - action: UpdateNsfRecordPermissionInput['action'], - role: string | undefined, - folderDisplayName: string, - recursive: boolean - ): string { - return formatNsfRecordPermissionRequestHeader(action, role, folderDisplayName, recursive) + return this.nestedShareFolderManager.formatNsfRecordPermissionPlan(result) } public formatNsfRecordPermissionFailures( failures: UpdateNsfRecordPermissionResult['grantFailures'], kind: 'GRANT' | 'REVOKE' ): string { - return formatNsfRecordPermissionFailures(failures, kind) + return this.nestedShareFolderManager.formatNsfRecordPermissionFailures(failures, kind) } public async shareFolder(input: ShareFolderInput): Promise { @@ -985,6 +1284,19 @@ export class KeeperVault { return result } + public async downloadMembership(options: DownloadMembershipOptions = {}): Promise { + return this.sharedFolderManager.downloadMembership(options) + } + + public async applyMembership( + data: ApplyMembershipInput, + options: ApplyMembershipOptions = {} + ): Promise { + const result = await this.sharedFolderManager.applyMembership(data, options) + await this.syncIfNeeded() + return result + } + public async getRecordHistory(recordUid: string): Promise { const auth = this.getAuthOrThrow() diff --git a/KeeperSdk/tsconfig.json b/KeeperSdk/tsconfig.json index 74c99318..15eafc2b 100644 --- a/KeeperSdk/tsconfig.json +++ b/KeeperSdk/tsconfig.json @@ -7,7 +7,7 @@ "skipLibCheck": true, "esModuleInterop": true, "declaration": true, - "rootDir": ".", + "rootDir": "src", "outDir": "dist", "types": ["node"] }, diff --git a/README.md b/README.md index 0732d0a1..cce3ad21 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ This repository contains npm packages for interacting with the Keeper backend from JavaScript or TypeScript. -[`@keeper-security/keeper-sdk-javascript`](KeeperSdk) has an easy-to-use API with high-level helpers for auth, records, folders, sharing, and teams, plus runnable examples. - -[`@keeper-security/keeperapi`](keeperapi) is the underlying core API client. Direct use is not recommended. +| Package | Purpose | +|---------|---------| +| [`@keeper-security/keeper-sdk-javascript`](KeeperSdk) | High-level API + Commander-style in-process CLI (`dispatchCliLine`) | +| [`@keeper-security/keeperapi`](keeperapi) | Low-level REST/protobuf client (direct use not recommended) | [![keeper-sdk-javascript on npm](https://img.shields.io/npm/v/@keeper-security/keeper-sdk-javascript?label=%40keeper-security%2Fkeeper-sdk-javascript&style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@keeper-security/keeper-sdk-javascript) [![keeperapi on npm](https://img.shields.io/npm/v/@keeper-security/keeperapi?label=%40keeper-security%2Fkeeperapi&style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@keeper-security/keeperapi) @@ -14,20 +15,45 @@ This repository contains npm packages for interacting with the Keeper backend fr ## Repository layout ``` -KeeperSdk/ # JS SDK (@keeper-security/keeper-sdk-javascript) -keeperapi/ # core API client (@keeper-security/keeperapi) -examples/ -└── sdk_example/ # runnable scripts demonstrating the SDK +keeper-sdk-javascript/ +├── KeeperSdk/ # @keeper-security/keeper-sdk-javascript +├── keeperapi/ # @keeper-security/keeperapi +└── examples/ + └── sdk_example/ # Runnable Node scripts (auth, records, folders, …) +``` + +**Start here for CLI / vault behavior:** [`KeeperSdk/README.md`](KeeperSdk/README.md) — built-in commands, `get` / `whoami` output, and `KeeperCliHost` adapter requirements. + +Browser embedders (e.g. `@keeper-security/keeper-shell-component`) consume this repo via npm or a local path. Fix CLI formatting and vault surface issues in **KeeperSdk first**, then rebuild the shell against the updated SDK. + +## Quick development + +Build **keeperapi** before **KeeperSdk** (SDK depends on keeperapi): + +```bash +cd keeperapi && npm install && npm run build +cd ../KeeperSdk && npm install && npm run link-local && npm run build +``` + +- **Node** entry: `KeeperSdk/dist/index.js` +- **Browser** entry: `KeeperSdk/dist/browser.js` (via `src/browser.ts` — no Node `readline` / console auth) + +Run SDK examples: + +```bash +cd examples/sdk_example && npm install +npm run auth:restore-session -- --from-json /path/to/session.json +npm run records:list:shell-cli -- --from-json /path/to/session.json ``` -See each package's README for installation and usage: +## Package docs -- [`KeeperSdk/README.md`](KeeperSdk/README.md) -- [`keeperapi/README.md`](keeperapi/README.md) +- [`KeeperSdk/README.md`](KeeperSdk/README.md) — API, CLI commands, host adapter +- [`keeperapi/README.md`](keeperapi/README.md) — core client ## Contributing -To ignore formatting-only commits in `git blame`, run: +To ignore formatting-only commits in `git blame`: ```bash git config blame.ignoreRevsFile .git-blame-ignore-revs diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 740b72f8..a98d9881 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -24,11 +24,18 @@ "folders:tree": "ts-node src/folders/tree.ts", "shared-folders:list-sf": "ts-node src/sharedFolders/list_sf.ts", "shared-folders:share-folder": "ts-node src/sharedFolders/share_folder.ts", + "shared-folders:download-membership": "ts-node src/sharedFolders/download_membership.ts", + "shared-folders:apply-membership": "ts-node src/sharedFolders/apply_membership.ts", "roles:list": "ts-node src/roles/listRoles.ts", "roles:view": "ts-node src/roles/viewRole.ts", "roles:add": "ts-node src/roles/addRole.ts", "roles:update": "ts-node src/roles/updateRole.ts", "roles:delete": "ts-node src/roles/deleteRole.ts", + "roles:enforcements": "ts-node src/roles/roleEnforcements.ts", + "roles:copy": "ts-node src/roles/copyRole.ts", + "roles:role-users": "ts-node src/roles/roleUsers.ts", + "roles:manage-node": "ts-node src/roles/manageNode.ts", + "roles:privileges": "ts-node src/roles/rolePrivileges.ts", "nsf:list": "ts-node src/nestedShareFolders/list_nsf.ts", "nsf:get": "ts-node src/nestedShareFolders/get_nsf.ts", "nsf:ln": "ts-node src/nestedShareFolders/link_nsf.ts", @@ -57,12 +64,19 @@ "users:action": "ts-node src/users/action_user.ts", "users:alias": "ts-node src/users/alias_user.ts", "users:user-team": "ts-node src/users/team_user.ts", + "users:update-team-user": "ts-node src/users/update_team_user.ts", "reports:audit-report": "ts-node src/enterpriseReport/audit_report.ts", "reports:action-report": "ts-node src/enterpriseReport/action_report.ts", "reports:password-report": "ts-node src/enterpriseReport/password_report.ts", "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", "types": "tsc --watch", - "types:ci": "tsc" + "types:ci": "tsc", + "nsf:folder": "ts-node src/nestedShareFolders/nsf_folder.ts", + "nsf:rndir": "ts-node src/nestedShareFolders/rndir_nsf.ts", + "nsf:share": "ts-node src/nestedShareFolders/share_nsf.ts", + "nsf:shortcut": "ts-node src/nestedShareFolders/shortcut_nsf.ts", + "nsf:transfer-record": "ts-node src/nestedShareFolders/transfer_nsf_record.ts", + "nsf:record-permission": "ts-node src/nestedShareFolders/nsf_record_permission.ts" }, "dependencies": { "@keeper-security/keeperapi": "17.1.0", diff --git a/examples/sdk_example/src/nestedShareFolders/nsf_folder.ts b/examples/sdk_example/src/nestedShareFolders/nsf_folder.ts new file mode 100644 index 00000000..30600390 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/nsf_folder.ts @@ -0,0 +1,169 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + NSF_FOLDER_COLORS, + NsfRemoveFolderOperation, + prompt, + type MkdirNsfInput, + type RemoveNsfFolderInput, + type RemoveNsfFolderResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes, splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +const ACTION_BY_INPUT: Record = { + '': 'mkdir', + '1': 'mkdir', + '2': 'rmdir', + mkdir: 'mkdir', + create: 'mkdir', + rmdir: 'rmdir', + remove: 'rmdir', +} + +const OPERATION_BY_INPUT: Record = { + '': NsfRemoveFolderOperation.FolderTrash, + '1': NsfRemoveFolderOperation.FolderTrash, + '2': NsfRemoveFolderOperation.DeletePermanent, + 'folder-trash': NsfRemoveFolderOperation.FolderTrash, + 'delete-permanent': NsfRemoveFolderOperation.DeletePermanent, +} + +const PERMANENT_DELETE_WARNING_LINES = [ + '*** WARNING ***', + 'delete-permanent is IRREVERSIBLE.', + 'All sub-folders and records inside will be permanently destroyed.', +] as const + +function parseAction(input: string): 'mkdir' | 'rmdir' { + return ACTION_BY_INPUT[input.trim().toLowerCase()] ?? 'mkdir' +} + +function parseOperation(input: string): NsfRemoveFolderOperation { + return OPERATION_BY_INPUT[input.trim().toLowerCase()] ?? NsfRemoveFolderOperation.FolderTrash +} + +function logPreview(vault: Vault, result: RemoveNsfFolderResult, quiet: boolean): void { + if (result.preview.length === 0) return + logger.info('') + logger.info(vault.formatRemoveNsfFolderPreview(result.preview, result.operation, quiet)) + logger.info('') +} + +function logPreviewWarnings(result: RemoveNsfFolderResult): void { + for (const item of result.preview) { + for (const warning of item.impact?.warnings ?? []) { + logger.info(`Warning: ${warning}`) + } + } +} + +async function mkdirNsf(vault: Vault): Promise { + const folder = (await prompt('Folder name or path (e.g. Team/Projects/Q1): ')).trim() + if (!folder) { + logger.info('Folder name is required.') + return + } + + logger.info(`Colors: ${NSF_FOLDER_COLORS.join(', ')}`) + const colorInput = (await prompt('Color (optional, leaf only) [none]: ')).trim().toLowerCase() + const color = + colorInput && (NSF_FOLDER_COLORS as readonly string[]).includes(colorInput) + ? (colorInput as MkdirNsfInput['color']) + : undefined + const noInherit = (await prompt('Do not inherit parent permissions? [y/N]: ')).trim().toLowerCase() === 'y' + + const result = await withSuppressedLogs(() => + vault.mkdirNestedShareFolder({ + folder, + color, + noInheritPermissions: noInherit, + }) + ) + + logger.info('') + if (result.message) logger.info(result.message) + logger.info(`Folder UID: ${result.folderUid}`) + logger.info(`Created: ${result.created ? 'Yes' : 'No'}`) + logger.info('') +} + +async function rmdirNsf(vault: Vault): Promise { + const folders = splitCommaSeparated(await prompt('Folder UID(s) or name(s), comma-separated: ')) + if (folders.length === 0) { + logger.info('At least one folder is required.') + return + } + + logger.info('Operation: 1) folder-trash 2) delete-permanent') + const operation = parseOperation(await prompt('Choose [1]: ')) + const dryRun = isYes(await prompt('Dry run (preview only)? [y/N]: ')) + const quiet = isYes(await prompt('Quiet (summary only)? [y/N]: ')) + const force = dryRun ? false : isYes(await prompt('Force confirm without prompt? [y/N]: ')) + + if (operation === NsfRemoveFolderOperation.DeletePermanent && !force && !dryRun) { + logger.info('') + for (const line of PERMANENT_DELETE_WARNING_LINES) { + logger.info(line) + } + logger.info('') + } + + const baseInput: RemoveNsfFolderInput = { folders, operation, quiet } + + if (dryRun) { + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, dryRun: true })) + logPreview(vault, result, quiet) + logger.info('[Dry-run] No folders were deleted.') + return + } + + if (force) { + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: true })) + logPreview(vault, result, quiet) + if (result.confirmed && result.message) logger.info(result.message) + return + } + + const preview = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: false })) + logPreview(vault, preview, quiet) + logPreviewWarnings(preview) + + const confirmPrompt = + operation === NsfRemoveFolderOperation.DeletePermanent + ? 'Do you want to permanently delete the folder(s) and all their contents? [y/n]: ' + : 'Do you want to proceed with the folder deletion? [y/n]: ' + + if (!isYes(await prompt(confirmPrompt))) { + logger.info('Removal cancelled.') + return + } + + const result = await withSuppressedLogs(() => vault.removeNestedShareFolders({ ...baseInput, force: true })) + if (result.confirmed && result.message) logger.info(result.message) +} + +async function nsfFolder() { + const vault = await login() + + try { + logger.info('Action: 1) create folder 2) remove folder') + const action = parseAction(await prompt('Choose [1]: ')) + if (action === 'mkdir') { + await mkdirNsf(vault) + } else { + await rmdirNsf(vault) + } + } catch (err) { + logger.error(`Folder operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(nsfFolder) diff --git a/examples/sdk_example/src/nestedShareFolders/share_nsf.ts b/examples/sdk_example/src/nestedShareFolders/share_nsf.ts new file mode 100644 index 00000000..b2d73ab5 --- /dev/null +++ b/examples/sdk_example/src/nestedShareFolders/share_nsf.ts @@ -0,0 +1,142 @@ +import { + NsfFolderShareAction, + NsfRecordShareAction, + cleanup, + extractErrorMessage, + login, + logger, + prompt, + NSF_RECORD_PERMISSION_ROLES, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated, withSuppressedLogs } from '../utils/format' + +type Vault = Awaited> + +const TARGET_BY_INPUT: Record = { + '': 'folder', + '1': 'folder', + '2': 'record', + folder: 'folder', + record: 'record', +} + +function parseTarget(input: string): 'folder' | 'record' { + return TARGET_BY_INPUT[input.trim().toLowerCase()] ?? 'folder' +} + +async function shareNsfFolder(vault: Vault): Promise { + const folders = splitCommaSeparated(await prompt('Folder path(s) or UID(s), comma-separated: ')) + if (folders.length === 0) { + logger.info('Folder path or UID is required.') + return + } + + const recipients = splitCommaSeparated( + await prompt('Recipient(s) — email, team name/UID, or @existing, comma-separated: ') + ) + if (recipients.length === 0) { + logger.info('At least one recipient is required.') + return + } + + const actionInput = (await prompt('Action (grant/remove) [grant]: ')).trim().toLowerCase() + const action = + actionInput === 'remove' ? NsfFolderShareAction.Remove : NsfFolderShareAction.Grant + + const role = + action === NsfFolderShareAction.Grant + ? (await prompt(`Role (${NSF_RECORD_PERMISSION_ROLES.join(', ')}) [viewer]: `)).trim() || + 'viewer' + : undefined + + const result = await withSuppressedLogs(() => + vault.shareNestedShareFolder({ + folders, + recipients, + action, + role, + }) + ) + + logger.info('') + for (const item of result.results) { + logger.info( + `${item.folderUid} ${item.recipient} ${item.actionTaken} ${item.success ? 'success' : 'failed'} ${item.message || ''}` + ) + } +} + +async function shareNsfRecord(vault: Vault): Promise { + const record = (await prompt('Record UID, title, or folder path: ')).trim() + if (!record) { + logger.info('Record path or UID is required.') + return + } + + const emails = splitCommaSeparated(await prompt('Recipient email(s), comma-separated: ')) + if (emails.length === 0) { + logger.info('At least one recipient email is required.') + return + } + + const actionInput = (await prompt('Action (grant/revoke/owner) [grant]: ')).trim().toLowerCase() + const action = + actionInput === 'revoke' + ? NsfRecordShareAction.Revoke + : actionInput === 'owner' + ? NsfRecordShareAction.Owner + : NsfRecordShareAction.Grant + + const role = + action === NsfRecordShareAction.Grant + ? (await prompt(`Role (${NSF_RECORD_PERMISSION_ROLES.join(', ')}) [viewer]: `)).trim() || + 'viewer' + : undefined + + const recursiveInput = (await prompt('Recursive when record is a folder? (y/N): ')) + .trim() + .toLowerCase() + const dryRunInput = (await prompt('Dry run? (y/N): ')).trim().toLowerCase() + + const result = await withSuppressedLogs(() => + vault.shareNestedShareRecord({ + record, + emails, + action, + role, + recursive: recursiveInput === 'y' || recursiveInput === 'yes', + dryRun: dryRunInput === 'y' || dryRunInput === 'yes', + }) + ) + + logger.info('') + logger.info(vault.formatNsfRecordSharePlan(result)) + if (!result.dryRun) { + logger.info('') + logger.info(vault.formatNsfRecordShareResults(result.results)) + } +} + +async function shareNsf() { + const vault = await login() + + try { + const target = parseTarget( + await prompt('Share target (1=folder, 2=record) [folder]: ') + ) + + if (target === 'record') { + await shareNsfRecord(vault) + } else { + await shareNsfFolder(vault) + } + } catch (err) { + logger.error(`NSF share failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(shareNsf) diff --git a/examples/sdk_example/src/roles/copyRole.ts b/examples/sdk_example/src/roles/copyRole.ts new file mode 100644 index 00000000..e5f59aff --- /dev/null +++ b/examples/sdk_example/src/roles/copyRole.ts @@ -0,0 +1,59 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { CopyRoleResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { isYes } from '../utils/format' + +async function copyRoleExample() { + const vault = await login() + + try { + const source = (await prompt('Source role name or ID to copy: ')).trim() + if (!source) { + logger.error('A source role is required.') + process.exitCode = 1 + return + } + + const nameRaw = (await prompt('New role name (Enter for " Copy"): ')).trim() + const name = nameRaw || undefined + + const parentRaw = (await prompt('Parent node name or ID (Enter to reuse source role node): ')).trim() + const parent: string | null = parentRaw || null + + const clone = isYes( + await prompt('Also clone role users and teams onto the new role? [y/N]: ') + ) + + const restore = suppressLogs() + let result: CopyRoleResult + try { + result = await vault.copyRoles({ source, name, parent, clone }) + } finally { + restore() + } + + const table = vault.formatCopyRoleResult(result) + logger.info('') + logger.info(vault.renderCopyRoleAsciiTable(table)) + logger.info('') + logger.info(`Result: ${result.success ? 'success' : 'failed'}`) + + if (!result.success) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(copyRoleExample) diff --git a/examples/sdk_example/src/roles/manageNode.ts b/examples/sdk_example/src/roles/manageNode.ts new file mode 100644 index 00000000..5fa8f9e4 --- /dev/null +++ b/examples/sdk_example/src/roles/manageNode.ts @@ -0,0 +1,113 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { + ManageRoleNodesAction, + ManageRoleNodesResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +enum ManageNodeMenuChoice { + Add = '1', + Update = '2', + Remove = '3', +} + +const ACTION_BY_CHOICE: Record = { + [ManageNodeMenuChoice.Add]: 'add', + [ManageNodeMenuChoice.Update]: 'update', + [ManageNodeMenuChoice.Remove]: 'remove', +} + +function parseCascade(raw: string): 'on' | 'off' | null | undefined { + const value = raw.trim().toLowerCase() + if (value === '' || value === 'skip') return undefined + if (value === 'on') return 'on' + if (value === 'off') return 'off' + return null +} + +async function manageNodeExample() { + const vault = await login() + + try { + logger.info('') + logger.info('Select an action:') + logger.info(` ${ManageNodeMenuChoice.Add}) Add role(s) as manager of node(s)`) + logger.info(` ${ManageNodeMenuChoice.Update}) Update cascade setting for existing role/node link(s)`) + logger.info(` ${ManageNodeMenuChoice.Remove}) Remove role(s) as manager of node(s)`) + logger.info('') + + const choice = ( + await prompt(`Action [${ManageNodeMenuChoice.Add}-${ManageNodeMenuChoice.Remove}]: `) + ).trim() as ManageNodeMenuChoice + const action = ACTION_BY_CHOICE[choice] + if (!action) { + logger.error('Invalid choice.') + process.exitCode = 1 + return + } + + const rolesRaw = (await prompt('Role name(s) or ID(s) (comma-separated): ')).trim() + const roles = splitCommaSeparated(rolesRaw) + if (roles.length === 0) { + logger.error('At least one role name or ID is required.') + process.exitCode = 1 + return + } + + const nodesRaw = (await prompt('Node name(s) or ID(s) (comma-separated): ')).trim() + const nodes = splitCommaSeparated(nodesRaw) + if (nodes.length === 0) { + logger.error('At least one node name or ID is required.') + process.exitCode = 1 + return + } + + let cascade: 'on' | 'off' | null | undefined + if (action !== 'remove') { + cascade = parseCascade( + await prompt('Cascade node management to sub-nodes? [on/off, Enter to skip]: ') + ) + if (cascade === null) { + logger.error('Invalid cascade value. Use on, off, or leave blank.') + process.exitCode = 1 + return + } + } + + const restore = suppressLogs() + let result: ManageRoleNodesResult + try { + result = await vault.manageRoleNodes({ roles, nodes, action, cascade }) + } finally { + restore() + } + + const table = vault.formatManageRoleNodesResult(result) + logger.info('') + logger.info(vault.renderManageRoleNodesAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(succeeded=${result.succeeded}, skipped=${result.skipped}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.succeeded === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(manageNodeExample) diff --git a/examples/sdk_example/src/roles/roleEnforcements.ts b/examples/sdk_example/src/roles/roleEnforcements.ts new file mode 100644 index 00000000..5abc416d --- /dev/null +++ b/examples/sdk_example/src/roles/roleEnforcements.ts @@ -0,0 +1,63 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { SetRoleEnforcementsResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +async function roleEnforcementsExample() { + const vault = await login() + + try { + const rolesRaw = (await prompt('Role name(s) or ID(s) (comma-separated): ')).trim() + const roles = splitCommaSeparated(rolesRaw) + if (roles.length === 0) { + logger.error('At least one role name or ID is required.') + process.exitCode = 1 + return + } + + const enforcementsRaw = ( + await prompt('Enforcements to apply (KEY:VALUE, comma-separated; VALUE=false removes): ') + ).trim() + const enforcements = splitCommaSeparated(enforcementsRaw) + if (enforcements.length === 0) { + logger.error('At least one KEY:VALUE enforcement is required.') + process.exitCode = 1 + return + } + + const restore = suppressLogs() + let result: SetRoleEnforcementsResult + try { + result = await vault.setRoleEnforcements({ roles, enforcements }) + } finally { + restore() + } + + const table = vault.formatSetRoleEnforcementsResult(result) + logger.info('') + logger.info(vault.renderRoleEnforcementAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(applied=${result.applied}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.applied === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(roleEnforcementsExample) diff --git a/examples/sdk_example/src/roles/rolePrivileges.ts b/examples/sdk_example/src/roles/rolePrivileges.ts new file mode 100644 index 00000000..941411a2 --- /dev/null +++ b/examples/sdk_example/src/roles/rolePrivileges.ts @@ -0,0 +1,71 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { ChangeRolePrivilegesResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +async function rolePrivilegesExample() { + const vault = await login() + + try { + const role = (await prompt('Role name or ID: ')).trim() + if (!role) { + logger.error('A role is required.') + process.exitCode = 1 + return + } + + const node = (await prompt('Managed node name or ID (must already be managed by the role): ')).trim() + if (!node) { + logger.error('A managed node is required.') + process.exitCode = 1 + return + } + + const addRaw = (await prompt('Privileges to add (comma-separated, Enter to skip): ')).trim() + const add = splitCommaSeparated(addRaw) + + const removeRaw = (await prompt('Privileges to remove (comma-separated, Enter to skip): ')).trim() + const remove = splitCommaSeparated(removeRaw) + + if (add.length === 0 && remove.length === 0) { + logger.error('At least one privilege to add or remove is required.') + process.exitCode = 1 + return + } + + const restore = suppressLogs() + let result: ChangeRolePrivilegesResult + try { + result = await vault.changeRolePrivileges({ role, node, add, remove }) + } finally { + restore() + } + + const table = vault.formatChangeRolePrivilegesResult(result) + logger.info('') + logger.info(vault.renderChangeRolePrivilegesAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(succeeded=${result.succeeded}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.succeeded === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(rolePrivilegesExample) diff --git a/examples/sdk_example/src/roles/roleUsers.ts b/examples/sdk_example/src/roles/roleUsers.ts new file mode 100644 index 00000000..452dc00c --- /dev/null +++ b/examples/sdk_example/src/roles/roleUsers.ts @@ -0,0 +1,100 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { RoleUserResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +type VaultHandle = Awaited> + +enum RoleUserMenuChoice { + Add = '1', + Remove = '2', +} + +type OperationInput = { kind: RoleUserMenuChoice; roles: string[]; users: string[] } + +function failWith(message: string): null { + logger.error(message) + process.exitCode = 1 + return null +} + +async function promptList(label: string, message: string): Promise { + const items = splitCommaSeparated((await prompt(message)).trim()) + if (items.length === 0) return failWith(`At least one ${label} is required.`) + return items +} + +async function gatherOperationInput(): Promise { + logger.info('') + logger.info('Select an operation:') + logger.info(` ${RoleUserMenuChoice.Add}) Add users to role(s)`) + logger.info(` ${RoleUserMenuChoice.Remove}) Remove users from role(s)`) + logger.info('') + + const choice = (await prompt(`Operation [${RoleUserMenuChoice.Add}-${RoleUserMenuChoice.Remove}]: `)).trim() + if (choice !== RoleUserMenuChoice.Add && choice !== RoleUserMenuChoice.Remove) { + return failWith(`Invalid choice. Please enter ${RoleUserMenuChoice.Add} or ${RoleUserMenuChoice.Remove}.`) + } + + const roles = await promptList('role name or ID', 'Role name(s) or ID(s) (comma-separated): ') + if (!roles) return null + + const users = await promptList('user email or ID', 'User email(s) or ID(s) (comma-separated): ') + if (!users) return null + + return { kind: choice, roles, users } +} + +async function executeOperation(vault: VaultHandle, input: OperationInput): Promise { + const restore = suppressLogs() + try { + switch (input.kind) { + case RoleUserMenuChoice.Add: + return await vault.addUsersToRoles({ roles: input.roles, users: input.users }) + case RoleUserMenuChoice.Remove: + return await vault.removeUsersFromRoles({ roles: input.roles, users: input.users }) + } + } finally { + restore() + } +} + +function reportResult(vault: VaultHandle, result: RoleUserResult): void { + const table = vault.formatRoleUserResult(result) + logger.info('') + logger.info(vault.renderRoleUserAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(succeeded=${result.succeeded}, skipped=${result.skipped}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.succeeded === 0)) { + process.exitCode = 1 + } +} + +async function roleUsersExample() { + const vault = await login() + try { + const input = await gatherOperationInput() + if (!input) return + + const result = await executeOperation(vault, input) + reportResult(vault, result) + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(roleUsersExample) diff --git a/examples/sdk_example/src/sharedFolders/apply_membership.ts b/examples/sdk_example/src/sharedFolders/apply_membership.ts new file mode 100644 index 00000000..7788b0dd --- /dev/null +++ b/examples/sdk_example/src/sharedFolders/apply_membership.ts @@ -0,0 +1,115 @@ +import { readFileSync } from "fs"; +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from "@keeper-security/keeper-sdk-javascript"; +import type { + ApplyMembershipInput, + ApplyMembershipOptions, + ApplyMembershipResult, +} from "@keeper-security/keeper-sdk-javascript"; +import { runExample } from "../utils/runner"; +import { isYes } from "../utils/format"; + +function summarize(result: ApplyMembershipResult): void { + for (const folder of result.folders) { + const label = folder.sharedFolderUid + ? `${folder.path} (${folder.sharedFolderUid})` + : folder.path; + if (folder.success) { + logger.info( + `${label}: users +${folder.counts.usersAdded}/~${folder.counts.usersUpdated}/-${folder.counts.usersRemoved}` + + ` teams +${folder.counts.teamsAdded}/~${folder.counts.teamsUpdated}/-${folder.counts.teamsRemoved}`, + ); + } else { + logger.error( + `${label}: FAILED${folder.message ? ` - ${folder.message}` : ""}`, + ); + } + } + for (const team of result.teamMembership) { + const label = team.teamUid ? `${team.name} (${team.teamUid})` : team.name; + if (team.success) { + logger.info( + `Team "${label}" membership: +${team.added}/-${team.removed}`, + ); + } else { + logger.error( + `Team "${label}" membership: FAILED${team.message ? ` - ${team.message}` : ""}`, + ); + } + } + const t = result.totals; + logger.info( + `Totals: users +${t.usersAdded}/~${t.usersUpdated}/-${t.usersRemoved}` + + ` teams +${t.teamsAdded}/~${t.teamsUpdated}/-${t.teamsRemoved}` + + ` team members +${t.teamMembersAdded}/-${t.teamMembersRemoved}`, + ); +} + +async function applyMembershipCommand() { + const vault = await login(); + + try { + const inputPath = + ( + await prompt("Input JSON file path (default membership.json): ") + ).trim() || "membership.json"; + const fullSync = isYes( + await prompt( + "Full sync (also update permissions and remove extras not in the file)? [y/N]: ", + ), + ); + + let raw: string; + try { + raw = readFileSync(inputPath, "utf8"); + } catch (err) { + logger.error( + `Could not read "${inputPath}": ${extractErrorMessage(err)}`, + ); + process.exitCode = 1; + return; + } + + let data: ApplyMembershipInput; + try { + data = JSON.parse(raw); + } catch (err) { + logger.error( + `Could not parse "${inputPath}" as JSON: ${extractErrorMessage(err)}`, + ); + process.exitCode = 1; + return; + } + + const options: ApplyMembershipOptions = { fullSync }; + + let result: ApplyMembershipResult; + const restore = suppressLogs(); + try { + result = await vault.applyMembership(data, options); + } finally { + restore(); + } + + summarize(result); + if (!result.success) { + logger.error( + "Some shared folders or teams could not be fully synced. See details above.", + ); + process.exitCode = 1; + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`); + process.exitCode = 1; + } finally { + cleanup(vault); + } +} + +runExample(applyMembershipCommand); diff --git a/examples/sdk_example/src/sharedFolders/download_membership.ts b/examples/sdk_example/src/sharedFolders/download_membership.ts new file mode 100644 index 00000000..043d252c --- /dev/null +++ b/examples/sdk_example/src/sharedFolders/download_membership.ts @@ -0,0 +1,59 @@ +import { writeFileSync } from "fs"; +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from "@keeper-security/keeper-sdk-javascript"; +import type { + DownloadMembershipOptions, + MembershipData, +} from "@keeper-security/keeper-sdk-javascript"; +import { runExample } from "../utils/runner"; +import { isYes } from "../utils/format"; + +async function downloadMembershipCommand() { + const vault = await login(); + + try { + const outputPath = + ( + await prompt("Output JSON file path (default membership.json): ") + ).trim() || "membership.json"; + const foldersOnly = isYes( + await prompt("Folders only (skip enterprise team membership)? [y/N]: "), + ); + const folderFilter = ( + await prompt("Filter by shared folder name/uid (leave blank for all): ") + ).trim(); + + const options: DownloadMembershipOptions = { foldersOnly }; + if (folderFilter) options.folderFilter = folderFilter; + + let data: MembershipData; + const restore = suppressLogs(); + try { + data = await vault.downloadMembership(options); + } finally { + restore(); + } + + writeFileSync(outputPath, JSON.stringify(data, null, 2), "utf8"); + + logger.info( + `Wrote ${data.shared_folders.length} shared folder(s) to "${outputPath}".`, + ); + if (data.teams) { + logger.info(`Included ${data.teams.length} enterprise team(s).`); + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`); + process.exitCode = 1; + } finally { + cleanup(vault); + } +} + +runExample(downloadMembershipCommand); diff --git a/examples/sdk_example/src/users/update_team_user.ts b/examples/sdk_example/src/users/update_team_user.ts new file mode 100644 index 00000000..01957b1c --- /dev/null +++ b/examples/sdk_example/src/users/update_team_user.ts @@ -0,0 +1,79 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import type { UpdateUsersOnTeamsResult } from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../utils/runner' +import { splitCommaSeparated } from '../utils/format' + +function parseHideSharedFolders(raw: string): boolean | null { + const value = raw.trim().toLowerCase() + if (value === 'on') return true + if (value === 'off') return false + return null +} + +async function updateTeamUserExample() { + const vault = await login() + + try { + const usersRaw = (await prompt('User email(s) or ID(s) (comma-separated): ')).trim() + const users = splitCommaSeparated(usersRaw) + if (users.length === 0) { + logger.error('At least one user email or ID is required.') + process.exitCode = 1 + return + } + + const teamsRaw = (await prompt('Team name(s) or UID(s) (comma-separated): ')).trim() + const teams = splitCommaSeparated(teamsRaw) + if (teams.length === 0) { + logger.error('At least one team name or UID is required.') + process.exitCode = 1 + return + } + + const hideSharedFolders = parseHideSharedFolders( + await prompt('Hide shared folders for these users on these teams? [on/off]: ') + ) + if (hideSharedFolders === null) { + logger.error('Invalid value. Use on or off.') + process.exitCode = 1 + return + } + + logger.info('Only users that are already members of the given team(s) will be updated.') + + const restore = suppressLogs() + let result: UpdateUsersOnTeamsResult + try { + result = await vault.updateUsersOnTeams({ users, teams, hideSharedFolders }) + } finally { + restore() + } + + const table = vault.formatUpdateTeamUserResult(result) + logger.info('') + logger.info(vault.renderUpdateTeamUserAsciiTable(table)) + logger.info('') + logger.info( + `Result: ${result.success ? 'success' : 'partial/failed'} ` + + `(updated=${result.updated}, skipped=${result.skipped}, failed=${result.failed})` + ) + + if (result.failed > 0 || (!result.success && result.updated === 0)) { + process.exitCode = 1 + } + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(updateTeamUserExample) diff --git a/keeperapi/src/browser/platform.ts b/keeperapi/src/browser/platform.ts index c3a00dfa..fc1b55b1 100644 --- a/keeperapi/src/browser/platform.ts +++ b/keeperapi/src/browser/platform.ts @@ -86,7 +86,7 @@ export const browserPlatform: Platform = class { static async importKey(keyId: string, key: Uint8Array, storage?: KeyStorage, canExport?: boolean): Promise { // An AES key for one of our Keeper objects can be used for either CBC or GCM operations. // Since CryptoKeys are bound to a particular algorithm, we need to keep a copy for each. - const extractable = !!canExport + const extractable = typeof storage?.saveKeyBytes === 'function' ? true : !!canExport const cbcKey = await this.aesCbcImportKey(key, extractable) const gcmKey = await this.aesGcmImportKey(key, extractable) cryptoKeysCache['cbc'][keyId] = cbcKey @@ -96,7 +96,8 @@ export const browserPlatform: Platform = class { if (storage.saveObject) { await storage.saveObject(this.getStorageKeyId(keyId, 'cbc'), cbcKey) await storage.saveObject(this.getStorageKeyId(keyId, 'gcm'), gcmKey) - } else { + } + if (storage.saveKeyBytes) { await storage.saveKeyBytes(keyId, key) } } @@ -114,7 +115,8 @@ export const browserPlatform: Platform = class { if (storage) { if (storage.saveObject) { await storage.saveObject(this.getStorageKeyId(keyId, 'ecc'), key) - } else { + } + if (storage.saveKeyBytes) { const jwk = await crypto.subtle.exportKey('jwk', key) const keyBytes = this.stringToBytes(JSON.stringify(jwk)) await storage.saveKeyBytes(keyId, keyBytes) @@ -168,10 +170,9 @@ export const browserPlatform: Platform = class { if (storage?.getObject) { const storageKeyId = this.getStorageKeyId(keyId, keyType) const storedKey = await storage.getObject(storageKeyId) - if (!storedKey) { - throw new Error('Unable to load crypto key ' + keyId) + if (storedKey) { + return storedKey } - return storedKey } const keyBytes = await this.loadKeyBytes(keyId, storage) @@ -212,6 +213,20 @@ export const browserPlatform: Platform = class { return key } + static async ensureAesKeyLoaded(keyId: string, storage?: KeyStorage): Promise { + if (cryptoKeysCache['cbc'][keyId] && cryptoKeysCache['gcm'][keyId]) { + return true + } + if (storage?.getKeyBytes) { + const keyBytes = await storage.getKeyBytes(keyId) + if (keyBytes) { + await this.importKey(keyId, keyBytes, storage, true) + return true + } + } + return false + } + static async unwrapKeys(keys: UnwrapKeyMap, storage?: KeyStorage): Promise { if (workerPool) { try { @@ -288,9 +303,7 @@ export const browserPlatform: Platform = class { await this.unwrapRSAKey(key, keyId, unwrappingKeyId, encryptionType, storage) break case 'aes': - if (cryptoKeysCache['gcm'][keyId]) { - // Keeperapp sometimes provides redundant key data, for example, like if you own a record in a shared folder, - // or if a record belongs to multiple shared folders. So, short circuit when possible for a performance improvement + if (await this.ensureAesKeyLoaded(keyId, storage)) { return } @@ -298,7 +311,7 @@ export const browserPlatform: Platform = class { break // TODO: add something like this, need to find pub/priv key pair case 'ecc': - if (cryptoKeysCache['gcm'][keyId]) { + if (cryptoKeysCache['ecc'][keyId]) { return } @@ -365,7 +378,8 @@ export const browserPlatform: Platform = class { break } - const canExtract: boolean = storage?.saveObject ? !!canExport : true + const mustPersistKeyBytes = typeof storage?.saveKeyBytes === 'function' + const canExtract: boolean = mustPersistKeyBytes ? true : storage?.saveObject ? !!canExport : true const keyUsages: KeyUsage[] = ['encrypt', 'decrypt', 'unwrapKey', 'wrapKey'] const gcmKey = await crypto.subtle.unwrapKey( @@ -394,7 +408,8 @@ export const browserPlatform: Platform = class { if (storage.saveObject) { await storage.saveObject(this.getStorageKeyId(keyId, 'cbc'), cbcKey) await storage.saveObject(this.getStorageKeyId(keyId, 'gcm'), gcmKey) - } else { + } + if (storage.saveKeyBytes) { const keyBuffer = await crypto.subtle.exportKey('raw', gcmKey) await storage.saveKeyBytes(keyId, new Uint8Array(keyBuffer)) } diff --git a/keeperapi/src/commands.ts b/keeperapi/src/commands.ts index 6e566b34..54eeb596 100644 --- a/keeperapi/src/commands.ts +++ b/keeperapi/src/commands.ts @@ -211,6 +211,25 @@ export const enterpriseAllocateIdsCommand = ( ): RestCommand => createCommand(request, 'enterprise_allocate_ids') +export type NodeEditRequest = { + node_id: number + parent_id?: number + encrypted_data: string +} + +export const nodeAddCommand = (request: NodeEditRequest): RestCommand => + createCommand(request, 'node_add') + +export const nodeUpdateCommand = (request: NodeEditRequest): RestCommand => + createCommand(request, 'node_update') + +export type NodeDeleteRequest = { + node_id: number +} + +export const nodeDeleteCommand = (request: NodeDeleteRequest): RestCommand => + createCommand(request, 'node_delete') + export type RoleEditRequest = { role_id: number node_id: number @@ -232,6 +251,117 @@ export type RoleDeleteRequest = { export const roleDeleteCommand = (request: RoleDeleteRequest): RestCommand => createCommand(request, 'role_delete') +export type RoleUserAddRequest = { + role_id: number + enterprise_user_id: number + tree_key?: string + tree_key_type?: string | number + role_admin_key?: string + role_admin_key_type?: string | number +} + +export const roleUserAddCommand = (request: RoleUserAddRequest): RestCommand => + createCommand(request, 'role_user_add') + +export type RoleUserRemoveRequest = { + role_id: number + enterprise_user_id: number +} + +export const roleUserRemoveCommand = ( + request: RoleUserRemoveRequest +): RestCommand => createCommand(request, 'role_user_remove') + +export type RoleManagedNodeTreeKey = { + enterprise_user_id: number + tree_key: string + tree_key_type?: string | number +} + +export type RoleManagedNodeAddResponse = KeeperResponse & { + missing_tree_keys?: Record + missing_ecc_tree_keys?: Record +} + +export type RoleManagedNodeAddRequest = { + role_id: number + managed_node_id: number + cascade_node_management?: boolean + tree_keys?: RoleManagedNodeTreeKey[] +} + +export const roleManagedNodeAddCommand = ( + request: RoleManagedNodeAddRequest +): RestCommand => createCommand(request, 'role_managed_node_add') + +export type RoleManagedNodeUpdateRequest = { + role_id: number + managed_node_id: number + cascade_node_management?: boolean + tree_keys?: RoleManagedNodeTreeKey[] +} + +export const roleManagedNodeUpdateCommand = ( + request: RoleManagedNodeUpdateRequest +): RestCommand => createCommand(request, 'role_managed_node_update') + +export type RoleManagedNodeRemoveRequest = { + role_id: number + managed_node_id: number +} + +export const roleManagedNodeRemoveCommand = ( + request: RoleManagedNodeRemoveRequest +): RestCommand => createCommand(request, 'role_managed_node_remove') + +/** Role AES key encrypted for an admin user (TRANSFER_ACCOUNT backward compat). */ +export type ManagedNodeRoleKey = { + enterprise_user_id: number + role_key: string + tree_key_type?: string | number +} + +/** MSP tree key for MANAGE_COMPANIES privilege. */ +export type ManagedNodeMspKey = { + mc_enterprise_id: number + tree_key: string +} + +export type ManagedNodePrivilegeAddRequest = { + role_id: number + managed_node_id: number + privilege: string + role_key_enc_with_tree_key?: string + role_public_key?: string + role_private_key?: string + role_keys?: ManagedNodeRoleKey[] + msp_keys?: ManagedNodeMspKey[] +} + +export type ManagedNodePrivilegeAddResponse = KeeperResponse & { + status?: { + missing_keys?: Record + invalid_users?: number[] + cant_be_pending?: number[] + } +} + +export const managedNodePrivilegeAddCommand = ( + request: ManagedNodePrivilegeAddRequest +): RestCommand => + createCommand(request, 'managed_node_privilege_add') + +export type ManagedNodePrivilegeRemoveRequest = { + role_id: number + managed_node_id: number + privilege: string +} + +export const managedNodePrivilegeRemoveCommand = ( + request: ManagedNodePrivilegeRemoveRequest +): RestCommand => + createCommand(request, 'managed_node_privilege_remove') + export type MoveRequest = { to_type: 'user_folder' | 'shared_folder' | 'shared_folder_folder' to_uid?: string @@ -379,6 +509,16 @@ export const teamEnterpriseUserRemoveCommand = ( request: TeamUserCommandRequest ): RestCommand => createCommand(request, 'team_enterprise_user_remove') +export type TeamEnterpriseUserUpdateRequest = { + team_uid: string + enterprise_user_id: number + user_type: number +} + +export const teamEnterpriseUserUpdateCommand = ( + request: TeamEnterpriseUserUpdateRequest +): RestCommand => createCommand(request, 'team_enterprise_user_update') + export type TeamGetKeysRequest = { teams: string[] } diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index e8301747..7fbd629d 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -640,6 +640,11 @@ export const enterpriseUsersLockMessage = ( ): RestMessage => createMessage(data, 'enterprise/enterprise_users_lock', Enterprise.LockUsersRequest, Enterprise.LockUsersResponse) +export const disableTwoFaMessage = ( + data: Enterprise.IEnterpriseUserIds +): RestInMessage => + createInMessage(data, 'enterprise/disable_two_fa', Enterprise.EnterpriseUserIds) + export const setV2AlternatePasswordMessage = ( data: Authentication.IUserAuthRequest ): RestInMessage => diff --git a/keeperapi/src/vault.ts b/keeperapi/src/vault.ts index 9efb78f4..96723eb2 100644 --- a/keeperapi/src/vault.ts +++ b/keeperapi/src/vault.ts @@ -1597,15 +1597,14 @@ export const syncDown = async (options: SyncDownOptions): Promise => const dToken = await storage.get('continuationToken') let continuationToken = dToken ? platform.base64ToBytes(dToken.token) : undefined - await platform.importKey('data', auth.dataKey!, undefined, true) + await platform.importKey('data', auth.dataKey!, storage, true) await platform.importKeyEC( 'pk_ecc', new Uint8Array(auth.eccPrivateKey!), new Uint8Array(auth.eccPublicKey!), - undefined, - true + storage ) - await platform.importKeyRSA('pk_rsa', auth.privateKey!, undefined, true) + await platform.importKeyRSA('pk_rsa', auth.privateKey!, storage) while (true) { const msg = syncDownMessage({