From 0a2d290046ff982bf73206edd750cf55997f5ee6 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Mon, 6 Apr 2026 10:40:46 +0530 Subject: [PATCH 1/8] Implemented example for records operation along ith login flow --- KeeperSdk/package.json | 17 + KeeperSdk/src/auth/ConsoleAuthUI.ts | 168 ++++++ KeeperSdk/src/auth/ConsoleLogin.ts | 244 ++++++++ KeeperSdk/src/auth/SessionManager.ts | 127 ++++ KeeperSdk/src/index.ts | 81 +++ KeeperSdk/src/records/RecordOperations.ts | 562 ++++++++++++++++++ KeeperSdk/src/records/RecordUtils.ts | 177 ++++++ KeeperSdk/src/sharing/Sharing.ts | 441 ++++++++++++++ KeeperSdk/src/storage/InMemoryStorage.ts | 132 ++++ KeeperSdk/src/utils/Logger.ts | 41 ++ KeeperSdk/src/utils/constants.ts | 7 + KeeperSdk/src/utils/errors.ts | 58 ++ KeeperSdk/src/vault/KeeperVault.ts | 409 +++++++++++++ KeeperSdk/tsconfig.json | 14 + examples/sdk_example/package.json | 29 + examples/sdk_example/src/auth/login.ts | 30 + .../src/auth/session_token_login.ts | 61 ++ .../sdk_example/src/records/add_record.ts | 54 ++ .../sdk_example/src/records/delete_record.ts | 45 ++ .../sdk_example/src/records/find_password.ts | 57 ++ .../sdk_example/src/records/get_record.ts | 126 ++++ .../sdk_example/src/records/list_records.ts | 29 + .../sdk_example/src/records/move_record.ts | 61 ++ .../sdk_example/src/records/record_history.ts | 125 ++++ .../sdk_example/src/records/update_record.ts | 85 +++ .../sdk_example/src/sharing/share_record.ts | 62 ++ .../sdk_example/src/sharing/share_report.ts | 94 +++ examples/sdk_example/tsconfig.json | 20 + 28 files changed, 3356 insertions(+) create mode 100644 KeeperSdk/package.json create mode 100644 KeeperSdk/src/auth/ConsoleAuthUI.ts create mode 100644 KeeperSdk/src/auth/ConsoleLogin.ts create mode 100644 KeeperSdk/src/auth/SessionManager.ts create mode 100644 KeeperSdk/src/index.ts create mode 100644 KeeperSdk/src/records/RecordOperations.ts create mode 100644 KeeperSdk/src/records/RecordUtils.ts create mode 100644 KeeperSdk/src/sharing/Sharing.ts create mode 100644 KeeperSdk/src/storage/InMemoryStorage.ts create mode 100644 KeeperSdk/src/utils/Logger.ts create mode 100644 KeeperSdk/src/utils/constants.ts create mode 100644 KeeperSdk/src/utils/errors.ts create mode 100644 KeeperSdk/src/vault/KeeperVault.ts create mode 100644 KeeperSdk/tsconfig.json create mode 100644 examples/sdk_example/package.json create mode 100644 examples/sdk_example/src/auth/login.ts create mode 100644 examples/sdk_example/src/auth/session_token_login.ts create mode 100644 examples/sdk_example/src/records/add_record.ts create mode 100644 examples/sdk_example/src/records/delete_record.ts create mode 100644 examples/sdk_example/src/records/find_password.ts create mode 100644 examples/sdk_example/src/records/get_record.ts create mode 100644 examples/sdk_example/src/records/list_records.ts create mode 100644 examples/sdk_example/src/records/move_record.ts create mode 100644 examples/sdk_example/src/records/record_history.ts create mode 100644 examples/sdk_example/src/records/update_record.ts create mode 100644 examples/sdk_example/src/sharing/share_record.ts create mode 100644 examples/sdk_example/src/sharing/share_report.ts create mode 100644 examples/sdk_example/tsconfig.json diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json new file mode 100644 index 0000000..a78e51f --- /dev/null +++ b/KeeperSdk/package.json @@ -0,0 +1,17 @@ +{ + "name": "keeper-sdk", + "version": "1.0.0", + "description": "High-level wrapper for Keeper Security JavaScript SDK", + "main": "src/index.ts", + "scripts": { + "link-local": "npm link ../keeperapi", + "types": "tsc --watch", + "types:ci": "tsc" + }, + "dependencies": { + "@keeper-security/keeperapi": "17.1.0", + "@types/node": "^20.9.1", + "ts-node": "^10.7.0", + "typescript": "^4.6.3" + } +} diff --git a/KeeperSdk/src/auth/ConsoleAuthUI.ts b/KeeperSdk/src/auth/ConsoleAuthUI.ts new file mode 100644 index 0000000..8bd403a --- /dev/null +++ b/KeeperSdk/src/auth/ConsoleAuthUI.ts @@ -0,0 +1,168 @@ +import readline from 'readline' +import type { AuthUI3, DeviceApprovalChannel, TwoFactorChannelData } from '@keeper-security/keeperapi' +import { Authentication } from '@keeper-security/keeperapi' +import { logger } from '../utils/Logger' +import { extractErrorMessage } from '../utils/errors' + +const APPROVAL_TIMEOUT_MS = 60_000 +const CODE_VALIDATION_DELAY_MS = 2_000 + +enum DeviceVerificationMethod { + Email = 0, + KeeperPush = 1, + TFA = 2, + AdminApproval = 3, +} + +function channelName(channel: number): string { + switch (channel) { + case DeviceVerificationMethod.Email: + return 'Email Verification' + case DeviceVerificationMethod.KeeperPush: + return 'Keeper Push' + case DeviceVerificationMethod.TFA: + return 'Two-Factor Authentication' + case DeviceVerificationMethod.AdminApproval: + return 'Admin Approval' + default: + return `Channel ${channel}` + } +} + +function twoFactorChannelName(channelType: Authentication.TwoFactorChannelType): string { + switch (channelType) { + case Authentication.TwoFactorChannelType.TWO_FA_CT_TOTP: + return 'Authenticator App (TOTP)' + case Authentication.TwoFactorChannelType.TWO_FA_CT_SMS: + return 'SMS' + case Authentication.TwoFactorChannelType.TWO_FA_CT_DUO: + return 'Duo Security' + case Authentication.TwoFactorChannelType.TWO_FA_CT_RSA: + return 'RSA SecurID' + case Authentication.TwoFactorChannelType.TWO_FA_CT_DNA: + return 'Keeper DNA' + case Authentication.TwoFactorChannelType.TWO_FA_CT_U2F: + return 'U2F Security Key' + case Authentication.TwoFactorChannelType.TWO_FA_CT_WEBAUTHN: + return 'WebAuthn Security Key' + case Authentication.TwoFactorChannelType.TWO_FA_CT_KEEPER: + return 'Keeper' + default: + return `2FA Channel ${channelType}` + } +} + +function askQuestion(rl: readline.Interface, question: string): Promise { + return new Promise((resolve) => { + rl.question(question, (answer) => resolve(answer.trim())) + }) +} + +function waitWithCancel(timeoutMs: number, cancel?: Promise): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs) + cancel?.then(() => { + clearTimeout(timer) + resolve() + }) + }) +} + +export class ConsoleAuthUI implements AuthUI3 { + public async waitForDeviceApproval(channels: DeviceApprovalChannel[], isCloud: boolean): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + + try { + logger.info('\n--- Device Approval Required ---') + logger.info('This device needs to be approved before you can log in.') + logger.info('Available verification methods:') + channels.forEach((ch, i) => { + logger.info(` ${i + 1}. ${channelName(ch.channel)}`) + }) + + const choice = await askQuestion(rl, '\nSelect method (number): ') + const idx = parseInt(choice, 10) - 1 + + if (isNaN(idx) || idx < 0 || idx >= channels.length) { + logger.warn('Invalid selection, cancelling.') + return false + } + + const selected = channels[idx] + logger.info(`\nSending ${channelName(selected.channel)} request...`) + await selected.sendApprovalRequest() + + if (selected.validateCode) { + const code = await askQuestion(rl, 'Enter verification code: ') + if (!code) return false + await selected.validateCode(code) + } else { + logger.info('Approval request sent. Waiting for approval on your other device...') + await waitWithCancel(APPROVAL_TIMEOUT_MS) + } + + return true + } catch (e) { + logger.error('Device approval error:', extractErrorMessage(e)) + return false + } finally { + rl.close() + } + } + + public async waitForTwoFactorCode(channels: TwoFactorChannelData[], cancel: Promise): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + + try { + logger.info('\n--- Two-Factor Authentication Required ---') + logger.info('Available 2FA methods:') + channels.forEach((ch, i) => { + const name = twoFactorChannelName(ch.channel.channelType!) + logger.info(` ${i + 1}. ${name}`) + }) + + const choice = await askQuestion(rl, '\nSelect method (number): ') + const idx = parseInt(choice, 10) - 1 + + if (isNaN(idx) || idx < 0 || idx >= channels.length) { + logger.warn('Invalid selection, cancelling.') + return false + } + + const selected = channels[idx] + const name = twoFactorChannelName(selected.channel.channelType!) + + if (selected.availablePushes && selected.availablePushes.length > 0) { + const pushChoice = await askQuestion(rl, `Send push notification for ${name}? (y/n): `) + if (pushChoice.toLowerCase() === 'y' && selected.sendPush) { + selected.sendPush(selected.availablePushes[0]) + logger.info('Push sent. Waiting for approval...') + await waitWithCancel(APPROVAL_TIMEOUT_MS, cancel) + return true + } + } + + const code = await askQuestion(rl, `Enter ${name} code: `) + if (!code) return false + + selected.sendCode(code) + await waitWithCancel(CODE_VALIDATION_DELAY_MS, cancel) + return true + } catch (e) { + logger.error('2FA error:', extractErrorMessage(e)) + return false + } finally { + rl.close() + } + } + + public async getPassword(isAlternate: boolean): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + try { + const label = isAlternate ? 'alternate master password' : 'master password' + return await askQuestion(rl, `Enter your ${label}: `) + } finally { + rl.close() + } + } +} diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts new file mode 100644 index 0000000..ce8de1b --- /dev/null +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -0,0 +1,244 @@ +import readline from 'readline' +import fs from 'fs' +import path from 'path' +import os from 'os' +import { KeeperVault } from '../vault/KeeperVault' +import { logger } from '../utils/Logger' +import { extractResultCode, extractErrorMessage, KeeperSdkError } from '../utils/errors' +import { SdkDefaults } from '../utils/constants' + +class ReadlineManager { + private rl: readline.Interface + + constructor() { + this.rl = this.create() + } + + private create(): readline.Interface { + return readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + } + + public reopen(): void { + this.rl = this.create() + } + + public question(query: string): Promise { + return new Promise((resolve) => { + this.rl.question(query, (answer) => resolve(answer.trim())) + }) + } + + public close(): void { + this.rl.close() + } +} + +const rlManager = new ReadlineManager() + +export function prompt(question: string, masked = false): Promise { + if (!masked) { + return rlManager.question(question) + } + + return new Promise((resolve, reject) => { + rlManager.close() + process.stdout.write(question) + let buf = '' + process.stdin.setRawMode(true) + process.stdin.resume() + process.stdin.setEncoding('utf8') + + const onData = (str: string) => { + for (const ch of str) { + if (ch === '\n' || ch === '\r') { + process.stdout.write('\n') + process.stdin.setRawMode(false) + process.stdin.pause() + process.stdin.removeListener('data', onData) + rlManager.reopen() + resolve(buf.trim()) + return + } else if (ch === '\u0003') { + process.stdout.write('\n') + process.stdin.setRawMode(false) + process.stdin.pause() + process.stdin.removeListener('data', onData) + rlManager.reopen() + reject(new KeeperSdkError('Operation cancelled by user.', 'user_cancelled')) + return + } else if (ch === '\u007F' || ch === '\b') { + if (buf.length > 0) { + buf = buf.slice(0, -1) + process.stdout.write('\b \b') + } + } else { + buf += ch + process.stdout.write('*') + } + } + } + + process.stdin.on('data', onData) + }) +} + +export const KEEPER_PUBLIC_HOSTS: Record = { + US: 'keepersecurity.com', + EU: 'keepersecurity.eu', + AU: 'keepersecurity.com.au', + GOV: 'govcloud.keepersecurity.us', + JP: 'keepersecurity.jp', + CA: 'keepersecurity.ca', + DEV: 'dev.keepersecurity.com', +} + +type KeeperConfig = { + last_login?: string + user?: string + last_server?: string + server?: string + users?: { user?: string; server?: string }[] +} + +export function loadKeeperConfig(): KeeperConfig { + const configPath = path.join(os.homedir(), '.keeper', 'config.json') + try { + if (fs.existsSync(configPath)) { + return JSON.parse(fs.readFileSync(configPath, 'utf-8')) + } + } catch {} + return {} +} + +export async function resolveServer(username?: string): Promise { + const config = loadKeeperConfig() + const configServer = config.last_server || config.server + + if (username) { + const users = config.users || [] + const userEntry = users.find( + (u) => u.user?.toLowerCase() === username.toLowerCase() + ) + if (userEntry?.server) return userEntry.server + } + + if (configServer) return configServer + + logger.info('Select server region:') + const entries = Object.entries(KEEPER_PUBLIC_HOSTS) + entries.forEach(([region, host], i) => { + logger.info(` ${i + 1}. ${region} (${host})`) + }) + logger.info(` Or enter a hostname directly (e.g. dev.keepersecurity.com)`) + + const choice = await prompt('Region [1 = US]: ') + + if (!choice) return KEEPER_PUBLIC_HOSTS.US + + const idx = parseInt(choice, 10) - 1 + if (idx >= 0 && idx < entries.length) return entries[idx][1] + + const byName = KEEPER_PUBLIC_HOSTS[choice.toUpperCase()] + if (byName) return byName + + return choice +} + +export function suppressLogs(): () => void { + const origLog = console.log + const origWarn = console.warn + const origDebug = console.debug + const origWrite = process.stdout.write.bind(process.stdout) + + console.log = () => {} + console.warn = () => {} + console.debug = () => {} + const boundWrite: typeof process.stdout.write = () => true + process.stdout.write = boundWrite + + return () => { + console.log = origLog + console.warn = origWarn + console.debug = origDebug + process.stdout.write = origWrite + } +} + +export async function login(): Promise { + const config = loadKeeperConfig() + const defaultUsername = config.last_login || config.user || '' + + let username: string + if (defaultUsername) { + logger.info(`Enter password for ${defaultUsername}`) + username = defaultUsername + } else { + username = await prompt('Username (email): ') + } + + if (!username) { + throw new KeeperSdkError('Username is required.', 'missing_username') + } + + const host = await resolveServer(username) + + const vault = new KeeperVault({ + host, + clientVersion: SdkDefaults.CLIENT_VERSION, + }) + + const savedConsoleLog = console.log + + while (true) { + // Restore console.log in case it was suppressed after a failed attempt + console.log = savedConsoleLog + + const password = await prompt('Password: ', true) + + if (!password) { + throw new KeeperSdkError('Password is required.', 'missing_password') + } + + const restore = suppressLogs() + try { + await vault.login(username, password) + restore() + break + } catch (err) { + restore() + const resultCode = extractResultCode(err) + if (resultCode === 'invalid_credentials') { + logger.warn('Invalid credentials') + console.log = () => {} + continue + } + throw KeeperSdkError.from(err) + } + } + + logger.info('Syncing vault...') + + const restore2 = suppressLogs() + try { + await vault.sync() + } finally { + restore2() + } + + logger.info(`Vault synced. ${vault.getSummary().recordCount} records loaded.\n`) + + return vault +} + +export async function cleanup(vault: KeeperVault): Promise { + const restore = suppressLogs() + try { + await vault.logout() + } finally { + restore() + } + rlManager.close() +} diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts new file mode 100644 index 0000000..b776dff --- /dev/null +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -0,0 +1,127 @@ +import fs from 'fs' +import path from 'path' +import os from 'os' +import type { DeviceConfig, SessionStorage, KeeperHost, SessionParams } from '@keeper-security/keeperapi' +import { logger } from '../utils/Logger' +import { SdkDefaults } from '../utils/constants' + +type PersistedDeviceConfig = { + readonly deviceToken?: string + readonly privateKey?: string + readonly publicKey?: string + readonly deviceName?: string + readonly transmissionKeyId?: number + readonly mlKemPublicKeyId?: number + readonly useHpkeTransmission?: boolean +} + +type PersistedConfig = { + lastUsername?: string + devices: Record + cloneCodes: Record +} + +export class SessionManager implements SessionStorage { + private readonly configPath: string + private readonly config: PersistedConfig + private sessionParams: SessionParams | null = null + private _lastUsername?: string + + constructor(configDir?: string) { + const dir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + this.configPath = path.join(dir, SdkDefaults.CONFIG_FILE) + this.config = this.load() + this._lastUsername = this.config.lastUsername + } + + public get lastUsername(): string | undefined { + return this._lastUsername + } + + public getDeviceConfig(host: string): DeviceConfig { + const persisted = this.config.devices[host] + if (!persisted) return {} + + return { + deviceToken: persisted.deviceToken ? Buffer.from(persisted.deviceToken, 'base64') : undefined, + privateKey: persisted.privateKey ? Buffer.from(persisted.privateKey, 'base64') : undefined, + publicKey: persisted.publicKey ? Buffer.from(persisted.publicKey, 'base64') : undefined, + deviceName: persisted.deviceName, + transmissionKeyId: persisted.transmissionKeyId, + mlKemPublicKeyId: persisted.mlKemPublicKeyId, + useHpkeTransmission: persisted.useHpkeTransmission, + } + } + + public createOnDeviceConfig(host: string): (deviceConfig: DeviceConfig) => Promise { + return async (deviceConfig: DeviceConfig) => { + this.config.devices[host] = { + deviceToken: deviceConfig.deviceToken ? Buffer.from(deviceConfig.deviceToken).toString('base64') : undefined, + privateKey: deviceConfig.privateKey ? Buffer.from(deviceConfig.privateKey).toString('base64') : undefined, + publicKey: deviceConfig.publicKey ? Buffer.from(deviceConfig.publicKey).toString('base64') : undefined, + deviceName: deviceConfig.deviceName, + transmissionKeyId: deviceConfig.transmissionKeyId, + mlKemPublicKeyId: deviceConfig.mlKemPublicKeyId, + useHpkeTransmission: deviceConfig.useHpkeTransmission, + } + this.save() + } + } + + private cloneCodeKey(host: KeeperHost, username: string): string { + return `${host}::${username}` + } + + public async getCloneCode(host: KeeperHost, username: string): Promise { + const key = this.cloneCodeKey(host, username) + const encoded = this.config.cloneCodes[key] + if (!encoded) return null + return Buffer.from(encoded, 'base64') + } + + public async saveCloneCode(host: KeeperHost, username: string, cloneCode: Uint8Array): Promise { + const key = this.cloneCodeKey(host, username) + this.config.cloneCodes[key] = Buffer.from(cloneCode).toString('base64') + this.save() + } + + public async getSessionParameters(): Promise { + return this.sessionParams + } + + public async saveSessionParameters(params: Partial): Promise { + this.sessionParams = params as SessionParams + if (params.username) { + this.config.lastUsername = params.username + this._lastUsername = params.username + this.save() + } + } + + public setLastUsername(username: string): void { + this.config.lastUsername = username + this._lastUsername = username + this.save() + } + + private load(): PersistedConfig { + try { + if (fs.existsSync(this.configPath)) { + const raw = fs.readFileSync(this.configPath, 'utf-8') + return JSON.parse(raw) + } + } catch {} + return { devices: {}, cloneCodes: {} } + } + + private save(): void { + try { + fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf-8') + } catch (e) { + logger.error('Failed to save SDK config:', e) + } + } +} diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts new file mode 100644 index 0000000..6180f3f --- /dev/null +++ b/KeeperSdk/src/index.ts @@ -0,0 +1,81 @@ +export { ConsoleAuthUI } from './auth/ConsoleAuthUI' +export { SessionManager } from './auth/SessionManager' +export { + login, + cleanup, + prompt, + suppressLogs, + loadKeeperConfig, + resolveServer, + KEEPER_PUBLIC_HOSTS +} from './auth/ConsoleLogin' + +export { InMemoryStorage } from './storage/InMemoryStorage' + +export { Logger, LogLevel, logger } from './utils/Logger' +export { KeeperSdkError, isKeeperError, extractErrorMessage, extractResultCode } from './utils/errors' +export { SdkDefaults } from './utils/constants' + +export { + searchRecords, + formatRecord, + getRecordTitle, + getRecordType, + getRecordFields, + getRecordPassword, + getRecordLogin, + getRecordUrl, + RecordVersion, +} from './records/RecordUtils' +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 { ShareReportGenerator, shareRecord, removeRecordShare } from './sharing/Sharing' +export type { + ShareReportEntry, + SharedFolderReportEntry, + ShareSummaryEntry, + ShareRecordInput, + ShareRecordResult, + RemoveShareInput, + RemoveShareResult, +} from './sharing/Sharing' + +export { KeeperVault } from './vault/KeeperVault' +export type { KeeperVaultConfig, VaultSummary } from './vault/KeeperVault' + +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/records/RecordOperations.ts b/KeeperSdk/src/records/RecordOperations.ts new file mode 100644 index 0000000..159b0e1 --- /dev/null +++ b/KeeperSdk/src/records/RecordOperations.ts @@ -0,0 +1,562 @@ +import { + Auth, + Records, + platform, + generateEncryptionKey, + generateUidBytes, + webSafe64FromBytes, + recordsAddMessage, + recordsUpdateMessage, + recordPreDeleteCommand, + recordDeleteCommand, + recordAddCommand, + moveCommand, +} from '@keeper-security/keeperapi' +import type { + DSharedFolder, + DSharedFolderFolder, + DSharedFolderRecord, + DUserFolder, + DRecord, + KeeperResponse, + KeeperPreDeleteResponse, + MoveObject, + MoveRequest, + TransitionKeyObject, + RecordPreDeleteObject, + RestCommand, + BaseRequest, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError } from '../utils/errors' +import { RecordVersion } from './RecordUtils' +import { InMemoryStorage } from '../storage/InMemoryStorage' + +enum FolderType { + UserFolder = 'user_folder', + SharedFolder = 'shared_folder', + SharedFolderFolder = 'shared_folder_folder', +} + +enum ObjectType { + Record = 'record', +} + +enum DeleteResolution { + Unlink = 'unlink', +} + +enum ResultCode { + Success = 'success', + OK = 'OK', +} + +enum CommandName { + GetRecordHistory = 'get_record_history', +} + +const MIN_RECORD_PAD_BYTES = 384 +const PAD_BLOCK_SIZE = 16 + +// Pads JSON to minimum 384 bytes, rounded up to nearest multiple of 16. +function getPaddedJsonBytes(data: Record): Uint8Array { + const json = JSON.stringify(data) + const paddedLength = Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * PAD_BLOCK_SIZE + const padded = json.padEnd(paddedLength, ' ') + return new TextEncoder().encode(padded) +} + +export type PasswordRecordData = { + title: string + login?: string + password?: string + url?: string + notes?: string + custom?: { name: string; value: string; type?: string }[] +} + +export type RecordFieldInput = { + type: string + value: any[] + label?: string +} + +export type TypedRecordData = { + type: string + title: string + fields?: RecordFieldInput[] + custom?: RecordFieldInput[] + notes?: string +} + +export type NewRecordInput = + | { version: 2; data: PasswordRecordData; folderUid?: string } + | { version: 3; data: TypedRecordData; folderUid?: string } + +export type AddRecordResult = { + recordUid: string + success: boolean + status?: string +} + +export type UpdateRecordResult = { + recordUid: string + success: boolean + status?: string +} + +export type DeleteRecordResult = { + recordUid: string + success: boolean + message?: string +} + +export async function addRecord( + auth: Auth, + input: NewRecordInput +): Promise { + if (input.version === 2) { + return addPasswordRecord(auth, input.data, input.folderUid) + } + return addTypedRecord(auth, input.data, input.folderUid) +} + +// v2 PasswordRecord via JSON command API, record key encrypted with AES-CBC. +async function addPasswordRecord( + auth: Auth, + data: PasswordRecordData, + folderUid?: string +): Promise { + const recordUidBytes = generateUidBytes() + const recordKey = generateEncryptionKey() + const recordUid = webSafe64FromBytes(recordUidBytes) + + const recordDataJson = JSON.stringify({ + title: data.title || '', + secret1: data.login || '', + secret2: data.password || '', + link: data.url || '', + notes: data.notes || '', + custom: (data.custom || []).map((c) => ({ + name: c.name, + value: c.value, + type: c.type || 'text', + })), + }) + + const extraJson = JSON.stringify({}) + + const dataBytes = new TextEncoder().encode(recordDataJson) + const extraBytes = new TextEncoder().encode(extraJson) + + const encryptedData = await platform.aesCbcEncrypt(dataBytes, recordKey, true) + const encryptedExtra = await platform.aesCbcEncrypt(extraBytes, recordKey, true) + const encryptedRecordKey = await platform.aesCbcEncrypt(recordKey, auth.dataKey!, true) + + const cmd = recordAddCommand({ + record_uid: recordUid, + record_key: webSafe64FromBytes(encryptedRecordKey), + record_type: 'password', + folder_type: FolderType.UserFolder, + how_long_ago: 0, + folder_uid: folderUid || '', + folder_key: '', + data: webSafe64FromBytes(encryptedData), + extra: webSafe64FromBytes(encryptedExtra), + non_shared_data: '', + file_ids: [], + }) + + const response = await auth.executeRestCommand(cmd) + + return { + recordUid, + success: response.result_code === ResultCode.Success, + status: response.result_code, + } +} + +// v3 TypedRecord via protobuf REST API, record key encrypted with AES-GCM. +async function addTypedRecord( + auth: Auth, + data: TypedRecordData, + folderUid?: string +): Promise { + const recordUidBytes = generateUidBytes() + const recordKey = generateEncryptionKey() + const recordUid = webSafe64FromBytes(recordUidBytes) + + const recordPayload = { + type: data.type, + title: data.title, + fields: data.fields || [], + custom: data.custom || [], + notes: data.notes || '', + } + + const dataBytes = getPaddedJsonBytes(recordPayload) + const encryptedData = await platform.aesGcmEncrypt(dataBytes, recordKey) + const encryptedRecordKey = await platform.aesGcmEncrypt(recordKey, auth.dataKey!) + + const recordAdd: Records.IRecordAdd = { + recordUid: recordUidBytes, + recordKey: encryptedRecordKey, + clientModifiedTime: Date.now(), + data: encryptedData, + folderType: Records.RecordFolderType.user_folder, + } + + if (folderUid) { + recordAdd.folderUid = platform.base64ToBytes(folderUid) + } + + const request: Records.IRecordsAddRequest = { + records: [recordAdd], + clientTime: Date.now(), + } + + const msg = recordsAddMessage(request) + const response = await auth.executeRest(msg) + + const recordStatus = response.records?.[0] + const success = + recordStatus?.status === Records.RecordModifyResult.RS_SUCCESS || + !recordStatus?.status + + return { + recordUid, + success, + status: recordStatus?.status != null + ? Records.RecordModifyResult[recordStatus.status] + : ResultCode.OK, + } +} + +export async function updateRecord( + auth: Auth, + recordUid: string, + data: TypedRecordData, + revision: number, + recordKey: Uint8Array +): Promise { + const recordUidBytes = platform.base64ToBytes(recordUid) + + const recordPayload = { + type: data.type, + title: data.title, + fields: data.fields || [], + custom: data.custom || [], + notes: data.notes || '', + } + + const dataBytes = getPaddedJsonBytes(recordPayload) + const encryptedData = await platform.aesGcmEncrypt(dataBytes, recordKey) + + const recordUpdate: Records.IRecordUpdate = { + recordUid: recordUidBytes, + clientModifiedTime: Date.now(), + revision, + data: encryptedData, + } + + const request: Records.IRecordsUpdateRequest = { + records: [recordUpdate], + clientTime: Date.now(), + } + + const msg = recordsUpdateMessage(request) + const response = await auth.executeRest(msg) + + const recordStatus = response.records?.[0] + const success = + recordStatus?.status === Records.RecordModifyResult.RS_SUCCESS || + !recordStatus?.status + + return { + recordUid, + success, + status: recordStatus?.status != null + ? Records.RecordModifyResult[recordStatus.status] + : ResultCode.OK, + } +} + +// Uses the v2 pre_delete → delete two-step flow. +export async function deleteRecord( + auth: Auth, + recordUid: string +): Promise { + const preDeleteRequest = { + objects: [ + { + object_uid: recordUid, + object_type: ObjectType.Record, + from_uid: '', + from_type: FolderType.UserFolder, + delete_resolution: DeleteResolution.Unlink, + } as RecordPreDeleteObject, + ], + } + + let preDeleteResponse: KeeperPreDeleteResponse + try { + const preDeleteCmd = recordPreDeleteCommand(preDeleteRequest) + preDeleteResponse = await auth.executeRestCommand(preDeleteCmd) + } catch (err) { + return { recordUid, success: false, message: extractErrorMessage(err) } + } + + const token = preDeleteResponse?.pre_delete_response?.pre_delete_token + if (!token) { + return { + recordUid, + success: false, + message: preDeleteResponse?.message || preDeleteResponse?.result_code || 'pre_delete failed: no token', + } + } + + try { + const deleteCmd = recordDeleteCommand({ pre_delete_token: token }) + await auth.executeRestCommand(deleteCmd) + } catch (err) { + return { recordUid, success: false, message: extractErrorMessage(err) } + } + + return { recordUid, success: true, message: ResultCode.Success } +} + +export type HistoryEntry = { + revision: number + version: number + userName: string + clientModifiedTime: number + data: Record | null +} + +export type RecordHistoryResult = { + recordUid: string + history: HistoryEntry[] +} + +type RecordHistoryRequest = { + record_uid: string + client_time: number +} + +type RecordHistoryResponseEntry = { + revision: number + version: number + user_name?: string + client_modified_time?: number + data?: string +} + +type RecordHistoryResponse = KeeperResponse & { + history?: RecordHistoryResponseEntry[] +} + +// Decrypts each revision using v2 AES-CBC or v3 AES-GCM based on version. +export async function getRecordHistory( + auth: Auth, + recordUid: string, + recordKey: Uint8Array +): Promise { + const cmd: RestCommand = { + baseRequest: { command: CommandName.GetRecordHistory } as BaseRequest, + request: { + record_uid: recordUid, + client_time: Date.now(), + }, + authorization: {}, + } + + let response: RecordHistoryResponse + try { + response = await auth.executeRestCommand(cmd) + } catch (err) { + throw KeeperSdkError.from(err) + } + + const rawHistory = response.history || [] + const history: HistoryEntry[] = [] + + for (const entry of rawHistory) { + let decryptedData: Record | null = null + + if (entry.data) { + try { + const dataBytes = platform.base64ToBytes( + normalizeBase64(entry.data) + ) + const version = entry.version || 0 + let decrypted: Uint8Array + + if (version <= RecordVersion.Legacy) { + decrypted = await platform.aesCbcDecrypt(dataBytes, recordKey, true) + } else { + decrypted = await platform.aesGcmDecrypt(dataBytes, recordKey) + } + + decryptedData = JSON.parse(platform.bytesToString(decrypted)) + } catch { + decryptedData = null + } + } + + history.push({ + revision: entry.revision, + version: entry.version, + userName: entry.user_name || '', + clientModifiedTime: entry.client_modified_time || 0, + data: decryptedData, + }) + } + + return { recordUid, history } +} + +function normalizeBase64(source: string): string { + return source.replace(/-/g, '+').replace(/_/g, '/') + + '=='.substring(0, (3 * source.length) % 4) +} + +export type MoveRecordInput = { + recordUid: string + dstFolderUid: string + srcFolderUid?: string + link?: boolean + canEdit?: boolean + canShare?: boolean +} + +export type MoveRecordResult = { + recordUid: string + success: boolean + message: string +} + +type FolderInfo = { + uid: string + folderType: FolderType + scopeUid: string +} + +function resolveFolder(uid: string, storage: InMemoryStorage): FolderInfo { + if (!uid) { + return { uid: '', folderType: FolderType.UserFolder, scopeUid: '' } + } + + const userFolders = storage.getAll(FolderType.UserFolder) + if (userFolders.find((f) => f.uid === uid)) { + return { uid, folderType: FolderType.UserFolder, scopeUid: '' } + } + + const sharedFolders = storage.getAll(FolderType.SharedFolder) + if (sharedFolders.find((f) => f.uid === uid)) { + return { uid, folderType: FolderType.SharedFolder, scopeUid: uid } + } + + const sfFolders = storage.getAll(FolderType.SharedFolderFolder) + const sfFolder = sfFolders.find((f) => f.uid === uid) + if (sfFolder) { + return { uid, folderType: FolderType.SharedFolderFolder, scopeUid: sfFolder.sharedFolderUid } + } + + return { uid, folderType: FolderType.UserFolder, scopeUid: '' } +} + +function findRecordSourceFolder( + recordUid: string, + storage: InMemoryStorage +): { folderUid: string; folderType: FolderType } { + const sfRecords = storage.getAll('shared_folder_record') + const sfr = sfRecords.find((r) => r.recordUid === recordUid) + if (sfr) { + return { folderUid: sfr.sharedFolderUid, folderType: FolderType.SharedFolder } + } + + return { folderUid: '', folderType: FolderType.UserFolder } +} + +// When moving across folder scopes, the record key is re-encrypted with the destination folder's key. +export async function moveRecord( + auth: Auth, + storage: InMemoryStorage, + input: MoveRecordInput +): Promise { + const { + recordUid, + dstFolderUid, + link = false, + canEdit, + canShare, + } = input + + const dst = resolveFolder(dstFolderUid, storage) + + let src: FolderInfo + if (input.srcFolderUid !== undefined) { + src = resolveFolder(input.srcFolderUid, storage) + } else { + const found = findRecordSourceFolder(recordUid, storage) + src = resolveFolder(found.folderUid, storage) + } + + const moveObj: MoveObject = { + uid: recordUid, + type: ObjectType.Record, + cascade: false, + from_type: src.folderType, + from_uid: src.uid || undefined, + can_edit: canEdit, + can_reshare: canShare, + } + + const transitionKeys: TransitionKeyObject[] = [] + + if (src.scopeUid !== dst.scopeUid) { + const recordKey = await storage.getKeyBytes(recordUid) + if (!recordKey) { + return { recordUid, success: false, message: 'Record key not found' } + } + + let dstKey: Uint8Array | undefined + if (dst.scopeUid) { + dstKey = await storage.getKeyBytes(dst.scopeUid) + } else { + dstKey = auth.dataKey + } + + if (!dstKey) { + return { recordUid, success: false, message: 'Destination folder key not found' } + } + + const record = storage.getAll(ObjectType.Record).find((r) => r.uid === recordUid) + const version = record?.version || RecordVersion.Typed + + let encryptedKey: Uint8Array + if (version >= RecordVersion.Typed) { + encryptedKey = await platform.aesGcmEncrypt(recordKey, dstKey) + } else { + encryptedKey = await platform.aesCbcEncrypt(recordKey, dstKey, true) + } + + transitionKeys.push({ uid: recordUid, key: webSafe64FromBytes(encryptedKey) }) + } + + const request: MoveRequest = { + to_type: dst.folderType, + to_uid: dst.uid || undefined, + link, + move: [moveObj], + transition_keys: transitionKeys, + } + + try { + const cmd = moveCommand(request) + await auth.executeRestCommand(cmd) + } catch (err) { + return { recordUid, success: false, message: extractErrorMessage(err) } + } + + return { recordUid, success: true, message: 'Record moved successfully' } +} diff --git a/KeeperSdk/src/records/RecordUtils.ts b/KeeperSdk/src/records/RecordUtils.ts new file mode 100644 index 0000000..c409d97 --- /dev/null +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -0,0 +1,177 @@ +import type { DRecord } from '@keeper-security/keeperapi' + +enum FieldType { + Login = 'login', + Password = 'password', + Url = 'url', + Note = 'note', + Text = 'text', +} + +export enum RecordVersion { + Legacy = 2, + Typed = 3, +} + +type RecordField = { + type: string + value: any[] + label?: string +} + +// Handles both legacy (v1/v2) and modern (v3+) record formats. +export function getRecordTitle(record: DRecord): string { + if (!record.data) return '(no data)' + if (typeof record.data === 'string') { + try { + const parsed = JSON.parse(record.data) + return parsed.title || parsed.name || '(untitled)' + } catch { + return '(parse error)' + } + } + return record.data.title || record.data.name || '(untitled)' +} + +export function getRecordType(record: DRecord): string { + if (record.version <= RecordVersion.Legacy) return 'legacy' + if (!record.data) return 'unknown' + return record.data.type || 'unknown' +} + +// Returns an empty array for legacy records. +export function getRecordFields(record: DRecord): RecordField[] { + if (!record.data) return [] + + if (record.version <= RecordVersion.Legacy) { + const fields: RecordField[] = [] + const d = record.data + if (d.secret1) fields.push({ type: FieldType.Login, value: [d.secret1] }) + if (d.secret2) fields.push({ type: FieldType.Password, value: [d.secret2] }) + if (d.link) fields.push({ type: FieldType.Url, value: [d.link] }) + if (d.notes) fields.push({ type: FieldType.Note, value: [d.notes] }) + return fields + } + + const fields: RecordField[] = [] + if (Array.isArray(record.data.fields)) { + for (const f of record.data.fields) { + fields.push({ + type: f.type || FieldType.Text, + value: Array.isArray(f.value) ? f.value : [f.value], + label: f.label, + }) + } + } + if (Array.isArray(record.data.custom)) { + for (const f of record.data.custom) { + fields.push({ + type: f.type || FieldType.Text, + value: Array.isArray(f.value) ? f.value : [f.value], + label: f.label, + }) + } + } + return fields +} + +export function getRecordPassword(record: DRecord): string | undefined { + if (record.version <= RecordVersion.Legacy) { + return record.data?.secret2 + } + const fields = getRecordFields(record) + const pwField = fields.find((f) => f.type === FieldType.Password) + if (pwField && pwField.value.length > 0) { + return String(pwField.value[0]) + } + return undefined +} + +export function getRecordLogin(record: DRecord): string | undefined { + if (record.version <= RecordVersion.Legacy) { + return record.data?.secret1 + } + const fields = getRecordFields(record) + const loginField = fields.find((f) => f.type === FieldType.Login) + if (loginField && loginField.value.length > 0) { + return String(loginField.value[0]) + } + return undefined +} + +export function getRecordUrl(record: DRecord): string | undefined { + if (record.version <= RecordVersion.Legacy) { + return record.data?.link + } + const fields = getRecordFields(record) + const urlField = fields.find((f) => f.type === FieldType.Url) + if (urlField && urlField.value.length > 0) { + const val = urlField.value[0] + return typeof val === 'string' ? val : val?.value || val?.url + } + return undefined +} + +export function searchRecords(records: DRecord[], criteria: string): DRecord[] { + if (!criteria.trim()) return records + + const searchWords = criteria.toLowerCase().split(/\s+/) + + return records.filter((record) => { + const words = collectRecordWords(record) + return searchWords.every((sw) => words.some((w) => w.includes(sw))) + }) +} + +function collectRecordWords(record: DRecord): string[] { + const words: string[] = [] + const title = getRecordTitle(record) + if (title) words.push(...title.toLowerCase().split(/\s+/)) + + for (const field of getRecordFields(record)) { + if (field.label) words.push(field.label.toLowerCase()) + for (const v of field.value) { + if (typeof v === 'string') { + words.push(...v.toLowerCase().split(/\s+/)) + } else if (v && typeof v === 'object') { + for (const val of Object.values(v)) { + if (typeof val === 'string') { + words.push(...val.toLowerCase().split(/\s+/)) + } + } + } + } + } + + words.push(record.uid) + return words +} + +export function formatRecord(record: DRecord, showDetails = false): string { + const title = getRecordTitle(record) + const type = getRecordType(record) + const lines: string[] = [] + + lines.push('-'.repeat(50)) + lines.push(`Title: ${title}`) + lines.push(`Record UID: ${record.uid}`) + lines.push(`Record Type: ${type}`) + + const login = getRecordLogin(record) + const url = getRecordUrl(record) + + if (login) lines.push(`Username: ${login}`) + if (url) lines.push(`URL: ${url}`) + + if (showDetails) { + const fields = getRecordFields(record) + for (const field of fields) { + if (field.type === FieldType.Login || field.type === FieldType.Url) continue + const label = field.label || field.type + const value = field.type === FieldType.Password ? '********' : field.value.join(', ') + lines.push(`${label}: ${value}`) + } + } + + return lines.join('\n') +} diff --git a/KeeperSdk/src/sharing/Sharing.ts b/KeeperSdk/src/sharing/Sharing.ts new file mode 100644 index 0000000..08ff21e --- /dev/null +++ b/KeeperSdk/src/sharing/Sharing.ts @@ -0,0 +1,441 @@ +import type { + DRecord, + DRecordMetadata, + DSharedFolder, + DSharedFolderUser, + DSharedFolderTeam, + DSharedFolderRecord, + DTeam, +} from '@keeper-security/keeperapi' +import { + Auth, + Records, + Authentication, + platform, + getPublicKeysMessage, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import { getRecordTitle } from '../records/RecordUtils' +import { extractErrorMessage, KeeperSdkError } from '../utils/errors' +import { InMemoryStorage } from '../storage/InMemoryStorage' + +enum ShareStatus { + Success = 'success', + PendingAccept = 'pending_accept', + MissingPublicKey = 'missing_public_key', + Error = 'error', + Unknown = 'unknown', +} + +enum PermissionLabel { + ManageUsersAndRecords = 'Can Manage Users & Records', + ManageRecords = 'Can Manage Records', + ManageUsers = 'Can Manage Users', + None = 'No Management Permissions', +} + +enum StorageType { + Record = 'record', + Metadata = 'metadata', + SharedFolder = 'shared_folder', + SharedFolderUser = 'shared_folder_user', + SharedFolderTeam = 'shared_folder_team', + SharedFolderRecord = 'shared_folder_record', + Team = 'team', +} + +const SHARE_UPDATE_PATH = 'vault/records_share_update' + +export type ShareReportEntry = { + recordUid: string + recordTitle: string + recordOwner: string + sharedWithCount: number + sharedWith: string[] +} + +export type SharedFolderReportEntry = { + folderUid: string + folderName: string + sharedTo: string + permissions: string +} + +export type ShareSummaryEntry = { + sharedTo: string + recordCount: number + sharedFolderCount: number +} + +export type ShareRecordInput = { + recordUid: string + email: string + canEdit?: boolean + canShare?: boolean +} + +export type ShareRecordResult = { + recordUid: string + email: string + success: boolean + status: string + message: string +} + +export type RemoveShareInput = { + recordUid: string + email: string +} + +export type RemoveShareResult = { + recordUid: string + email: string + success: boolean + status: string + message: string +} + +export class ShareReportGenerator { + private records: DRecord[] + private metadata: DRecordMetadata[] + private sharedFolders: DSharedFolder[] + private sharedFolderUsers: DSharedFolderUser[] + private sharedFolderTeams: DSharedFolderTeam[] + private sharedFolderRecords: DSharedFolderRecord[] + private teams: DTeam[] + private currentUser: string + + constructor(storage: InMemoryStorage, currentUser: string) { + this.records = storage.getAll(StorageType.Record) + this.metadata = storage.getAll(StorageType.Metadata) + this.sharedFolders = storage.getAll(StorageType.SharedFolder) + this.sharedFolderUsers = storage.getAll(StorageType.SharedFolderUser) + this.sharedFolderTeams = storage.getAll(StorageType.SharedFolderTeam) + this.sharedFolderRecords = storage.getAll(StorageType.SharedFolderRecord) + this.teams = storage.getAll(StorageType.Team) + this.currentUser = currentUser + } + + public generateRecordsReport(): ShareReportEntry[] { + const report: ShareReportEntry[] = [] + const metaMap = new Map(this.metadata.map((m) => [m.uid, m])) + + const sfRecordsByRecord = new Map() + for (const sfr of this.sharedFolderRecords) { + const list = sfRecordsByRecord.get(sfr.recordUid) || [] + list.push(sfr) + sfRecordsByRecord.set(sfr.recordUid, list) + } + + const sfUserMap = new Map() + for (const sfu of this.sharedFolderUsers) { + const list = sfUserMap.get(sfu.sharedFolderUid) || [] + list.push(sfu) + sfUserMap.set(sfu.sharedFolderUid, list) + } + + const sfTeamMap = new Map() + for (const sft of this.sharedFolderTeams) { + const list = sfTeamMap.get(sft.sharedFolderUid) || [] + list.push(sft) + sfTeamMap.set(sft.sharedFolderUid, list) + } + + for (const record of this.records) { + if (!record.shared) continue + + const meta = metaMap.get(record.uid) + const owner = meta?.ownerUsername || (meta?.owner ? this.currentUser : '') + const sharedWith: string[] = [] + + const sfRecords = sfRecordsByRecord.get(record.uid) || [] + const seenFolders = new Set() + + for (const sfr of sfRecords) { + if (seenFolders.has(sfr.sharedFolderUid)) continue + seenFolders.add(sfr.sharedFolderUid) + + const users = sfUserMap.get(sfr.sharedFolderUid) || [] + for (const u of users) { + const name = u.accountUsername || u.accountUid || '' + if (name && name !== this.currentUser && !sharedWith.includes(name)) { + sharedWith.push(name) + } + } + + const teams = sfTeamMap.get(sfr.sharedFolderUid) || [] + for (const t of teams) { + const teamLabel = `(Team) ${t.name}` + if (!sharedWith.includes(teamLabel)) { + sharedWith.push(teamLabel) + } + } + } + + if (sharedWith.length > 0 || sfRecords.length > 0) { + report.push({ + recordUid: record.uid, + recordTitle: getRecordTitle(record), + recordOwner: owner, + sharedWithCount: sharedWith.length, + sharedWith, + }) + } + } + + return report + } + + public generateSharedFoldersReport(): SharedFolderReportEntry[] { + const report: SharedFolderReportEntry[] = [] + + for (const sf of this.sharedFolders) { + const users = this.sharedFolderUsers.filter( + (u) => u.sharedFolderUid === sf.uid + ) + const teams = this.sharedFolderTeams.filter( + (t) => t.sharedFolderUid === sf.uid + ) + + const folderName = sf.name || sf.data?.name || sf.uid + + for (const u of users) { + report.push({ + folderUid: sf.uid, + folderName, + sharedTo: u.accountUsername || u.accountUid || '', + permissions: formatFolderPermissions(u.manageUsers, u.manageRecords), + }) + } + + for (const t of teams) { + report.push({ + folderUid: sf.uid, + folderName, + sharedTo: `(Team) ${t.name}`, + permissions: formatFolderPermissions(t.manageUsers, t.manageRecords), + }) + } + } + + return report + } + + public generateSummaryReport(): ShareSummaryEntry[] { + const recordShares = new Map>() + const folderShares = new Map>() + + const sfRecordMap = new Map() + for (const sfr of this.sharedFolderRecords) { + const list = sfRecordMap.get(sfr.sharedFolderUid) || [] + list.push(sfr.recordUid) + sfRecordMap.set(sfr.sharedFolderUid, list) + } + + for (const sfu of this.sharedFolderUsers) { + const name = sfu.accountUsername || sfu.accountUid || '' + if (!name || name === this.currentUser) continue + + if (!folderShares.has(name)) folderShares.set(name, new Set()) + folderShares.get(name)!.add(sfu.sharedFolderUid) + + if (!recordShares.has(name)) recordShares.set(name, new Set()) + const recs = sfRecordMap.get(sfu.sharedFolderUid) || [] + for (const r of recs) recordShares.get(name)!.add(r) + } + + for (const sft of this.sharedFolderTeams) { + const name = `(Team) ${sft.name}` + + if (!folderShares.has(name)) folderShares.set(name, new Set()) + folderShares.get(name)!.add(sft.sharedFolderUid) + + if (!recordShares.has(name)) recordShares.set(name, new Set()) + const recs = sfRecordMap.get(sft.sharedFolderUid) || [] + for (const r of recs) recordShares.get(name)!.add(r) + } + + const allTargets = new Set([...recordShares.keys(), ...folderShares.keys()]) + const entries: ShareSummaryEntry[] = [] + + for (const target of allTargets) { + entries.push({ + sharedTo: target, + recordCount: recordShares.get(target)?.size || 0, + sharedFolderCount: folderShares.get(target)?.size || 0, + }) + } + + entries.sort((a, b) => (b.recordCount + b.sharedFolderCount) - (a.recordCount + a.sharedFolderCount)) + return entries + } +} + +function formatFolderPermissions(manageUsers: boolean, manageRecords: boolean): string { + if (manageUsers && manageRecords) return PermissionLabel.ManageUsersAndRecords + if (manageRecords) return PermissionLabel.ManageRecords + if (manageUsers) return PermissionLabel.ManageUsers + return PermissionLabel.None +} + +function recordsShareUpdateMessage(data: Records.IRecordShareUpdateRequest) { + return { + path: SHARE_UPDATE_PATH, + toBytes(): Uint8Array { + return Records.RecordShareUpdateRequest.encode( + Records.RecordShareUpdateRequest.create(data) + ).finish() + }, + fromBytes(resp: Uint8Array): Records.IRecordShareUpdateResponse { + return Records.RecordShareUpdateResponse.decode(resp) + }, + } +} + +type UserKeys = { + username: string + rsaPublicKey: Uint8Array | null + eccPublicKey: Uint8Array | null + errorCode: string | null +} + +async function loadUserPublicKey(auth: Auth, email: string): Promise { + const msg = getPublicKeysMessage({ usernames: [email] }) + let response: Authentication.IGetPublicKeysResponse + + try { + response = await auth.executeRest(msg) + } catch (err) { + throw new KeeperSdkError(`Failed to fetch public key for ${email}: ${extractErrorMessage(err)}`) + } + + const keyResponses = response.keyResponses || [] + if (keyResponses.length === 0) { + throw new KeeperSdkError(`No public key returned for ${email}`, 'missing_public_key') + } + + const entry = keyResponses[0] + if (entry.errorCode) { + throw new KeeperSdkError( + `Public key lookup failed for ${email}: ${entry.errorCode} - ${entry.message || ''}`, + entry.errorCode + ) + } + + return { + username: entry.username || email, + 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, + } +} + +function uidToBytes(uid: string): Uint8Array { + return platform.base64ToBytes( + uid.replace(/-/g, '+').replace(/_/g, '/') + + '=='.substring(0, (3 * uid.length) % 4) + ) +} + +// Encrypts the record key with the recipient's public key (ECC preferred, RSA fallback). +export async function shareRecord( + auth: Auth, + recordKey: Uint8Array, + input: ShareRecordInput +): Promise { + const { recordUid, email, canEdit = false, canShare = false } = input + + const userKeys = await loadUserPublicKey(auth, email) + + let encryptedRecordKey: Uint8Array + let useEccKey = false + + if (userKeys.eccPublicKey) { + encryptedRecordKey = await platform.publicEncryptEC(recordKey, userKeys.eccPublicKey) + useEccKey = true + } else if (userKeys.rsaPublicKey) { + const rsaKeyBase64 = platform.bytesToBase64(userKeys.rsaPublicKey) + encryptedRecordKey = platform.publicEncrypt(recordKey, rsaKeyBase64) + useEccKey = false + } else { + return { + recordUid, + email, + success: false, + status: ShareStatus.MissingPublicKey, + message: `No usable public key available for ${email}`, + } + } + + const sharedRecord: Records.ISharedRecord = { + toUsername: email, + recordUid: uidToBytes(recordUid), + recordKey: encryptedRecordKey, + editable: canEdit, + shareable: canShare, + useEccKey, + } + + const msg = recordsShareUpdateMessage({ addSharedRecord: [sharedRecord] }) + + let response: Records.IRecordShareUpdateResponse + try { + response = await auth.executeRest(msg) + } catch (err) { + return { recordUid, email, success: false, status: ShareStatus.Error, message: extractErrorMessage(err) } + } + + const addStatuses = response.addSharedRecordStatus || [] + if (addStatuses.length > 0) { + const st = addStatuses[0] + const isSuccess = st.status === ShareStatus.Success || st.status === ShareStatus.PendingAccept + return { + recordUid, + email: st.username || email, + success: isSuccess, + status: st.status || ShareStatus.Unknown, + message: st.message || st.status || '', + } + } + + return { recordUid, email, success: true, status: ShareStatus.Success, message: 'Record shared successfully' } +} + +export async function removeRecordShare( + auth: Auth, + input: RemoveShareInput +): Promise { + const { recordUid, email } = input + + const msg = recordsShareUpdateMessage({ + removeSharedRecord: [{ + toUsername: email, + recordUid: uidToBytes(recordUid), + }], + }) + + let response: Records.IRecordShareUpdateResponse + try { + response = await auth.executeRest(msg) + } catch (err) { + return { recordUid, email, success: false, status: ShareStatus.Error, message: extractErrorMessage(err) } + } + + const removeStatuses = response.removeSharedRecordStatus || [] + if (removeStatuses.length > 0) { + const st = removeStatuses[0] + return { + recordUid, + email: st.username || email, + success: st.status === ShareStatus.Success, + status: st.status || ShareStatus.Unknown, + message: st.message || st.status || '', + } + } + + return { recordUid, email, success: true, status: ShareStatus.Success, message: 'Share removed successfully' } +} diff --git a/KeeperSdk/src/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts new file mode 100644 index 0000000..e480af5 --- /dev/null +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -0,0 +1,132 @@ +import type { + VaultStorage, + VaultStorageData, + VaultStorageKind, + VaultStorageResult, + Dependency, + Dependencies, + RemovedDependencies, + DRecord, +} from '@keeper-security/keeperapi' + +export class InMemoryStorage implements VaultStorage { + private keys = new Map() + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- KeyStorage.saveObject is unconstrained + private objects = new Map() + private store = new Map>() + private deps = new Map() + + public async getKeyBytes(keyId: string): Promise { + return this.keys.get(keyId) + } + + public async saveKeyBytes(keyId: string, key: Uint8Array): Promise { + this.keys.set(keyId, key) + } + + public async getObject(key: string): Promise { + return this.objects.get(key) as T | undefined + } + + public async saveObject(key: string, value: T): Promise { + this.objects.set(key, value) + } + + public async put(item: VaultStorageData): Promise { + const kind = item.kind + if (!this.store.has(kind)) { + this.store.set(kind, new Map()) + } + const uid = this.extractUid(item) + this.store.get(kind)!.set(uid, item) + } + + public async get(kind: T, uid?: string): Promise> { + const kindMap = this.store.get(kind) + if (!kindMap) return undefined as VaultStorageResult + + if (uid) { + return kindMap.get(uid) as VaultStorageResult + } + const first = kindMap.values().next() + return (first.done ? undefined : first.value) as VaultStorageResult + } + + public async delete(kind: VaultStorageKind, uid: string | Uint8Array): Promise { + const uidStr = typeof uid === 'string' ? uid : '' + this.store.get(kind)?.delete(uidStr) + } + + public async clear(): Promise { + this.store.clear() + this.keys.clear() + this.objects.clear() + this.deps.clear() + } + + public async getDependencies(uid: string): Promise { + return this.deps.get(uid) + } + + public async addDependencies(dependencies: Dependencies): Promise { + for (const [parentUid, children] of Object.entries(dependencies)) { + if (!this.deps.has(parentUid)) { + this.deps.set(parentUid, []) + } + const existing = this.deps.get(parentUid)! + for (const child of children) { + existing.push(child) + } + } + } + + public async removeDependencies(dependencies: RemovedDependencies): Promise { + for (const [parentUid, children] of Object.entries(dependencies)) { + if (children === '*') { + this.deps.delete(parentUid) + } else { + const existing = this.deps.get(parentUid) + if (existing) { + const removeSet = children as Set + this.deps.set( + parentUid, + existing.filter((d) => !removeSet.has(d.uid)) + ) + } + } + } + } + + public getAll(kind: VaultStorageKind): T[] { + const kindMap = this.store.get(kind) + if (!kindMap) return [] + return Array.from(kindMap.values()) as T[] + } + + public getRecords(): DRecord[] { + return this.getAll('record') + } + + private extractUid(item: VaultStorageData): string { + const record = item as VaultStorageData & { + uid?: string + token?: string + sharedFolderUid?: string + recordUid?: string + accountUid?: string + teamUid?: string + } + if (record.uid) return record.uid + if (record.token) return record.token + if (record.sharedFolderUid && record.recordUid) { + return `${record.sharedFolderUid}:${record.recordUid}` + } + if (record.sharedFolderUid && record.accountUid) { + return `${record.sharedFolderUid}:${record.accountUid}` + } + if (record.sharedFolderUid && record.teamUid) { + return `${record.sharedFolderUid}:${record.teamUid}` + } + return '_singleton_' + } +} diff --git a/KeeperSdk/src/utils/Logger.ts b/KeeperSdk/src/utils/Logger.ts new file mode 100644 index 0000000..11d2ff6 --- /dev/null +++ b/KeeperSdk/src/utils/Logger.ts @@ -0,0 +1,41 @@ +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4, +} + +export class Logger { + private level: LogLevel + + constructor(level: LogLevel = LogLevel.INFO) { + this.level = level + } + + public setLevel(level: LogLevel): void { + this.level = level + } + + public getLevel(): LogLevel { + return this.level + } + + public debug(...args: any[]): void { + if (this.level <= LogLevel.DEBUG) console.debug(...args) + } + + public info(...args: any[]): void { + if (this.level <= LogLevel.INFO) console.log(...args) + } + + public warn(...args: any[]): void { + if (this.level <= LogLevel.WARN) console.warn(...args) + } + + public error(...args: any[]): void { + if (this.level <= LogLevel.ERROR) console.error(...args) + } +} + +export const logger = new Logger() diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts new file mode 100644 index 0000000..d32239f --- /dev/null +++ b/KeeperSdk/src/utils/constants.ts @@ -0,0 +1,7 @@ +export const SdkDefaults = { + CLIENT_VERSION: 'c17.0.0', + DEVICE_NAME: 'JavaScript Keeper SDK', + CONFIG_DIR: '.keeper', + CONFIG_FILE: 'sdk-config.json', + LOG_FORMAT: '!', +} as const diff --git a/KeeperSdk/src/utils/errors.ts b/KeeperSdk/src/utils/errors.ts new file mode 100644 index 0000000..ee61fe8 --- /dev/null +++ b/KeeperSdk/src/utils/errors.ts @@ -0,0 +1,58 @@ +import type { KeeperError } from '@keeper-security/keeperapi' + +export function isKeeperError(err: any): err is KeeperError { + return ( + err != null && + typeof err === 'object' && + !(err instanceof Error) && + ('result_code' in err || 'error' in err || 'response_code' in err) + ) +} + +export function extractResultCode(err: any): string | undefined { + if (isKeeperError(err)) { + return err.result_code || err.error + } + if (err instanceof Error) { + try { + const parsed = JSON.parse(err.message) + return parsed.result_code || parsed.error + } catch {} + } + if (typeof err === 'string') return err + return err?.result_code || err?.error +} + +export function extractErrorMessage(err: any): string { + if (isKeeperError(err)) { + return err.message || err.result_code || err.error || 'Unknown Keeper error' + } + if (err instanceof Error) return err.message + if (typeof err === 'string') return err + return err?.message || err?.result_code || String(err) +} + +export class KeeperSdkError extends Error { + readonly resultCode?: string + readonly keeperError?: KeeperError + + constructor(message: string, resultCode?: string, keeperError?: KeeperError) { + super(message) + this.name = 'KeeperSdkError' + this.resultCode = resultCode + this.keeperError = keeperError + } + + static from(err: any): KeeperSdkError { + if (err instanceof KeeperSdkError) return err + if (isKeeperError(err)) { + return new KeeperSdkError( + err.message || err.result_code || err.error || 'Unknown Keeper error', + err.result_code || err.error, + err + ) + } + if (err instanceof Error) return new KeeperSdkError(err.message) + return new KeeperSdkError(String(err)) + } +} diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts new file mode 100644 index 0000000..8d172cb --- /dev/null +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -0,0 +1,409 @@ +import { + Auth, + KeeperEnvironment, + syncDown, + DRecord, + DRecordMetadata, + DSharedFolder, + DTeam, + DUserFolder, + Authentication, +} from '@keeper-security/keeperapi' +import type { SyncResult, SyncLogFormat, VaultStorage } from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { SessionManager } from '../auth/SessionManager' +import { ConsoleAuthUI } from '../auth/ConsoleAuthUI' +import { searchRecords, formatRecord, getRecordTitle, getRecordType } from '../records/RecordUtils' +import { + addRecord as addRecordOp, + updateRecord as updateRecordOp, + deleteRecord as deleteRecordOp, + getRecordHistory as getRecordHistoryOp, + moveRecord as moveRecordOp, +} from '../records/RecordOperations' +import type { + NewRecordInput, + TypedRecordData, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from '../records/RecordOperations' +import { + ShareReportGenerator, + shareRecord as shareRecordOp, + removeRecordShare as removeRecordShareOp, +} from '../sharing/Sharing' +import type { + ShareReportEntry, + SharedFolderReportEntry, + ShareSummaryEntry, + ShareRecordInput, + ShareRecordResult, + RemoveShareInput, + RemoveShareResult, +} from '../sharing/Sharing' +import { logger, LogLevel } from '../utils/Logger' +import type { Logger } from '../utils/Logger' +import { KeeperSdkError } from '../utils/errors' +import { SdkDefaults } from '../utils/constants' + +enum VaultStatus { + RecordNotFound = 'RECORD_NOT_FOUND', + RecordKeyNotFound = 'RECORD_KEY_NOT_FOUND', +} + +export type KeeperVaultConfig = { + host?: string + clientVersion?: string + configDir?: string + useConsoleAuth?: boolean + logFormat?: SyncLogFormat + logLevel?: LogLevel +} + +export type VaultSummary = { + recordCount: number + sharedFolderCount: number + teamCount: number + folderCount: number +} + +export class KeeperVault { + private auth: Auth | null = null + private readonly storage: InMemoryStorage + private readonly sessionManager: SessionManager + private readonly authUI: ConsoleAuthUI + private readonly config: Required + private readonly log: Logger + private synced = false + + constructor(config?: KeeperVaultConfig) { + this.config = { + host: config?.host || KeeperEnvironment.Prod, + clientVersion: config?.clientVersion || SdkDefaults.CLIENT_VERSION, + configDir: config?.configDir || '', + useConsoleAuth: config?.useConsoleAuth !== false, + logFormat: config?.logFormat || SdkDefaults.LOG_FORMAT, + logLevel: config?.logLevel ?? LogLevel.INFO, + } + + if (config?.logLevel !== undefined) { + logger.setLevel(config.logLevel) + } + + this.log = logger + this.storage = new InMemoryStorage() + this.sessionManager = new SessionManager(this.config.configDir || undefined) + this.authUI = new ConsoleAuthUI() + } + + private createAuth(options?: { useSessionResumption?: boolean }): Auth { + const host = this.config.host + const deviceConfig = this.sessionManager.getDeviceConfig(host) + + if (!deviceConfig.deviceName) { + deviceConfig.deviceName = SdkDefaults.DEVICE_NAME + } + + return new Auth({ + host, + clientVersion: this.config.clientVersion, + deviceConfig, + authUI3: this.config.useConsoleAuth ? this.authUI : undefined, + sessionStorage: this.sessionManager, + onDeviceConfig: this.sessionManager.createOnDeviceConfig(host), + useSessionResumption: options?.useSessionResumption, + }) + } + + private getAuthOrThrow(): Auth { + if (!this.auth || !this.auth.sessionToken) { + throw new KeeperSdkError('Not logged in. Call login() first.', 'not_logged_in') + } + return this.auth + } + + // Handles device registration, 2FA, and device approval via console prompts when useConsoleAuth is enabled. + public async login(username: string, password: string): Promise { + this.auth = this.createAuth() + this.sessionManager.setLastUsername(username) + + await this.auth.loginV3({ + username, + password, + loginType: Authentication.LoginType.NORMAL, + loginMethod: Authentication.LoginMethod.EXISTING_ACCOUNT, + }) + + this.synced = false + this.log.info(`Logged in as ${username}`) + } + + // Device must already be registered and approved for this host via a prior normal login. + public async loginWithSessionToken(username: string, sessionToken: string): Promise { + const deviceConfig = this.sessionManager.getDeviceConfig(this.config.host) + + if (!deviceConfig.deviceToken || !deviceConfig.privateKey) { + throw new KeeperSdkError( + 'Device is not registered for this host. Perform a normal login first to register the device before using session token login.', + 'device_not_registered' + ) + } + + this.auth = this.createAuth() + this.sessionManager.setLastUsername(username) + + await this.auth.loginV3({ + username, + givenSessionToken: sessionToken, + loginType: Authentication.LoginType.NORMAL, + loginMethod: Authentication.LoginMethod.EXISTING_ACCOUNT, + }) + + if (!this.auth.sessionToken) { + throw new KeeperSdkError( + 'Session token login failed — token may be expired or invalid.', + 'session_token_expired' + ) + } + + this.synced = false + this.log.info(`Logged in as ${username} (via session token)`) + } + + public getSessionToken(): string | undefined { + return this.auth?.sessionToken || undefined + } + + public async resumeSession(): Promise { + this.auth = this.createAuth({ useSessionResumption: true }) + const username = this.sessionManager.lastUsername || '' + + await this.auth.loginV3({ + username, + loginType: Authentication.LoginType.NORMAL, + resumeSessionOnly: true, + }) + + this.synced = false + this.log.info(`Session resumed for ${username}`) + } + + public async sync(): Promise { + const auth = this.getAuthOrThrow() + + const result = await syncDown({ + auth, + storage: this.storage, + logFormat: this.config.logFormat, + }) + + this.synced = true + return result + } + + public getRecords(): DRecord[] { + return this.storage.getRecords() + } + + public getRecordByUid(uid: string): DRecord | undefined { + return this.getRecords().find((r) => r.uid === uid) + } + + // Tries exact UID match first, then case-insensitive title match. + public findRecord(uidOrTitle: string): DRecord | undefined { + const records = this.getRecords() + const byUid = records.find((r) => r.uid === uidOrTitle) + if (byUid) return byUid + + const needle = uidOrTitle.toLowerCase() + return records.find((r) => getRecordTitle(r).toLowerCase() === needle) + } + + public findRecords(criteria: string): DRecord[] { + return searchRecords(this.getRecords(), criteria) + } + + public getRecordsByVersion(version: number): DRecord[] { + return this.getRecords().filter((r) => r.version === version) + } + + public getRecordsByType(recordType: string): DRecord[] { + return this.getRecords().filter((r) => getRecordType(r) === recordType) + } + + public getRecordMetadata(): DRecordMetadata[] { + return this.storage.getAll('metadata') + } + + public getRecordMetadataByUid(uid: string): DRecordMetadata | undefined { + return this.getRecordMetadata().find((m) => m.uid === uid) + } + + public getSharedFolders(): DSharedFolder[] { + return this.storage.getAll('shared_folder') + } + + public getTeams(): DTeam[] { + return this.storage.getAll('team') + } + + public getUserFolders(): DUserFolder[] { + return this.storage.getAll('user_folder') + } + + public getSummary(): VaultSummary { + return { + recordCount: this.getRecords().length, + sharedFolderCount: this.getSharedFolders().length, + teamCount: this.getTeams().length, + folderCount: this.getUserFolders().length, + } + } + + public printRecords(showDetails = false): void { + const records = this.getRecords() + if (records.length === 0) { + this.log.info('No records found in vault.') + return + } + this.log.info(`\n=== Vault Records (${records.length}) ===\n`) + for (const record of records) { + this.log.info(formatRecord(record, showDetails)) + } + } + + public async addRecord(input: NewRecordInput): Promise { + const auth = this.getAuthOrThrow() + const result = await addRecordOp(auth, input) + if (result.success) await this.sync() + return result + } + + public async updateRecord(recordUid: string, data: TypedRecordData): Promise { + const auth = this.getAuthOrThrow() + + const record = this.getRecordByUid(recordUid) + if (!record) { + return { recordUid, success: false, status: VaultStatus.RecordNotFound } + } + + const keyBytes = await this.storage.getKeyBytes(recordUid) + if (!keyBytes) { + return { recordUid, success: false, status: VaultStatus.RecordKeyNotFound } + } + + const result = await updateRecordOp(auth, recordUid, data, record.revision, keyBytes) + if (result.success) await this.sync() + return result + } + + public async deleteRecord(recordUid: string): Promise { + const auth = this.getAuthOrThrow() + const result = await deleteRecordOp(auth, recordUid) + if (result.success) await this.sync() + return result + } + + public async moveRecord(input: MoveRecordInput): Promise { + const auth = this.getAuthOrThrow() + const result = await moveRecordOp(auth, this.storage, input) + if (result.success) await this.sync() + return result + } + + public createShareReportGenerator(): ShareReportGenerator { + const auth = this.getAuthOrThrow() + return new ShareReportGenerator(this.storage, auth.username || '') + } + + public getSharedRecordsReport(): ShareReportEntry[] { + return this.createShareReportGenerator().generateRecordsReport() + } + + public getSharedFoldersReport(): SharedFolderReportEntry[] { + return this.createShareReportGenerator().generateSharedFoldersReport() + } + + public getShareSummaryReport(): ShareSummaryEntry[] { + return this.createShareReportGenerator().generateSummaryReport() + } + + public async shareRecord(input: ShareRecordInput): Promise { + const auth = this.getAuthOrThrow() + + const record = this.getRecordByUid(input.recordUid) + || this.findRecord(input.recordUid) + if (!record) { + return { + recordUid: input.recordUid, + email: input.email, + success: false, + status: VaultStatus.RecordNotFound, + message: `Record "${input.recordUid}" not found`, + } + } + + const keyBytes = await this.storage.getKeyBytes(record.uid) + if (!keyBytes) { + return { + recordUid: record.uid, + email: input.email, + success: false, + status: VaultStatus.RecordKeyNotFound, + message: 'Record key not available', + } + } + + const result = await shareRecordOp(auth, keyBytes, { ...input, recordUid: record.uid }) + if (result.success) await this.sync() + return result + } + + public async removeRecordShare(input: RemoveShareInput): Promise { + const auth = this.getAuthOrThrow() + const result = await removeRecordShareOp(auth, input) + if (result.success) await this.sync() + return result + } + + public async getRecordHistory(recordUid: string): Promise { + const auth = this.getAuthOrThrow() + + const keyBytes = await this.storage.getKeyBytes(recordUid) + if (!keyBytes) { + return { recordUid, history: [] } + } + + return getRecordHistoryOp(auth, recordUid, keyBytes) + } + + public getStorage(): VaultStorage { + return this.storage + } + + public getAuth(): Auth { + return this.getAuthOrThrow() + } + + public async logout(): Promise { + if (this.auth) { + try { this.auth.disconnect() } catch {} + try { await this.auth.logout() } catch {} + this.auth = null + } + this.synced = false + this.log.info('Logged out.') + } + + public get isLoggedIn(): boolean { + return this.auth !== null && !!this.auth.sessionToken + } + + public get isSynced(): boolean { + return this.synced + } +} diff --git a/KeeperSdk/tsconfig.json b/KeeperSdk/tsconfig.json new file mode 100644 index 0000000..3e2083d --- /dev/null +++ b/KeeperSdk/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2018", + "sourceMap": true, + "strict": false, + "skipLibCheck": true, + "esModuleInterop": true, + "declaration": true, + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json new file mode 100644 index 0000000..aca45ee --- /dev/null +++ b/examples/sdk_example/package.json @@ -0,0 +1,29 @@ +{ + "name": "sdk-example", + "version": "1.0.0", + "description": "Example usage of the KeeperSdk wrapper — mirrors Python SDK examples", + "scripts": { + "auth:login": "ts-node src/auth/login.ts", + "auth:session-token": "ts-node src/auth/session_token_login.ts", + "records:list": "ts-node src/records/list_records.ts", + "records:get": "ts-node src/records/get_record.ts", + "records:add": "ts-node src/records/add_record.ts", + "records:update": "ts-node src/records/update_record.ts", + "records:delete": "ts-node src/records/delete_record.ts", + "records:history": "ts-node src/records/record_history.ts", + "records:find-password": "ts-node src/records/find_password.ts", + "records:move": "ts-node src/records/move_record.ts", + "sharing:share-record": "ts-node src/sharing/share_record.ts", + "sharing:report": "ts-node src/sharing/share_report.ts", + "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", + "types": "tsc --watch", + "types:ci": "tsc" + }, + "dependencies": { + "@keeper-security/keeperapi": "17.1.0", + "@types/node": "^20.9.1", + "ts-node": "^10.7.0", + "tsconfig-paths": "^4.2.0", + "typescript": "^4.6.3" + } +} diff --git a/examples/sdk_example/src/auth/login.ts b/examples/sdk_example/src/auth/login.ts new file mode 100644 index 0000000..1e0a33a --- /dev/null +++ b/examples/sdk_example/src/auth/login.ts @@ -0,0 +1,30 @@ +import { login, cleanup, logger, extractErrorMessage } from 'keeper-sdk' + +async function displaySessionInfo() { + const vault = await login() + + try { + const auth = vault.getAuth() + const summary = vault.getSummary() + + logger.info('--- Session Info ---') + logger.info(` Username: ${auth.username}`) + logger.info(` Session Token: ${auth.sessionToken || '(none)'}`) + logger.info(` Data Key: ${auth.dataKey ? '(loaded)' : '(not loaded)'}`) + logger.info(` Records: ${summary.recordCount}`) + logger.info(` Shared Folders: ${summary.sharedFolderCount}`) + logger.info(` Teams: ${summary.teamCount}`) + logger.info(` Folders: ${summary.folderCount}`) + + logger.info('\nLogin successful. Session is active.') + } finally { + await cleanup(vault) + } +} + +displaySessionInfo() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/auth/session_token_login.ts b/examples/sdk_example/src/auth/session_token_login.ts new file mode 100644 index 0000000..3b73b5f --- /dev/null +++ b/examples/sdk_example/src/auth/session_token_login.ts @@ -0,0 +1,61 @@ +import { KeeperVault, prompt, suppressLogs, logger, KeeperSdkError, extractErrorMessage, SdkDefaults } from 'keeper-sdk' + +async function main() { + const username = await prompt('Username (email): ') + if (!username) throw new KeeperSdkError('Username is required.', 'missing_username') + + const host = await prompt('Host [keepersecurity.com]: ') + + const sessionToken = await prompt('Session Token: ') + if (!sessionToken) throw new KeeperSdkError('Session token is required.', 'missing_session_token') + + const resolvedHost = host || 'keepersecurity.com' + + logger.info(`\nLogging in as ${username} on ${resolvedHost} using session token...`) + logger.info(` Session Token: ${sessionToken}\n`) + + const vault = new KeeperVault({ host: resolvedHost, clientVersion: SdkDefaults.CLIENT_VERSION }) + + let restore = suppressLogs() + try { + await vault.loginWithSessionToken(username, sessionToken) + } finally { + restore() + } + + const auth = vault.getAuth() + + logger.info('--- Session Info ---') + logger.info(` Username: ${auth.username}`) + logger.info(` Session Token: ${auth.sessionToken ? '(active)' : '(none)'}`) + logger.info(` Data Key: ${auth.dataKey ? '(loaded)' : '(not loaded)'}`) + + logger.info('\nSyncing vault...') + restore = suppressLogs() + try { + await vault.sync() + } finally { + restore() + } + + const summary = vault.getSummary() + logger.info(` Records: ${summary.recordCount}`) + logger.info(` Shared Folders: ${summary.sharedFolderCount}`) + logger.info(` Teams: ${summary.teamCount}`) + logger.info(` Folders: ${summary.folderCount}`) + + logger.info('\nLogin successful. Session is active.') + + restore = suppressLogs() + try { + vault.getAuth().disconnect() + } catch { /* ignore */ } + restore() +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/records/add_record.ts b/examples/sdk_example/src/records/add_record.ts new file mode 100644 index 0000000..4c0b401 --- /dev/null +++ b/examples/sdk_example/src/records/add_record.ts @@ -0,0 +1,54 @@ +import { login, cleanup, suppressLogs, formatRecord, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' + +async function addRecord() { + const vault = await login() + + try { + logger.info('Adding new record to vault...') + logger.info('-'.repeat(50)) + + const result = await vault.addRecord({ + version: 3, + data: { + type: 'login', + title: 'Example SDK Record', + fields: [ + { type: 'login', value: ['userSDK@example.com'] }, + { type: 'password', value: ['SecureSDKPassword123!'] }, + { type: 'url', value: ['https://SDKexample.com'] }, + ], + notes: 'This is an example record created using the Keeper SDK', + }, + }) + + if (result.success) { + logger.info('Successfully added record!') + logger.info(`Record UID: ${result.recordUid}`) + + logger.info('\nVerifying record was added...') + const restore = suppressLogs() + try { + await vault.sync() + } finally { + restore() + } + + const record = vault.getRecordByUid(result.recordUid) + if (record) { + logger.info(`Verified: "${getRecordTitle(record)}" found in vault.`) + logger.info(formatRecord(record, true)) + } + } else { + logger.error(`Error adding record: ${result.status}`) + } + } finally { + await cleanup(vault) + } +} + +addRecord() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/records/delete_record.ts b/examples/sdk_example/src/records/delete_record.ts new file mode 100644 index 0000000..af5e608 --- /dev/null +++ b/examples/sdk_example/src/records/delete_record.ts @@ -0,0 +1,45 @@ +import { login, cleanup, prompt, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' + +async function deleteRecord() { + const vault = await login() + + try { + const uid = await prompt('Enter Record UID or title to delete: ') + if (!uid) { + logger.info('No input provided.') + return + } + + const record = vault.findRecord(uid) + if (!record) { + logger.info(`Record "${uid}" not found.`) + return + } + + const title = getRecordTitle(record) + const confirm = await prompt(`\nAre you sure you want to delete "${title}" (${record.uid})? [y/N]: `) + + if (confirm.toLowerCase() !== 'y') { + logger.info('Delete cancelled.') + return + } + + logger.info('Deleting record...') + const result = await vault.deleteRecord(record.uid) + + if (result.success) { + logger.info(`Record "${title}" deleted successfully.`) + } else { + logger.error(`Failed to delete record: ${result.message || 'Unknown error'}`) + } + } finally { + await cleanup(vault) + } +} + +deleteRecord() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/records/find_password.ts b/examples/sdk_example/src/records/find_password.ts new file mode 100644 index 0000000..61874f0 --- /dev/null +++ b/examples/sdk_example/src/records/find_password.ts @@ -0,0 +1,57 @@ +import { execSync } from 'child_process' +import { login, cleanup, prompt, getRecordTitle, getRecordPassword, logger, extractErrorMessage } from 'keeper-sdk' + +function copyToClipboard(text: string): boolean { + try { + if (process.platform === 'darwin') { + execSync('pbcopy', { input: text }) + } else if (process.platform === 'win32') { + execSync('clip', { input: text }) + } else { + execSync('xclip -selection clipboard', { input: text }) + } + return true + } catch { + return false + } +} + +async function findPassword() { + const vault = await login() + + try { + const input = await prompt('Enter Record UID or title: ') + if (!input) { + logger.info('No input provided.') + return + } + + const record = vault.findRecord(input) + if (!record) { + logger.info(`Record "${input}" not found.`) + return + } + + const password = getRecordPassword(record) + if (!password) { + logger.info('No password found for this record.') + return + } + + const title = getRecordTitle(record) + if (copyToClipboard(password)) { + logger.info(`Password for "${title}" copied to clipboard.`) + } else { + logger.error('Failed to copy to clipboard.') + } + } finally { + await cleanup(vault) + } +} + +findPassword() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/records/get_record.ts b/examples/sdk_example/src/records/get_record.ts new file mode 100644 index 0000000..ab8e149 --- /dev/null +++ b/examples/sdk_example/src/records/get_record.ts @@ -0,0 +1,126 @@ +import { + login, + cleanup, + prompt, + getRecordTitle, + getRecordType, + getRecordFields, + getRecordLogin, + getRecordPassword, + getRecordUrl, + logger, + extractErrorMessage, +} from 'keeper-sdk' + +async function getRecord() { + const vault = await login() + + try { + const records = vault.getRecords() + + if (records.length === 0) { + logger.info('No records found in vault.') + return + } + + let searchInput = await prompt('Enter record UID or title: ') + + if (!searchInput) { + searchInput = records[0].uid + logger.info(`No input provided. Using first record: ${searchInput}`) + } + + const record = vault.findRecord(searchInput) + + if (!record) { + logger.info(`\nRecord "${searchInput}" not found.`) + return + } + + const title = getRecordTitle(record) + const type = getRecordType(record) + const version = record.version + + logger.info('\n' + '-'.repeat(50)) + logger.info('Record Details') + logger.info('-'.repeat(50)) + logger.info(` Title: ${title}`) + logger.info(` Record UID: ${record.uid}`) + logger.info(` Version: ${version}`) + logger.info(` Revision: ${record.revision}`) + logger.info(` Shared: ${record.shared}`) + + if (version <= 2) { + logger.info(` Type: password (legacy v${version})`) + const loginVal = getRecordLogin(record) + const password = getRecordPassword(record) + const url = getRecordUrl(record) + + if (loginVal) logger.info(` Login: ${loginVal}`) + if (password) logger.info(` Password: ${'*'.repeat(password.length)}`) + if (url) logger.info(` URL: ${url}`) + + const data = record.data + if (data?.notes) logger.info(` Notes: ${data.notes}`) + + if (data?.custom && Array.isArray(data.custom)) { + logger.info('\n Custom Fields:') + for (const cf of data.custom) { + logger.info(` ${cf.name || cf.type || 'custom'}: ${cf.value}`) + } + } + } else { + logger.info(` Type: ${type} (v${version})`) + + const fields = getRecordFields(record) + if (fields.length > 0) { + logger.info('\n Fields:') + for (const field of fields) { + const label = field.label || field.type + let displayValue: string + + if (field.type === 'password') { + const pw = field.value[0] + displayValue = pw ? '*'.repeat(String(pw).length) : '(empty)' + } else if (field.type === 'fileRef') { + displayValue = `[${field.value.length} file(s)]` + } else { + displayValue = field.value + .map((v: any) => { + if (typeof v === 'string') return v + if (v && typeof v === 'object') return JSON.stringify(v) + return String(v) + }) + .filter(Boolean) + .join(', ') || '(empty)' + } + + logger.info(` ${label}: ${displayValue}`) + } + } + + if (record.data?.notes) { + logger.info(`\n Notes: ${record.data.notes}`) + } + } + + const meta = vault.getRecordMetadataByUid(record.uid) + if (meta) { + logger.info('\n Permissions:') + logger.info(` Owner: ${meta.owner}`) + logger.info(` Can Share: ${meta.canShare}`) + logger.info(` Can Edit: ${meta.canEdit}`) + } + + logger.info('-'.repeat(50)) + } finally { + await cleanup(vault) + } +} + +getRecord() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/records/list_records.ts b/examples/sdk_example/src/records/list_records.ts new file mode 100644 index 0000000..149a455 --- /dev/null +++ b/examples/sdk_example/src/records/list_records.ts @@ -0,0 +1,29 @@ +import { login, cleanup, formatRecord, logger, extractErrorMessage } from 'keeper-sdk' + +async function listRecords() { + const vault = await login() + + try { + const records = vault.getRecords() + + if (records.length === 0) { + logger.info('No records found in vault.') + return + } + + logger.info('Vault Records:') + for (const record of records) { + logger.info(formatRecord(record)) + } + logger.info('-'.repeat(50)) + } finally { + await cleanup(vault) + } +} + +listRecords() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/records/move_record.ts b/examples/sdk_example/src/records/move_record.ts new file mode 100644 index 0000000..c0b6e6c --- /dev/null +++ b/examples/sdk_example/src/records/move_record.ts @@ -0,0 +1,61 @@ +import { login, cleanup, prompt, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' + +async function moveRecord() { + const vault = await login() + + try { + const recordInput = await prompt('Enter Record UID or title to move: ') + if (!recordInput) { + logger.info('No input provided.') + return + } + + const record = vault.findRecord(recordInput) + if (!record) { + logger.info(`Record "${recordInput}" not found.`) + return + } + + const title = getRecordTitle(record) + logger.info(`\nRecord: "${title}" (${record.uid})`) + + const folders = vault.getSharedFolders() + const userFolders = vault.getUserFolders() + + if (folders.length > 0 || userFolders.length > 0) { + logger.info('\nAvailable folders:') + for (const uf of userFolders) { + const name = uf.data?.name || uf.uid + logger.info(` [User] ${uf.uid} ${name}`) + } + for (const sf of folders) { + const name = sf.name || sf.data?.name || sf.uid + logger.info(` [Shared] ${sf.uid} ${name}`) + } + } + + const dstFolderUid = await prompt('\nEnter destination folder UID (empty for root): ') + + logger.info(`\nMoving "${title}" to ${dstFolderUid || '(root)'}...`) + + const result = await vault.moveRecord({ + recordUid: record.uid, + dstFolderUid: dstFolderUid || '', + }) + + if (result.success) { + logger.info(`Record "${title}" moved successfully.`) + } else { + logger.error(`Failed to move record: ${result.message}`) + } + } finally { + await cleanup(vault) + } +} + +moveRecord() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/records/record_history.ts b/examples/sdk_example/src/records/record_history.ts new file mode 100644 index 0000000..4ab0fb3 --- /dev/null +++ b/examples/sdk_example/src/records/record_history.ts @@ -0,0 +1,125 @@ +import { login, cleanup, prompt, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' +import type { HistoryEntry } from 'keeper-sdk' + +async function recordHistory() { + const vault = await login() + + try { + const input = await prompt('Enter Record UID or title: ') + if (!input) { + logger.info('No input provided.') + return + } + + const record = vault.findRecord(input) + if (!record) { + logger.info(`Record "${input}" not found.`) + return + } + + const title = getRecordTitle(record) + logger.info(`\nFetching history for "${title}" (${record.uid})...\n`) + + const result = await vault.getRecordHistory(record.uid) + const history = result.history + + if (history.length === 0) { + logger.info('No history found for this record.') + return + } + + logger.info('-'.repeat(70)) + logger.info( + padRight('Version', 10) + + padRight('Modified By', 35) + + 'Time Modified' + ) + logger.info('-'.repeat(70)) + + const total = history.length + for (let i = 0; i < total; i++) { + const entry = history[i] + const label = i === 0 ? 'Current' : `V.${total - i}` + const modified = entry.clientModifiedTime + ? new Date(entry.clientModifiedTime).toLocaleString() + : '' + + logger.info( + padRight(label, 10) + + padRight(entry.userName, 35) + + modified + ) + } + logger.info('-'.repeat(70)) + + const viewChoice = await prompt('\nEnter revision number to view details (or press Enter to skip): ') + if (!viewChoice) return + + const revNum = parseInt(viewChoice, 10) + if (isNaN(revNum) || revNum < 1 || revNum >= total) { + logger.info(`Invalid revision. Valid range: 1..${total - 1}`) + return + } + + const revIndex = total - revNum + const rev = history[revIndex] + + displayRevision(rev, `V.${revNum}`) + } finally { + await cleanup(vault) + } +} + +function displayRevision(entry: HistoryEntry, label: string) { + logger.info('\n' + '-'.repeat(50)) + logger.info(`Revision: ${label}`) + logger.info('-'.repeat(50)) + + if (!entry.data) { + logger.info(' (could not decrypt revision data)') + return + } + + logger.info(` Title: ${entry.data.title || '(untitled)'}`) + logger.info(` Type: ${entry.data.type || 'unknown'}`) + + const fields = entry.data.fields || [] + for (const field of fields) { + const fieldLabel = field.label || field.type + const values = Array.isArray(field.value) ? field.value : [field.value] + let displayVal: string + + if (field.type === 'password') { + displayVal = values[0] ? '*'.repeat(String(values[0]).length) : '(empty)' + } else { + displayVal = values + .map((v: any) => (typeof v === 'string' ? v : JSON.stringify(v))) + .filter(Boolean) + .join(', ') || '(empty)' + } + + logger.info(` ${fieldLabel}: ${displayVal}`) + } + + if (entry.data.notes) { + logger.info(` Notes: ${entry.data.notes}`) + } + + const modified = entry.clientModifiedTime + ? new Date(entry.clientModifiedTime).toLocaleString() + : 'unknown' + logger.info(` Modified: ${modified}`) + logger.info(` By: ${entry.userName}`) + logger.info('-'.repeat(50)) +} + +function padRight(str: string, len: number): string { + return str.length >= len ? str.substring(0, len) : str + ' '.repeat(len - str.length) +} + +recordHistory() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/records/update_record.ts b/examples/sdk_example/src/records/update_record.ts new file mode 100644 index 0000000..9ada134 --- /dev/null +++ b/examples/sdk_example/src/records/update_record.ts @@ -0,0 +1,85 @@ +import { + login, + cleanup, + prompt, + getRecordTitle, + getRecordType, + getRecordFields, + logger, + extractErrorMessage, +} from 'keeper-sdk' +import type { TypedRecordData, RecordFieldInput } from 'keeper-sdk' + +async function updateRecord() { + const vault = await login() + + try { + const input = await prompt('Enter Record UID or title to update: ') + if (!input) { + logger.info('No input provided.') + return + } + + const record = vault.findRecord(input) + if (!record) { + logger.info(`Record "${input}" not found.`) + return + } + + const currentTitle = getRecordTitle(record) + const currentType = getRecordType(record) + const currentFields = getRecordFields(record) + + logger.info(`\nCurrent record: "${currentTitle}" (${currentType})`) + if (currentFields.length > 0) { + logger.info('Current fields:') + for (const f of currentFields) { + const label = f.label || f.type + const value = f.type === 'password' ? '********' : JSON.stringify(f.value) + logger.info(` ${label}: ${value}`) + } + } + + logger.info('\nEnter new values (press Enter to keep current):\n') + + const newTitle = await prompt(`Title [${currentTitle}]: `) || currentTitle + + const newFields: RecordFieldInput[] = [] + for (const field of currentFields) { + const label = field.label || field.type + const currentVal = field.type === 'password' ? '********' : String(field.value[0] || '') + const newVal = await prompt(`${label} [${currentVal}]: `) + newFields.push({ + type: field.type, + value: [newVal || field.value[0]], + ...(field.label ? { label: field.label } : {}), + }) + } + + const updateData: TypedRecordData = { + type: currentType === 'legacy' ? 'login' : currentType, + title: newTitle, + fields: newFields, + } + + logger.info('\nUpdating record...') + const result = await vault.updateRecord(record.uid, updateData) + + if (result.success) { + logger.info('Record updated successfully!') + logger.info(` UID: ${result.recordUid}`) + logger.info(` Status: ${result.status}`) + } else { + logger.error(`Failed to update record: ${result.status}`) + } + } finally { + await cleanup(vault) + } +} + +updateRecord() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/sharing/share_record.ts b/examples/sdk_example/src/sharing/share_record.ts new file mode 100644 index 0000000..b03f4df --- /dev/null +++ b/examples/sdk_example/src/sharing/share_record.ts @@ -0,0 +1,62 @@ +import { login, cleanup, prompt, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' + +async function shareRecordExample() { + const vault = await login() + + try { + const recordInput = await prompt('Enter Record UID or title to share: ') + if (!recordInput) { + logger.info('No input provided.') + return + } + + const record = vault.findRecord(recordInput) + if (!record) { + logger.info(`Record "${recordInput}" not found.`) + return + } + + const title = getRecordTitle(record) + logger.info(`\nRecord: "${title}" (${record.uid})`) + + const email = await prompt('Enter email of user to share with: ') + if (!email) { + logger.info('No email provided.') + return + } + + const editAnswer = await prompt('Grant edit permission? (y/N): ') + const canEdit = editAnswer.toLowerCase() === 'y' + + const shareAnswer = await prompt('Grant re-share permission? (y/N): ') + const canShare = shareAnswer.toLowerCase() === 'y' + + logger.info(`\nSharing "${title}" with ${email}...`) + logger.info(` Can Edit: ${canEdit}`) + logger.info(` Can Share: ${canShare}`) + + const result = await vault.shareRecord({ + recordUid: record.uid, + email, + canEdit, + canShare, + }) + + if (result.success) { + logger.info(`\nRecord "${title}" shared with ${email} successfully.`) + logger.info(`Status: ${result.status}`) + } else { + logger.error(`\nFailed to share record: ${result.message}`) + logger.error(`Status: ${result.status}`) + } + } finally { + await cleanup(vault) + } +} + +shareRecordExample() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/src/sharing/share_report.ts b/examples/sdk_example/src/sharing/share_report.ts new file mode 100644 index 0000000..dd5b2a0 --- /dev/null +++ b/examples/sdk_example/src/sharing/share_report.ts @@ -0,0 +1,94 @@ +import { login, cleanup, logger, extractErrorMessage } from 'keeper-sdk' + +function padRight(str: string, len: number): string { + if (str.length >= len) return str.substring(0, len) + return str + ' '.repeat(len - str.length) +} + +async function shareReport() { + const vault = await login() + + try { + logger.info('=== Shared Records Report ===\n') + + const recordsReport = vault.getSharedRecordsReport() + if (recordsReport.length === 0) { + logger.info('No shared records found.\n') + } else { + logger.info( + padRight('Record UID', 24) + + padRight('Title', 30) + + padRight('Owner', 30) + + 'Shared With' + ) + logger.info('-'.repeat(100)) + + for (const entry of recordsReport) { + logger.info( + padRight(entry.recordUid, 24) + + padRight(entry.recordTitle, 30) + + padRight(entry.recordOwner, 30) + + String(entry.sharedWithCount) + ) + } + logger.info('') + } + + logger.info('=== Shared Folders Report ===\n') + + const foldersReport = vault.getSharedFoldersReport() + if (foldersReport.length === 0) { + logger.info('No shared folders found.\n') + } else { + logger.info( + padRight('Folder UID', 24) + + padRight('Folder Name', 25) + + padRight('Shared To', 30) + + 'Permissions' + ) + logger.info('-'.repeat(100)) + + for (const entry of foldersReport) { + logger.info( + padRight(entry.folderUid, 24) + + padRight(entry.folderName, 25) + + padRight(entry.sharedTo, 30) + + entry.permissions + ) + } + logger.info('') + } + + logger.info('=== Share Summary Report ===\n') + + const summaryReport = vault.getShareSummaryReport() + if (summaryReport.length === 0) { + logger.info('No shares found.\n') + } else { + logger.info( + padRight('Shared To', 40) + + padRight('Records', 12) + + 'Shared Folders' + ) + logger.info('-'.repeat(66)) + + for (const entry of summaryReport) { + logger.info( + padRight(entry.sharedTo, 40) + + padRight(String(entry.recordCount), 12) + + String(entry.sharedFolderCount) + ) + } + logger.info('') + } + } finally { + await cleanup(vault) + } +} + +shareReport() + .then(() => process.exit(0)) + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exit(1) + }) diff --git a/examples/sdk_example/tsconfig.json b/examples/sdk_example/tsconfig.json new file mode 100644 index 0000000..bdbd836 --- /dev/null +++ b/examples/sdk_example/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2018", + "sourceMap": true, + "strict": false, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "keeper-sdk": ["../../KeeperSdk/src"], + "keeper-sdk/*": ["../../KeeperSdk/src/*"] + } + }, + "exclude": ["node_modules"], + "ts-node": { + "require": ["tsconfig-paths/register"] + } +} From 27a17b48e7c9563ca77557480d2b7ae2fb0a174d Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 8 Apr 2026 15:50:01 +0530 Subject: [PATCH 2/8] Persistent login and code improvement changes --- KeeperSdk/src/auth/ConsoleLogin.ts | 252 +++++++++------- KeeperSdk/src/auth/SessionManager.ts | 275 +++++++++++++----- KeeperSdk/src/index.ts | 5 +- KeeperSdk/src/records/RecordOperations.ts | 21 +- KeeperSdk/src/records/RecordUtils.ts | 88 +++--- KeeperSdk/src/sharing/Sharing.ts | 118 ++++---- KeeperSdk/src/storage/InMemoryStorage.ts | 30 +- KeeperSdk/src/utils/constants.ts | 1 - KeeperSdk/src/utils/errors.ts | 33 ++- KeeperSdk/src/vault/KeeperVault.ts | 179 +++++++++--- examples/sdk_example/package.json | 1 - examples/sdk_example/src/auth/login.ts | 76 ++++- .../src/auth/session_token_login.ts | 77 +++-- .../sdk_example/src/records/add_record.ts | 46 +-- .../sdk_example/src/records/delete_record.ts | 16 +- .../sdk_example/src/records/find_password.ts | 24 +- .../sdk_example/src/records/get_record.ts | 132 ++++----- .../sdk_example/src/records/list_records.ts | 12 +- .../sdk_example/src/records/move_record.ts | 28 +- .../sdk_example/src/records/record_history.ts | 30 +- .../sdk_example/src/records/update_record.ts | 30 +- .../sdk_example/src/sharing/share_record.ts | 32 +- .../sdk_example/src/sharing/share_report.ts | 94 ------ examples/sdk_example/src/utils/format.ts | 28 ++ examples/sdk_example/src/utils/runner.ts | 9 + 25 files changed, 975 insertions(+), 662 deletions(-) delete mode 100644 examples/sdk_example/src/sharing/share_report.ts create mode 100644 examples/sdk_example/src/utils/format.ts create mode 100644 examples/sdk_example/src/utils/runner.ts diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts index ce8de1b..025459d 100644 --- a/KeeperSdk/src/auth/ConsoleLogin.ts +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -1,72 +1,100 @@ import readline from 'readline' -import fs from 'fs' -import path from 'path' -import os from 'os' import { KeeperVault } from '../vault/KeeperVault' import { logger } from '../utils/Logger' import { extractResultCode, extractErrorMessage, KeeperSdkError } from '../utils/errors' import { SdkDefaults } from '../utils/constants' +import { FileConfigLoader } from './SessionManager' +import type { KeeperJsonConfig } from './SessionManager' + +const MAX_LOGIN_ATTEMPTS = 5 +const defaultConfigLoader = new FileConfigLoader() + +let rlManager: ReadlineManager | null = null +let suppressionDepth = 0 +let originals: { + log: typeof console.log + warn: typeof console.warn + debug: typeof console.debug + error: typeof console.error + stdoutWrite: typeof process.stdout.write + stderrWrite: typeof process.stderr.write +} | null = null class ReadlineManager { - private rl: readline.Interface - - constructor() { - this.rl = this.create() + private rl: readline.Interface | null = null + + private getOrCreate(): readline.Interface { + if (!this.rl) { + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + } + return this.rl } - private create(): readline.Interface { - return readline.createInterface({ + public reopen(): void { + if (this.rl) { + this.rl.close() + } + this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, }) } - public reopen(): void { - this.rl = this.create() - } - public question(query: string): Promise { + const rl = this.getOrCreate() return new Promise((resolve) => { - this.rl.question(query, (answer) => resolve(answer.trim())) + rl.question(query, (answer) => resolve(answer.trim())) }) } public close(): void { - this.rl.close() + if (this.rl) { + this.rl.close() + this.rl = null + } } } -const rlManager = new ReadlineManager() +function getReadlineManager(): ReadlineManager { + if (!rlManager) { + rlManager = new ReadlineManager() + } + return rlManager +} export function prompt(question: string, masked = false): Promise { + const mgr = getReadlineManager() if (!masked) { - return rlManager.question(question) + return mgr.question(question) } return new Promise((resolve, reject) => { - rlManager.close() + mgr.close() process.stdout.write(question) let buf = '' process.stdin.setRawMode(true) process.stdin.resume() process.stdin.setEncoding('utf8') + function exitRawMode() { + process.stdout.write('\n') + process.stdin.setRawMode(false) + process.stdin.pause() + process.stdin.removeListener('data', onData) + mgr.reopen() + } + const onData = (str: string) => { for (const ch of str) { if (ch === '\n' || ch === '\r') { - process.stdout.write('\n') - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.removeListener('data', onData) - rlManager.reopen() + exitRawMode() resolve(buf.trim()) return } else if (ch === '\u0003') { - process.stdout.write('\n') - process.stdin.setRawMode(false) - process.stdin.pause() - process.stdin.removeListener('data', onData) - rlManager.reopen() + exitRawMode() reject(new KeeperSdkError('Operation cancelled by user.', 'user_cancelled')) return } else if (ch === '\u007F' || ch === '\b') { @@ -89,32 +117,17 @@ export const KEEPER_PUBLIC_HOSTS: Record = { US: 'keepersecurity.com', EU: 'keepersecurity.eu', AU: 'keepersecurity.com.au', - GOV: 'govcloud.keepersecurity.us', - JP: 'keepersecurity.jp', CA: 'keepersecurity.ca', - DEV: 'dev.keepersecurity.com', + JP: 'keepersecurity.jp', + GOV: 'govcloud.keepersecurity.us', } -type KeeperConfig = { - last_login?: string - user?: string - last_server?: string - server?: string - users?: { user?: string; server?: string }[] +export function loadKeeperConfig(): KeeperJsonConfig { + return defaultConfigLoader.load() } -export function loadKeeperConfig(): KeeperConfig { - const configPath = path.join(os.homedir(), '.keeper', 'config.json') - try { - if (fs.existsSync(configPath)) { - return JSON.parse(fs.readFileSync(configPath, 'utf-8')) - } - } catch {} - return {} -} - -export async function resolveServer(username?: string): Promise { - const config = loadKeeperConfig() +export async function resolveServer(username?: string, preloadedConfig?: KeeperJsonConfig): Promise { + const config = preloadedConfig || loadKeeperConfig() const configServer = config.last_server || config.server if (username) { @@ -148,22 +161,47 @@ export async function resolveServer(username?: string): Promise { } export function suppressLogs(): () => void { - const origLog = console.log - const origWarn = console.warn - const origDebug = console.debug - const origWrite = process.stdout.write.bind(process.stdout) - - console.log = () => {} - console.warn = () => {} - console.debug = () => {} - const boundWrite: typeof process.stdout.write = () => true - process.stdout.write = boundWrite + if (suppressionDepth === 0) { + originals = { + log: console.log, + warn: console.warn, + debug: console.debug, + error: console.error, + stdoutWrite: process.stdout.write.bind(process.stdout), + stderrWrite: process.stderr.write.bind(process.stderr), + } + console.log = () => {} + console.warn = () => {} + console.debug = () => {} + console.error = () => {} + process.stdout.write = (() => true) as typeof process.stdout.write + process.stderr.write = (() => true) as typeof process.stderr.write + } + suppressionDepth++ + let restored = false return () => { - console.log = origLog - console.warn = origWarn - console.debug = origDebug - process.stdout.write = origWrite + if (restored) return + restored = true + suppressionDepth-- + if (suppressionDepth === 0 && originals) { + console.log = originals.log + console.warn = originals.warn + console.debug = originals.debug + console.error = originals.error + process.stdout.write = originals.stdoutWrite + process.stderr.write = originals.stderrWrite + originals = null + } + } +} + +async function withSuppressedOutput(fn: () => Promise): Promise { + const restore = suppressLogs() + try { + return await fn() + } finally { + restore() } } @@ -171,9 +209,18 @@ export async function login(): Promise { const config = loadKeeperConfig() const defaultUsername = config.last_login || config.user || '' + const host = defaultUsername + ? await resolveServer(defaultUsername, config) + : undefined + + if (defaultUsername && host) { + const vault = await tryPersistentLogin(host, defaultUsername) + if (vault) return vault + } + let username: string if (defaultUsername) { - logger.info(`Enter password for ${defaultUsername}`) + logger.info(`Enter master password for ${defaultUsername}`) username = defaultUsername } else { username = await prompt('Username (email): ') @@ -183,62 +230,69 @@ export async function login(): Promise { throw new KeeperSdkError('Username is required.', 'missing_username') } - const host = await resolveServer(username) - - const vault = new KeeperVault({ - host, - clientVersion: SdkDefaults.CLIENT_VERSION, - }) + const resolvedHost = host || await resolveServer(username, config) + return await interactiveLogin(resolvedHost, username) +} - const savedConsoleLog = console.log +async function tryPersistentLogin(host: string, username: string): Promise { + const vault = new KeeperVault({ host, clientVersion: SdkDefaults.CLIENT_VERSION }) + try { + await withSuppressedOutput(() => vault.resumeSession()) + logger.info(`Logging in to Keeper as ${username}`) + logger.info('Successfully authenticated with Persistent Login') + return await syncVault(vault) + } catch (err) { + logger.debug('Persistent login failed:', extractErrorMessage(err)) + vault.disconnect() + return null + } +} - while (true) { - // Restore console.log in case it was suppressed after a failed attempt - console.log = savedConsoleLog +async function interactiveLogin(host: string, username: string): Promise { + const vault = new KeeperVault({ host, clientVersion: SdkDefaults.CLIENT_VERSION }) + for (let attempt = 1; attempt <= MAX_LOGIN_ATTEMPTS; attempt++) { const password = await prompt('Password: ', true) if (!password) { throw new KeeperSdkError('Password is required.', 'missing_password') } - const restore = suppressLogs() try { - await vault.login(username, password) - restore() - break + await withSuppressedOutput(() => vault.login(username, password)) + logger.info('Successfully authenticated with Master Password\n') + return await syncVault(vault) } catch (err) { - restore() const resultCode = extractResultCode(err) if (resultCode === 'invalid_credentials') { - logger.warn('Invalid credentials') - console.log = () => {} - continue + const remaining = MAX_LOGIN_ATTEMPTS - attempt + if (remaining > 0) { + logger.warn(`Invalid credentials (${remaining} attempt${remaining === 1 ? '' : 's'} remaining)`) + continue + } + throw new KeeperSdkError( + `Maximum login attempts (${MAX_LOGIN_ATTEMPTS}) exceeded.`, + 'max_attempts_exceeded' + ) } throw KeeperSdkError.from(err) } } - logger.info('Syncing vault...') - - const restore2 = suppressLogs() - try { - await vault.sync() - } finally { - restore2() - } + throw new KeeperSdkError( + `Maximum login attempts (${MAX_LOGIN_ATTEMPTS}) exceeded.`, + 'max_attempts_exceeded' + ) +} +async function syncVault(vault: KeeperVault): Promise { + logger.info('Syncing vault...') + await withSuppressedOutput(() => vault.sync()) logger.info(`Vault synced. ${vault.getSummary().recordCount} records loaded.\n`) - return vault } -export async function cleanup(vault: KeeperVault): Promise { - const restore = suppressLogs() - try { - await vault.logout() - } finally { - restore() - } - rlManager.close() +export function cleanup(vault: KeeperVault): void { + vault.disconnect() + getReadlineManager().close() } diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts index b776dff..51375cb 100644 --- a/KeeperSdk/src/auth/SessionManager.ts +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -3,71 +3,119 @@ import path from 'path' import os from 'os' import type { DeviceConfig, SessionStorage, KeeperHost, SessionParams } from '@keeper-security/keeperapi' import { logger } from '../utils/Logger' +import { extractErrorMessage } from '../utils/errors' import { SdkDefaults } from '../utils/constants' -type PersistedDeviceConfig = { - readonly deviceToken?: string - readonly privateKey?: string - readonly publicKey?: string - readonly deviceName?: string - readonly transmissionKeyId?: number - readonly mlKemPublicKeyId?: number - readonly useHpkeTransmission?: boolean +export type KeeperJsonConfig = { + last_login?: string + last_server?: string + user?: string + server?: string + device_token?: string + private_key?: string + clone_code?: string + users?: Array<{ + user?: string + server?: string + last_device?: { device_token?: string } + }> + devices?: Array<{ + device_token?: string + private_key?: string + server_info?: Array<{ + server?: string + clone_code?: string + }> + }> } -type PersistedConfig = { - lastUsername?: string - devices: Record - cloneCodes: Record +type ResolvedDevice = { + deviceToken: Buffer + privateKey: Buffer + serverInfo: Array<{ server: string; clone_code: string }> +} + +export interface ConfigLoader { + load(): KeeperJsonConfig + save(config: KeeperJsonConfig): void + 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) + } + + load(): KeeperJsonConfig { + const configPath = path.join(this.configDir, 'config.json') + try { + if (fs.existsSync(configPath)) { + const parsed: unknown = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + if (SessionManager.isValidKeeperConfig(parsed)) { + return parsed + } + } + } catch (err) { + logger.debug('Failed to load keeper config:', extractErrorMessage(err)) + } + return {} + } + + save(config: KeeperJsonConfig): void { + const configPath = path.join(this.configDir, 'config.json') + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }) + } } export class SessionManager implements SessionStorage { - private readonly configPath: string - private readonly config: PersistedConfig + private readonly configLoader: ConfigLoader private sessionParams: SessionParams | null = null private _lastUsername?: string + private _keeperConfig: KeeperJsonConfig | null = null + private _deviceCache: { username: string; device: ResolvedDevice | null } | null = null + private sessionDevices = new Map() + private sessionCloneCodes = new Map() - constructor(configDir?: string) { - const dir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) + constructor(configDir?: string) + constructor(loader: ConfigLoader) + constructor(configDirOrLoader?: string | ConfigLoader) { + if (typeof configDirOrLoader === 'string' || configDirOrLoader === undefined) { + this.configLoader = new FileConfigLoader(configDirOrLoader as string | undefined) + } else { + this.configLoader = configDirOrLoader } - this.configPath = path.join(dir, SdkDefaults.CONFIG_FILE) - this.config = this.load() - this._lastUsername = this.config.lastUsername + } + + public get configDir(): string { + return this.configLoader.configDir } public get lastUsername(): string | undefined { - return this._lastUsername + if (this._lastUsername) return this._lastUsername + const kc = this.loadKeeperConfig() + return kc.last_login || kc.user || undefined } public getDeviceConfig(host: string): DeviceConfig { - const persisted = this.config.devices[host] - if (!persisted) return {} - - return { - deviceToken: persisted.deviceToken ? Buffer.from(persisted.deviceToken, 'base64') : undefined, - privateKey: persisted.privateKey ? Buffer.from(persisted.privateKey, 'base64') : undefined, - publicKey: persisted.publicKey ? Buffer.from(persisted.publicKey, 'base64') : undefined, - deviceName: persisted.deviceName, - transmissionKeyId: persisted.transmissionKeyId, - mlKemPublicKeyId: persisted.mlKemPublicKeyId, - useHpkeTransmission: persisted.useHpkeTransmission, + const username = this.lastUsername + if (username) { + const device = this.findDeviceInKeeperConfig(username) + if (device) { + return { + deviceToken: device.deviceToken, + privateKey: device.privateKey, + } + } } + + return this.sessionDevices.get(host) || {} } public createOnDeviceConfig(host: string): (deviceConfig: DeviceConfig) => Promise { return async (deviceConfig: DeviceConfig) => { - this.config.devices[host] = { - deviceToken: deviceConfig.deviceToken ? Buffer.from(deviceConfig.deviceToken).toString('base64') : undefined, - privateKey: deviceConfig.privateKey ? Buffer.from(deviceConfig.privateKey).toString('base64') : undefined, - publicKey: deviceConfig.publicKey ? Buffer.from(deviceConfig.publicKey).toString('base64') : undefined, - deviceName: deviceConfig.deviceName, - transmissionKeyId: deviceConfig.transmissionKeyId, - mlKemPublicKeyId: deviceConfig.mlKemPublicKeyId, - useHpkeTransmission: deviceConfig.useHpkeTransmission, - } - this.save() + this.sessionDevices.set(host, { ...deviceConfig }) } } @@ -76,16 +124,67 @@ export class SessionManager implements SessionStorage { } public async getCloneCode(host: KeeperHost, username: string): Promise { + const hostStr = String(host) + const key = this.cloneCodeKey(host, username) - const encoded = this.config.cloneCodes[key] - if (!encoded) return null - return Buffer.from(encoded, 'base64') + const sessionCode = this.sessionCloneCodes.get(key) + if (sessionCode) return sessionCode + + const device = this.findDeviceInKeeperConfig(username) + if (device) { + const serverInfo = device.serverInfo.find(si => si.server === hostStr) + if (serverInfo) { + return SessionManager.base64urlDecode(serverInfo.clone_code) + } + } + + return null } public async saveCloneCode(host: KeeperHost, username: string, cloneCode: Uint8Array): Promise { const key = this.cloneCodeKey(host, username) - this.config.cloneCodes[key] = Buffer.from(cloneCode).toString('base64') - this.save() + this.sessionCloneCodes.set(key, cloneCode) + this.updateKeeperConfigCloneCode(String(host), username, cloneCode) + } + + private updateKeeperConfigCloneCode(host: string, username: string, cloneCode: Uint8Array): void { + try { + const parsed = this.configLoader.load() + if (!parsed || Object.keys(parsed).length === 0) return + + let updated = false + const encodedCloneCode = Buffer.from(cloneCode).toString('base64url') + + const server = parsed.last_server || parsed.server + if (parsed.user?.toLowerCase() === username.toLowerCase() && server === host) { + parsed.clone_code = encodedCloneCode + updated = true + } + + const user = (parsed.users || []).find( + u => u.user?.toLowerCase() === username.toLowerCase() + ) + if (user?.last_device?.device_token) { + const device = (parsed.devices || []).find( + d => d.device_token === user.last_device.device_token + ) + if (device?.server_info) { + const serverInfo = device.server_info.find(si => si.server === host) + if (serverInfo) { + serverInfo.clone_code = encodedCloneCode + updated = true + } + } + } + + if (updated) { + this.configLoader.save(parsed) + this._keeperConfig = null + this._deviceCache = null + } + } catch (err) { + logger.warn('Failed to update keeper config clone code:', extractErrorMessage(err)) + } } public async getSessionParameters(): Promise { @@ -93,35 +192,79 @@ export class SessionManager implements SessionStorage { } public async saveSessionParameters(params: Partial): Promise { - this.sessionParams = params as SessionParams + this.sessionParams = { ...this.sessionParams, ...params } as SessionParams if (params.username) { - this.config.lastUsername = params.username this._lastUsername = params.username - this.save() } } public setLastUsername(username: string): void { - this.config.lastUsername = username this._lastUsername = username - this.save() } - private load(): PersistedConfig { - try { - if (fs.existsSync(this.configPath)) { - const raw = fs.readFileSync(this.configPath, 'utf-8') - return JSON.parse(raw) - } - } catch {} - return { devices: {}, cloneCodes: {} } + private loadKeeperConfig(): KeeperJsonConfig { + if (this._keeperConfig) return this._keeperConfig + this._keeperConfig = this.configLoader.load() + return this._keeperConfig } - private save(): void { - try { - fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2), 'utf-8') - } catch (e) { - logger.error('Failed to save SDK config:', e) + private findDeviceInKeeperConfig(username: string): ResolvedDevice | null { + const normalizedUsername = username.toLowerCase() + if (this._deviceCache?.username === normalizedUsername) { + return this._deviceCache.device } + + const device = this.lookupDeviceInKeeperConfig(normalizedUsername) + this._deviceCache = { username: normalizedUsername, device } + return device + } + + private lookupDeviceInKeeperConfig(normalizedUsername: string): ResolvedDevice | null { + const kc = this.loadKeeperConfig() + + if (kc.device_token && kc.private_key && kc.user?.toLowerCase() === normalizedUsername) { + const serverInfo: Array<{ server: string; clone_code: string }> = [] + const server = kc.last_server || kc.server + if (server && kc.clone_code) { + serverInfo.push({ server, clone_code: kc.clone_code }) + } + return { + deviceToken: SessionManager.base64urlDecode(kc.device_token), + privateKey: SessionManager.base64urlDecode(kc.private_key), + serverInfo, + } + } + + if (kc.users && kc.devices) { + const user = kc.users.find(u => u.user?.toLowerCase() === normalizedUsername) + if (user?.last_device?.device_token) { + const deviceTokenStr = user.last_device.device_token + const device = kc.devices.find(d => d.device_token === deviceTokenStr) + if (device?.private_key) { + return { + deviceToken: SessionManager.base64urlDecode(deviceTokenStr), + privateKey: SessionManager.base64urlDecode(device.private_key), + serverInfo: (device.server_info || []) + .filter((si): si is { server: string; clone_code: string } => + !!si.server && !!si.clone_code + ), + } + } + } + } + + return null + } + + 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 + } + + private static base64urlDecode(str: string): Buffer { + return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64') } } diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 6180f3f..daf7815 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -1,5 +1,6 @@ export { ConsoleAuthUI } from './auth/ConsoleAuthUI' -export { SessionManager } from './auth/SessionManager' +export { SessionManager, FileConfigLoader } from './auth/SessionManager' +export type { KeeperJsonConfig, ConfigLoader } from './auth/SessionManager' export { login, cleanup, @@ -22,11 +23,13 @@ export { getRecordTitle, getRecordType, getRecordFields, + getRecordSummary, getRecordPassword, getRecordLogin, getRecordUrl, RecordVersion, } from './records/RecordUtils' +export type { RecordSummary } from './records/RecordUtils' export { addRecord, updateRecord, deleteRecord, getRecordHistory, moveRecord } from './records/RecordOperations' export type { PasswordRecordData, diff --git a/KeeperSdk/src/records/RecordOperations.ts b/KeeperSdk/src/records/RecordOperations.ts index 159b0e1..2805069 100644 --- a/KeeperSdk/src/records/RecordOperations.ts +++ b/KeeperSdk/src/records/RecordOperations.ts @@ -28,6 +28,7 @@ import type { BaseRequest, } from '@keeper-security/keeperapi' import { extractErrorMessage, KeeperSdkError } from '../utils/errors' +import { logger } from '../utils/Logger' import { RecordVersion } from './RecordUtils' import { InMemoryStorage } from '../storage/InMemoryStorage' @@ -57,7 +58,6 @@ enum CommandName { const MIN_RECORD_PAD_BYTES = 384 const PAD_BLOCK_SIZE = 16 -// Pads JSON to minimum 384 bytes, rounded up to nearest multiple of 16. function getPaddedJsonBytes(data: Record): Uint8Array { const json = JSON.stringify(data) const paddedLength = Math.ceil(Math.max(MIN_RECORD_PAD_BYTES, json.length) / PAD_BLOCK_SIZE) * PAD_BLOCK_SIZE @@ -120,7 +120,6 @@ export async function addRecord( return addTypedRecord(auth, input.data, input.folderUid) } -// v2 PasswordRecord via JSON command API, record key encrypted with AES-CBC. async function addPasswordRecord( auth: Auth, data: PasswordRecordData, @@ -175,7 +174,6 @@ async function addPasswordRecord( } } -// v3 TypedRecord via protobuf REST API, record key encrypted with AES-GCM. async function addTypedRecord( auth: Auth, data: TypedRecordData, @@ -280,7 +278,6 @@ export async function updateRecord( } } -// Uses the v2 pre_delete → delete two-step flow. export async function deleteRecord( auth: Auth, recordUid: string @@ -354,7 +351,6 @@ type RecordHistoryResponse = KeeperResponse & { history?: RecordHistoryResponseEntry[] } -// Decrypts each revision using v2 AES-CBC or v3 AES-GCM based on version. export async function getRecordHistory( auth: Auth, recordUid: string, @@ -397,7 +393,8 @@ export async function getRecordHistory( } decryptedData = JSON.parse(platform.bytesToString(decrypted)) - } catch { + } catch (err) { + logger.debug(`Failed to decrypt history revision ${entry.revision}:`, extractErrorMessage(err)) decryptedData = null } } @@ -445,18 +442,15 @@ function resolveFolder(uid: string, storage: InMemoryStorage): FolderInfo { return { uid: '', folderType: FolderType.UserFolder, scopeUid: '' } } - const userFolders = storage.getAll(FolderType.UserFolder) - if (userFolders.find((f) => f.uid === uid)) { + if (storage.getByUid(FolderType.UserFolder, uid)) { return { uid, folderType: FolderType.UserFolder, scopeUid: '' } } - const sharedFolders = storage.getAll(FolderType.SharedFolder) - if (sharedFolders.find((f) => f.uid === uid)) { + if (storage.getByUid(FolderType.SharedFolder, uid)) { return { uid, folderType: FolderType.SharedFolder, scopeUid: uid } } - const sfFolders = storage.getAll(FolderType.SharedFolderFolder) - const sfFolder = sfFolders.find((f) => f.uid === uid) + const sfFolder = storage.getByUid(FolderType.SharedFolderFolder, uid) if (sfFolder) { return { uid, folderType: FolderType.SharedFolderFolder, scopeUid: sfFolder.sharedFolderUid } } @@ -477,7 +471,6 @@ function findRecordSourceFolder( return { folderUid: '', folderType: FolderType.UserFolder } } -// When moving across folder scopes, the record key is re-encrypted with the destination folder's key. export async function moveRecord( auth: Auth, storage: InMemoryStorage, @@ -530,7 +523,7 @@ export async function moveRecord( return { recordUid, success: false, message: 'Destination folder key not found' } } - const record = storage.getAll(ObjectType.Record).find((r) => r.uid === recordUid) + const record = storage.getByUid(ObjectType.Record, recordUid) const version = record?.version || RecordVersion.Typed let encryptedKey: Uint8Array diff --git a/KeeperSdk/src/records/RecordUtils.ts b/KeeperSdk/src/records/RecordUtils.ts index c409d97..464db4d 100644 --- a/KeeperSdk/src/records/RecordUtils.ts +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -19,14 +19,13 @@ type RecordField = { label?: string } -// Handles both legacy (v1/v2) and modern (v3+) record formats. export function getRecordTitle(record: DRecord): string { if (!record.data) return '(no data)' if (typeof record.data === 'string') { try { const parsed = JSON.parse(record.data) return parsed.title || parsed.name || '(untitled)' - } catch { + } catch (_err) { return '(parse error)' } } @@ -39,7 +38,6 @@ export function getRecordType(record: DRecord): string { return record.data.type || 'unknown' } -// Returns an empty array for legacy records. export function getRecordFields(record: DRecord): RecordField[] { if (!record.data) return [] @@ -75,51 +73,68 @@ export function getRecordFields(record: DRecord): RecordField[] { return fields } -export function getRecordPassword(record: DRecord): string | undefined { +export type RecordSummary = { + login?: string + password?: string + url?: string + fields: RecordField[] +} + +export function getRecordSummary(record: DRecord): RecordSummary { + const fields = getRecordFields(record) if (record.version <= RecordVersion.Legacy) { - return record.data?.secret2 + return { + login: record.data?.secret1, + password: record.data?.secret2, + url: record.data?.link, + fields, + } } - const fields = getRecordFields(record) - const pwField = fields.find((f) => f.type === FieldType.Password) - if (pwField && pwField.value.length > 0) { - return String(pwField.value[0]) + + let login: string | undefined + let password: string | undefined + let url: string | undefined + + for (const f of fields) { + if (!login && f.type === FieldType.Login && f.value.length > 0) { + login = String(f.value[0]) + } else if (!password && f.type === FieldType.Password && f.value.length > 0) { + password = String(f.value[0]) + } else if (!url && f.type === FieldType.Url && f.value.length > 0) { + const val = f.value[0] + url = typeof val === 'string' ? val : val?.value || val?.url + } } - return undefined + + return { login, password, url, fields } +} + +export function getRecordPassword(record: DRecord): string | undefined { + return getRecordSummary(record).password } export function getRecordLogin(record: DRecord): string | undefined { - if (record.version <= RecordVersion.Legacy) { - return record.data?.secret1 - } - const fields = getRecordFields(record) - const loginField = fields.find((f) => f.type === FieldType.Login) - if (loginField && loginField.value.length > 0) { - return String(loginField.value[0]) - } - return undefined + return getRecordSummary(record).login } export function getRecordUrl(record: DRecord): string | undefined { - if (record.version <= RecordVersion.Legacy) { - return record.data?.link - } - const fields = getRecordFields(record) - const urlField = fields.find((f) => f.type === FieldType.Url) - if (urlField && urlField.value.length > 0) { - const val = urlField.value[0] - return typeof val === 'string' ? val : val?.value || val?.url - } - return undefined + return getRecordSummary(record).url } +const wordCache = new WeakMap() + export function searchRecords(records: DRecord[], criteria: string): DRecord[] { if (!criteria.trim()) return records const searchWords = criteria.toLowerCase().split(/\s+/) return records.filter((record) => { - const words = collectRecordWords(record) - return searchWords.every((sw) => words.some((w) => w.includes(sw))) + let words = wordCache.get(record) + if (!words) { + words = collectRecordWords(record) + wordCache.set(record, words) + } + return searchWords.every((sw) => words!.some((w) => w.includes(sw))) }) } @@ -150,6 +165,7 @@ function collectRecordWords(record: DRecord): string[] { export function formatRecord(record: DRecord, showDetails = false): string { const title = getRecordTitle(record) const type = getRecordType(record) + const summary = getRecordSummary(record) const lines: string[] = [] lines.push('-'.repeat(50)) @@ -157,15 +173,11 @@ export function formatRecord(record: DRecord, showDetails = false): string { lines.push(`Record UID: ${record.uid}`) lines.push(`Record Type: ${type}`) - const login = getRecordLogin(record) - const url = getRecordUrl(record) - - if (login) lines.push(`Username: ${login}`) - if (url) lines.push(`URL: ${url}`) + if (summary.login) lines.push(`Username: ${summary.login}`) + if (summary.url) lines.push(`URL: ${summary.url}`) if (showDetails) { - const fields = getRecordFields(record) - for (const field of fields) { + for (const field of summary.fields) { if (field.type === FieldType.Login || field.type === FieldType.Url) continue const label = field.label || field.type const value = field.type === FieldType.Password ? '********' : field.value.join(', ') diff --git a/KeeperSdk/src/sharing/Sharing.ts b/KeeperSdk/src/sharing/Sharing.ts index 08ff21e..1c57785 100644 --- a/KeeperSdk/src/sharing/Sharing.ts +++ b/KeeperSdk/src/sharing/Sharing.ts @@ -96,82 +96,89 @@ export type RemoveShareResult = { } export class ShareReportGenerator { - private records: DRecord[] - private metadata: DRecordMetadata[] - private sharedFolders: DSharedFolder[] - private sharedFolderUsers: DSharedFolderUser[] - private sharedFolderTeams: DSharedFolderTeam[] - private sharedFolderRecords: DSharedFolderRecord[] - private teams: DTeam[] - private currentUser: string + private readonly storage: InMemoryStorage + private readonly currentUser: string + + private _sfUserMap: Map | null = null + private _sfTeamMap: Map | null = null + private _sfRecordsByRecord: Map | null = null constructor(storage: InMemoryStorage, currentUser: string) { - this.records = storage.getAll(StorageType.Record) - this.metadata = storage.getAll(StorageType.Metadata) - this.sharedFolders = storage.getAll(StorageType.SharedFolder) - this.sharedFolderUsers = storage.getAll(StorageType.SharedFolderUser) - this.sharedFolderTeams = storage.getAll(StorageType.SharedFolderTeam) - this.sharedFolderRecords = storage.getAll(StorageType.SharedFolderRecord) - this.teams = storage.getAll(StorageType.Team) + this.storage = storage this.currentUser = currentUser } - public generateRecordsReport(): ShareReportEntry[] { - const report: ShareReportEntry[] = [] - const metaMap = new Map(this.metadata.map((m) => [m.uid, m])) - - const sfRecordsByRecord = new Map() - for (const sfr of this.sharedFolderRecords) { - const list = sfRecordsByRecord.get(sfr.recordUid) || [] - list.push(sfr) - sfRecordsByRecord.set(sfr.recordUid, list) + private get sfUserMap(): Map { + if (!this._sfUserMap) { + this._sfUserMap = new Map() + for (const sfu of this.storage.getAll(StorageType.SharedFolderUser)) { + const list = this._sfUserMap.get(sfu.sharedFolderUid) || [] + list.push(sfu) + this._sfUserMap.set(sfu.sharedFolderUid, list) + } } + return this._sfUserMap + } - const sfUserMap = new Map() - for (const sfu of this.sharedFolderUsers) { - const list = sfUserMap.get(sfu.sharedFolderUid) || [] - list.push(sfu) - sfUserMap.set(sfu.sharedFolderUid, list) + private get sfTeamMap(): Map { + if (!this._sfTeamMap) { + this._sfTeamMap = new Map() + for (const sft of this.storage.getAll(StorageType.SharedFolderTeam)) { + const list = this._sfTeamMap.get(sft.sharedFolderUid) || [] + list.push(sft) + this._sfTeamMap.set(sft.sharedFolderUid, list) + } } + return this._sfTeamMap + } - const sfTeamMap = new Map() - for (const sft of this.sharedFolderTeams) { - const list = sfTeamMap.get(sft.sharedFolderUid) || [] - list.push(sft) - sfTeamMap.set(sft.sharedFolderUid, list) + private get sfRecordsByRecord(): Map { + if (!this._sfRecordsByRecord) { + this._sfRecordsByRecord = new Map() + for (const sfr of this.storage.getAll(StorageType.SharedFolderRecord)) { + const list = this._sfRecordsByRecord.get(sfr.recordUid) || [] + list.push(sfr) + this._sfRecordsByRecord.set(sfr.recordUid, list) + } } + return this._sfRecordsByRecord + } - for (const record of this.records) { + public generateRecordsReport(): ShareReportEntry[] { + const report: ShareReportEntry[] = [] + const records = this.storage.getAll(StorageType.Record) + const metadata = this.storage.getAll(StorageType.Metadata) + const metaMap = new Map(metadata.map((m) => [m.uid, m])) + + for (const record of records) { if (!record.shared) continue const meta = metaMap.get(record.uid) const owner = meta?.ownerUsername || (meta?.owner ? this.currentUser : '') - const sharedWith: string[] = [] + const sharedWithSet = new Set() - const sfRecords = sfRecordsByRecord.get(record.uid) || [] + const sfRecords = this.sfRecordsByRecord.get(record.uid) || [] const seenFolders = new Set() for (const sfr of sfRecords) { if (seenFolders.has(sfr.sharedFolderUid)) continue seenFolders.add(sfr.sharedFolderUid) - const users = sfUserMap.get(sfr.sharedFolderUid) || [] + const users = this.sfUserMap.get(sfr.sharedFolderUid) || [] for (const u of users) { const name = u.accountUsername || u.accountUid || '' - if (name && name !== this.currentUser && !sharedWith.includes(name)) { - sharedWith.push(name) + if (name && name !== this.currentUser) { + sharedWithSet.add(name) } } - const teams = sfTeamMap.get(sfr.sharedFolderUid) || [] + const teams = this.sfTeamMap.get(sfr.sharedFolderUid) || [] for (const t of teams) { - const teamLabel = `(Team) ${t.name}` - if (!sharedWith.includes(teamLabel)) { - sharedWith.push(teamLabel) - } + sharedWithSet.add(`(Team) ${t.name}`) } } + const sharedWith = Array.from(sharedWithSet) if (sharedWith.length > 0 || sfRecords.length > 0) { report.push({ recordUid: record.uid, @@ -188,14 +195,11 @@ export class ShareReportGenerator { public generateSharedFoldersReport(): SharedFolderReportEntry[] { const report: SharedFolderReportEntry[] = [] + const sharedFolders = this.storage.getAll(StorageType.SharedFolder) - for (const sf of this.sharedFolders) { - const users = this.sharedFolderUsers.filter( - (u) => u.sharedFolderUid === sf.uid - ) - const teams = this.sharedFolderTeams.filter( - (t) => t.sharedFolderUid === sf.uid - ) + for (const sf of sharedFolders) { + const users = this.sfUserMap.get(sf.uid) || [] + const teams = this.sfTeamMap.get(sf.uid) || [] const folderName = sf.name || sf.data?.name || sf.uid @@ -224,15 +228,18 @@ export class ShareReportGenerator { public generateSummaryReport(): ShareSummaryEntry[] { const recordShares = new Map>() const folderShares = new Map>() + const sharedFolderRecords = this.storage.getAll(StorageType.SharedFolderRecord) + const sharedFolderUsers = this.storage.getAll(StorageType.SharedFolderUser) + const sharedFolderTeams = this.storage.getAll(StorageType.SharedFolderTeam) const sfRecordMap = new Map() - for (const sfr of this.sharedFolderRecords) { + for (const sfr of sharedFolderRecords) { const list = sfRecordMap.get(sfr.sharedFolderUid) || [] list.push(sfr.recordUid) sfRecordMap.set(sfr.sharedFolderUid, list) } - for (const sfu of this.sharedFolderUsers) { + for (const sfu of sharedFolderUsers) { const name = sfu.accountUsername || sfu.accountUid || '' if (!name || name === this.currentUser) continue @@ -244,7 +251,7 @@ export class ShareReportGenerator { for (const r of recs) recordShares.get(name)!.add(r) } - for (const sft of this.sharedFolderTeams) { + for (const sft of sharedFolderTeams) { const name = `(Team) ${sft.name}` if (!folderShares.has(name)) folderShares.set(name, new Set()) @@ -341,7 +348,6 @@ function uidToBytes(uid: string): Uint8Array { ) } -// Encrypts the record key with the recipient's public key (ECC preferred, RSA fallback). export async function shareRecord( auth: Auth, recordKey: Uint8Array, diff --git a/KeeperSdk/src/storage/InMemoryStorage.ts b/KeeperSdk/src/storage/InMemoryStorage.ts index e480af5..91cfcd1 100644 --- a/KeeperSdk/src/storage/InMemoryStorage.ts +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -15,6 +15,7 @@ export class InMemoryStorage implements VaultStorage { private objects = new Map() private store = new Map>() private deps = new Map() + private arrayCache = new Map() public async getKeyBytes(keyId: string): Promise { return this.keys.get(keyId) @@ -39,6 +40,7 @@ export class InMemoryStorage implements VaultStorage { } const uid = this.extractUid(item) this.store.get(kind)!.set(uid, item) + this.arrayCache.delete(kind) } public async get(kind: T, uid?: string): Promise> { @@ -53,8 +55,11 @@ export class InMemoryStorage implements VaultStorage { } public async delete(kind: VaultStorageKind, uid: string | Uint8Array): Promise { - const uidStr = typeof uid === 'string' ? uid : '' + const uidStr = typeof uid === 'string' + ? uid + : Buffer.from(uid).toString('base64url') this.store.get(kind)?.delete(uidStr) + this.arrayCache.delete(kind) } public async clear(): Promise { @@ -62,6 +67,7 @@ export class InMemoryStorage implements VaultStorage { this.keys.clear() this.objects.clear() this.deps.clear() + this.arrayCache.clear() } public async getDependencies(uid: string): Promise { @@ -74,8 +80,12 @@ export class InMemoryStorage implements VaultStorage { this.deps.set(parentUid, []) } const existing = this.deps.get(parentUid)! + const seen = new Set(existing.map(d => d.uid)) for (const child of children) { - existing.push(child) + if (!seen.has(child.uid)) { + existing.push(child) + seen.add(child.uid) + } } } } @@ -98,15 +108,29 @@ export class InMemoryStorage implements VaultStorage { } public getAll(kind: VaultStorageKind): T[] { + const cached = this.arrayCache.get(kind) + if (cached) return cached as T[] + const kindMap = this.store.get(kind) if (!kindMap) return [] - return Array.from(kindMap.values()) as T[] + + const arr = Array.from(kindMap.values()) + this.arrayCache.set(kind, arr) + return arr as T[] } public getRecords(): DRecord[] { return this.getAll('record') } + public getByUid(kind: VaultStorageKind, uid: string): T | undefined { + return this.store.get(kind)?.get(uid) as T | undefined + } + + public getCount(kind: VaultStorageKind): number { + return this.store.get(kind)?.size ?? 0 + } + private extractUid(item: VaultStorageData): string { const record = item as VaultStorageData & { uid?: string diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index d32239f..7c6f5e2 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -2,6 +2,5 @@ export const SdkDefaults = { CLIENT_VERSION: 'c17.0.0', DEVICE_NAME: 'JavaScript Keeper SDK', CONFIG_DIR: '.keeper', - CONFIG_FILE: 'sdk-config.json', LOG_FORMAT: '!', } as const diff --git a/KeeperSdk/src/utils/errors.ts b/KeeperSdk/src/utils/errors.ts index ee61fe8..204406e 100644 --- a/KeeperSdk/src/utils/errors.ts +++ b/KeeperSdk/src/utils/errors.ts @@ -1,6 +1,6 @@ import type { KeeperError } from '@keeper-security/keeperapi' -export function isKeeperError(err: any): err is KeeperError { +export function isKeeperError(err: unknown): err is KeeperError { return ( err != null && typeof err === 'object' && @@ -9,27 +9,40 @@ export function isKeeperError(err: any): err is KeeperError { ) } -export function extractResultCode(err: any): string | undefined { +export function extractResultCode(err: unknown): string | undefined { if (isKeeperError(err)) { return err.result_code || err.error } if (err instanceof Error) { - try { - const parsed = JSON.parse(err.message) - return parsed.result_code || parsed.error - } catch {} + const msg = err.message + if (msg.length > 0 && (msg[0] === '{' || msg[0] === '[')) { + try { + const parsed = JSON.parse(msg) + return parsed.result_code || parsed.error + } catch {} + } } if (typeof err === 'string') return err - return err?.result_code || err?.error + if (typeof err === 'object' && err !== null) { + const obj = err as Record + if (typeof obj.result_code === 'string') return obj.result_code + if (typeof obj.error === 'string') return obj.error + } + return undefined } -export function extractErrorMessage(err: any): string { +export function extractErrorMessage(err: unknown): string { if (isKeeperError(err)) { return err.message || err.result_code || err.error || 'Unknown Keeper error' } if (err instanceof Error) return err.message if (typeof err === 'string') return err - return err?.message || err?.result_code || String(err) + if (typeof err === 'object' && err !== null) { + const obj = err as Record + if (typeof obj.message === 'string') return obj.message + if (typeof obj.result_code === 'string') return obj.result_code + } + return String(err) } export class KeeperSdkError extends Error { @@ -43,7 +56,7 @@ export class KeeperSdkError extends Error { this.keeperError = keeperError } - static from(err: any): KeeperSdkError { + static from(err: unknown): KeeperSdkError { if (err instanceof KeeperSdkError) return err if (isKeeperError(err)) { return new KeeperSdkError( diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 8d172cb..4721c9e 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -9,7 +9,7 @@ import { DUserFolder, Authentication, } from '@keeper-security/keeperapi' -import type { SyncResult, SyncLogFormat, VaultStorage } 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' @@ -45,9 +45,8 @@ import type { RemoveShareInput, RemoveShareResult, } from '../sharing/Sharing' -import { logger, LogLevel } from '../utils/Logger' -import type { Logger } from '../utils/Logger' -import { KeeperSdkError } from '../utils/errors' +import { Logger, LogLevel } from '../utils/Logger' +import { KeeperSdkError, extractErrorMessage } from '../utils/errors' import { SdkDefaults } from '../utils/constants' enum VaultStatus { @@ -62,6 +61,10 @@ export type KeeperVaultConfig = { useConsoleAuth?: boolean logFormat?: SyncLogFormat logLevel?: LogLevel + autoSync?: boolean + storage?: InMemoryStorage + sessionStorage?: SessionManager + authUI?: AuthUI3 } export type VaultSummary = { @@ -75,45 +78,53 @@ export class KeeperVault { private auth: Auth | null = null private readonly storage: InMemoryStorage private readonly sessionManager: SessionManager - private readonly authUI: ConsoleAuthUI - private readonly config: Required + private readonly authUI: AuthUI3 + private readonly config: Required> private readonly log: Logger private synced = false + private batchDepth = 0 + private _reportGenerator: ShareReportGenerator | null = null constructor(config?: KeeperVaultConfig) { this.config = { host: config?.host || KeeperEnvironment.Prod, clientVersion: config?.clientVersion || SdkDefaults.CLIENT_VERSION, - configDir: config?.configDir || '', + configDir: config?.configDir ?? '', useConsoleAuth: config?.useConsoleAuth !== false, logFormat: config?.logFormat || SdkDefaults.LOG_FORMAT, logLevel: config?.logLevel ?? LogLevel.INFO, + autoSync: config?.autoSync !== false, } - if (config?.logLevel !== undefined) { - logger.setLevel(config.logLevel) - } - - this.log = logger - this.storage = new InMemoryStorage() - this.sessionManager = new SessionManager(this.config.configDir || undefined) - this.authUI = new ConsoleAuthUI() + this.log = new Logger(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() } private createAuth(options?: { useSessionResumption?: boolean }): Auth { const host = this.config.host - const deviceConfig = this.sessionManager.getDeviceConfig(host) - - if (!deviceConfig.deviceName) { - deviceConfig.deviceName = SdkDefaults.DEVICE_NAME + const baseDeviceConfig = this.sessionManager.getDeviceConfig(host) + const deviceConfig = { + ...baseDeviceConfig, + deviceName: baseDeviceConfig.deviceName || SdkDefaults.DEVICE_NAME, } + const sessionStorage: SessionStorage = options?.useSessionResumption === false + ? { + getCloneCode: async () => null, + saveCloneCode: (h, u, c) => this.sessionManager.saveCloneCode(h, u, c), + getSessionParameters: () => this.sessionManager.getSessionParameters(), + saveSessionParameters: (p) => this.sessionManager.saveSessionParameters(p), + } + : this.sessionManager + return new Auth({ host, clientVersion: this.config.clientVersion, deviceConfig, authUI3: this.config.useConsoleAuth ? this.authUI : undefined, - sessionStorage: this.sessionManager, + sessionStorage, onDeviceConfig: this.sessionManager.createOnDeviceConfig(host), useSessionResumption: options?.useSessionResumption, }) @@ -126,9 +137,8 @@ export class KeeperVault { return this.auth } - // Handles device registration, 2FA, and device approval via console prompts when useConsoleAuth is enabled. public async login(username: string, password: string): Promise { - this.auth = this.createAuth() + this.auth = this.createAuth({ useSessionResumption: false }) this.sessionManager.setLastUsername(username) await this.auth.loginV3({ @@ -142,7 +152,6 @@ export class KeeperVault { this.log.info(`Logged in as ${username}`) } - // Device must already be registered and approved for this host via a prior normal login. public async loginWithSessionToken(username: string, sessionToken: string): Promise { const deviceConfig = this.sessionManager.getDeviceConfig(this.config.host) @@ -179,17 +188,46 @@ export class KeeperVault { } public async resumeSession(): Promise { + const username = this.sessionManager.lastUsername + if (!username) { + throw new KeeperSdkError( + 'No previous login found. Perform a normal login first.', + 'no_previous_login' + ) + } + + const deviceConfig = this.sessionManager.getDeviceConfig(this.config.host) + if (!deviceConfig.deviceToken || !deviceConfig.privateKey) { + throw new KeeperSdkError( + 'Device is not registered for this host. Perform a normal login first.', + 'device_not_registered' + ) + } + + const cloneCode = await this.sessionManager.getCloneCode(this.config.host, username) + if (!cloneCode) { + throw new KeeperSdkError( + 'No clone code found. Persistent login not enabled or clone code expired. Perform a normal login.', + 'no_clone_code' + ) + } + this.auth = this.createAuth({ useSessionResumption: true }) - const username = this.sessionManager.lastUsername || '' await this.auth.loginV3({ - username, loginType: Authentication.LoginType.NORMAL, resumeSessionOnly: true, }) + if (!this.auth.sessionToken) { + throw new KeeperSdkError( + 'Persistent login failed — clone code may be expired or persistent login not enabled. Perform a normal login.', + 'persistent_login_failed' + ) + } + this.synced = false - this.log.info(`Session resumed for ${username}`) + this.log.info(`Session resumed for ${username} (persistent login)`) } public async sync(): Promise { @@ -202,25 +240,48 @@ export class KeeperVault { }) this.synced = true + this._reportGenerator = null return result } + public async batch(fn: () => Promise): Promise { + this.batchDepth++ + try { + await fn() + } finally { + this.batchDepth-- + if (this.batchDepth === 0 && this.config.autoSync) { + await this.sync() + } + } + } + + private async syncIfNeeded(): Promise { + if (this.batchDepth > 0) { + this.synced = false + return + } + if (this.config.autoSync) { + await this.sync() + } else { + this.synced = false + } + } + public getRecords(): DRecord[] { return this.storage.getRecords() } public getRecordByUid(uid: string): DRecord | undefined { - return this.getRecords().find((r) => r.uid === uid) + return this.storage.getByUid('record', uid) } - // Tries exact UID match first, then case-insensitive title match. public findRecord(uidOrTitle: string): DRecord | undefined { - const records = this.getRecords() - const byUid = records.find((r) => r.uid === uidOrTitle) + const byUid = this.getRecordByUid(uidOrTitle) if (byUid) return byUid const needle = uidOrTitle.toLowerCase() - return records.find((r) => getRecordTitle(r).toLowerCase() === needle) + return this.getRecords().find((r) => getRecordTitle(r).toLowerCase() === needle) } public findRecords(criteria: string): DRecord[] { @@ -240,7 +301,7 @@ export class KeeperVault { } public getRecordMetadataByUid(uid: string): DRecordMetadata | undefined { - return this.getRecordMetadata().find((m) => m.uid === uid) + return this.storage.getByUid('metadata', uid) } public getSharedFolders(): DSharedFolder[] { @@ -257,10 +318,10 @@ export class KeeperVault { public getSummary(): VaultSummary { return { - recordCount: this.getRecords().length, - sharedFolderCount: this.getSharedFolders().length, - teamCount: this.getTeams().length, - folderCount: this.getUserFolders().length, + recordCount: this.storage.getCount('record'), + sharedFolderCount: this.storage.getCount('shared_folder'), + teamCount: this.storage.getCount('team'), + folderCount: this.storage.getCount('user_folder'), } } @@ -279,7 +340,7 @@ export class KeeperVault { public async addRecord(input: NewRecordInput): Promise { const auth = this.getAuthOrThrow() const result = await addRecordOp(auth, input) - if (result.success) await this.sync() + if (result.success) await this.syncIfNeeded() return result } @@ -297,21 +358,21 @@ export class KeeperVault { } const result = await updateRecordOp(auth, recordUid, data, record.revision, keyBytes) - if (result.success) await this.sync() + if (result.success) await this.syncIfNeeded() return result } public async deleteRecord(recordUid: string): Promise { const auth = this.getAuthOrThrow() const result = await deleteRecordOp(auth, recordUid) - if (result.success) await this.sync() + if (result.success) await this.syncIfNeeded() return result } public async moveRecord(input: MoveRecordInput): Promise { const auth = this.getAuthOrThrow() const result = await moveRecordOp(auth, this.storage, input) - if (result.success) await this.sync() + if (result.success) await this.syncIfNeeded() return result } @@ -321,15 +382,23 @@ export class KeeperVault { } public getSharedRecordsReport(): ShareReportEntry[] { - return this.createShareReportGenerator().generateRecordsReport() + return this.getOrCreateReportGenerator().generateRecordsReport() } public getSharedFoldersReport(): SharedFolderReportEntry[] { - return this.createShareReportGenerator().generateSharedFoldersReport() + return this.getOrCreateReportGenerator().generateSharedFoldersReport() } public getShareSummaryReport(): ShareSummaryEntry[] { - return this.createShareReportGenerator().generateSummaryReport() + return this.getOrCreateReportGenerator().generateSummaryReport() + } + + private getOrCreateReportGenerator(): ShareReportGenerator { + if (!this._reportGenerator) { + const auth = this.getAuthOrThrow() + this._reportGenerator = new ShareReportGenerator(this.storage, auth.username || '') + } + return this._reportGenerator } public async shareRecord(input: ShareRecordInput): Promise { @@ -359,14 +428,14 @@ export class KeeperVault { } const result = await shareRecordOp(auth, keyBytes, { ...input, recordUid: record.uid }) - if (result.success) await this.sync() + if (result.success) await this.syncIfNeeded() return result } public async removeRecordShare(input: RemoveShareInput): Promise { const auth = this.getAuthOrThrow() const result = await removeRecordShareOp(auth, input) - if (result.success) await this.sync() + if (result.success) await this.syncIfNeeded() return result } @@ -389,16 +458,30 @@ export class KeeperVault { return this.getAuthOrThrow() } - public async logout(): Promise { + public disconnect(): void { if (this.auth) { - try { this.auth.disconnect() } catch {} - try { await this.auth.logout() } catch {} + try { this.auth.disconnect() } catch (err) { + this.log.debug('disconnect error:', extractErrorMessage(err)) + } this.auth = null } this.synced = false + } + + public async logout(): Promise { + if (this.auth) { + try { await this.auth.logout() } catch (err) { + this.log.debug('logout error:', extractErrorMessage(err)) + } + } + this.disconnect() this.log.info('Logged out.') } + public get host(): string { + return this.config.host + } + public get isLoggedIn(): boolean { return this.auth !== null && !!this.auth.sessionToken } diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index aca45ee..b14b476 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -14,7 +14,6 @@ "records:find-password": "ts-node src/records/find_password.ts", "records:move": "ts-node src/records/move_record.ts", "sharing:share-record": "ts-node src/sharing/share_record.ts", - "sharing:report": "ts-node src/sharing/share_report.ts", "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", "types": "tsc --watch", "types:ci": "tsc" diff --git a/examples/sdk_example/src/auth/login.ts b/examples/sdk_example/src/auth/login.ts index 1e0a33a..785171e 100644 --- a/examples/sdk_example/src/auth/login.ts +++ b/examples/sdk_example/src/auth/login.ts @@ -1,14 +1,75 @@ -import { login, cleanup, logger, extractErrorMessage } from 'keeper-sdk' +import { + KeeperVault, + KeeperSdkError, + loadKeeperConfig, + resolveServer, + prompt, + suppressLogs, + cleanup, + logger, + extractResultCode, + SdkDefaults, +} from 'keeper-sdk' +import { runExample } from '../utils/runner' -async function displaySessionInfo() { - const vault = await login() +const MAX_ATTEMPTS = 5 + +async function passwordLogin() { + const config = loadKeeperConfig() + const defaultUsername = config.last_login || config.user || '' + + let username: string + if (defaultUsername) { + logger.info(`Enter master password for ${defaultUsername}`) + username = defaultUsername + } else { + username = await prompt('Username (email): ') + if (!username) throw new KeeperSdkError('Username is required.', 'missing_username') + } + + const host = await resolveServer(username) + const vault = new KeeperVault({ host, clientVersion: SdkDefaults.CLIENT_VERSION }) try { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const password = await prompt('Password: ', true) + if (!password) throw new KeeperSdkError('Password is required.', 'missing_password') + + const restore = suppressLogs() + try { + await vault.login(username, password) + restore() + break + } catch (err) { + restore() + const resultCode = extractResultCode(err) + if (resultCode === 'invalid_credentials') { + const remaining = MAX_ATTEMPTS - attempt + if (remaining > 0) { + logger.warn(`Incorrect Password (${remaining} attempt${remaining === 1 ? '' : 's'} remaining)`) + continue + } + throw new KeeperSdkError(`Maximum login attempts (${MAX_ATTEMPTS}) exceeded.`, 'max_attempts_exceeded') + } + throw KeeperSdkError.from(err) + } + } + + logger.info('Successfully authenticated with Master Password\n') + logger.info('Syncing vault...') + const restore = suppressLogs() + try { + await vault.sync() + } finally { + restore() + } + const auth = vault.getAuth() const summary = vault.getSummary() logger.info('--- Session Info ---') logger.info(` Username: ${auth.username}`) + logger.info(` Server: ${vault.host}`) logger.info(` Session Token: ${auth.sessionToken || '(none)'}`) logger.info(` Data Key: ${auth.dataKey ? '(loaded)' : '(not loaded)'}`) logger.info(` Records: ${summary.recordCount}`) @@ -18,13 +79,8 @@ async function displaySessionInfo() { logger.info('\nLogin successful. Session is active.') } finally { - await cleanup(vault) + cleanup(vault) } } -displaySessionInfo() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(passwordLogin) diff --git a/examples/sdk_example/src/auth/session_token_login.ts b/examples/sdk_example/src/auth/session_token_login.ts index 3b73b5f..4d49047 100644 --- a/examples/sdk_example/src/auth/session_token_login.ts +++ b/examples/sdk_example/src/auth/session_token_login.ts @@ -1,4 +1,5 @@ -import { KeeperVault, prompt, suppressLogs, logger, KeeperSdkError, extractErrorMessage, SdkDefaults } from 'keeper-sdk' +import { KeeperVault, prompt, suppressLogs, cleanup, logger, KeeperSdkError, SdkDefaults } from 'keeper-sdk' +import { runExample } from '../utils/runner' async function main() { const username = await prompt('Username (email): ') @@ -12,50 +13,46 @@ async function main() { const resolvedHost = host || 'keepersecurity.com' logger.info(`\nLogging in as ${username} on ${resolvedHost} using session token...`) - logger.info(` Session Token: ${sessionToken}\n`) const vault = new KeeperVault({ host: resolvedHost, clientVersion: SdkDefaults.CLIENT_VERSION }) - let restore = suppressLogs() try { - await vault.loginWithSessionToken(username, sessionToken) + { + const restore = suppressLogs() + try { + await vault.loginWithSessionToken(username, sessionToken) + } finally { + restore() + } + } + + const auth = vault.getAuth() + + logger.info('--- Session Info ---') + logger.info(` Username: ${auth.username}`) + logger.info(` Session Token: ${auth.sessionToken ? '(active)' : '(none)'}`) + logger.info(` Data Key: ${auth.dataKey ? '(loaded)' : '(not loaded)'}`) + + logger.info('\nSyncing vault...') + { + const restore = suppressLogs() + try { + await vault.sync() + } finally { + restore() + } + } + + const summary = vault.getSummary() + logger.info(` Records: ${summary.recordCount}`) + logger.info(` Shared Folders: ${summary.sharedFolderCount}`) + logger.info(` Teams: ${summary.teamCount}`) + logger.info(` Folders: ${summary.folderCount}`) + + logger.info('\nLogin successful. Session is active.') } finally { - restore() + cleanup(vault) } - - const auth = vault.getAuth() - - logger.info('--- Session Info ---') - logger.info(` Username: ${auth.username}`) - logger.info(` Session Token: ${auth.sessionToken ? '(active)' : '(none)'}`) - logger.info(` Data Key: ${auth.dataKey ? '(loaded)' : '(not loaded)'}`) - - logger.info('\nSyncing vault...') - restore = suppressLogs() - try { - await vault.sync() - } finally { - restore() - } - - const summary = vault.getSummary() - logger.info(` Records: ${summary.recordCount}`) - logger.info(` Shared Folders: ${summary.sharedFolderCount}`) - logger.info(` Teams: ${summary.teamCount}`) - logger.info(` Folders: ${summary.folderCount}`) - - logger.info('\nLogin successful. Session is active.') - - restore = suppressLogs() - try { - vault.getAuth().disconnect() - } catch { /* ignore */ } - restore() } -main() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(main) diff --git a/examples/sdk_example/src/records/add_record.ts b/examples/sdk_example/src/records/add_record.ts index 4c0b401..38e2e2a 100644 --- a/examples/sdk_example/src/records/add_record.ts +++ b/examples/sdk_example/src/records/add_record.ts @@ -1,4 +1,5 @@ -import { login, cleanup, suppressLogs, formatRecord, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' +import { login, cleanup, suppressLogs, formatRecord, getRecordTitle, logger } from 'keeper-sdk' +import { runExample } from '../utils/runner' async function addRecord() { const vault = await login() @@ -7,19 +8,27 @@ async function addRecord() { logger.info('Adding new record to vault...') logger.info('-'.repeat(50)) - const result = await vault.addRecord({ - version: 3, - data: { - type: 'login', - title: 'Example SDK Record', - fields: [ - { type: 'login', value: ['userSDK@example.com'] }, - { type: 'password', value: ['SecureSDKPassword123!'] }, - { type: 'url', value: ['https://SDKexample.com'] }, - ], - notes: 'This is an example record created using the Keeper SDK', - }, - }) + let result + { + const restore = suppressLogs() + try { + result = await vault.addRecord({ + version: 3, + data: { + type: 'login', + title: 'Example SDK Record', + fields: [ + { type: 'login', value: ['userSDK@example.com'] }, + { type: 'password', value: ['SecureSDKPassword123!'] }, + { type: 'url', value: ['https://SDKexample.com'] }, + ], + notes: 'This is an example record created using the Keeper SDK', + }, + }) + } finally { + restore() + } + } if (result.success) { logger.info('Successfully added record!') @@ -42,13 +51,8 @@ async function addRecord() { logger.error(`Error adding record: ${result.status}`) } } finally { - await cleanup(vault) + cleanup(vault) } } -addRecord() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(addRecord) diff --git a/examples/sdk_example/src/records/delete_record.ts b/examples/sdk_example/src/records/delete_record.ts index af5e608..b16c08e 100644 --- a/examples/sdk_example/src/records/delete_record.ts +++ b/examples/sdk_example/src/records/delete_record.ts @@ -1,4 +1,5 @@ -import { login, cleanup, prompt, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' +import { login, cleanup, prompt, getRecordTitle, logger } from 'keeper-sdk' +import { runExample } from '../utils/runner' async function deleteRecord() { const vault = await login() @@ -17,9 +18,9 @@ async function deleteRecord() { } const title = getRecordTitle(record) - const confirm = await prompt(`\nAre you sure you want to delete "${title}" (${record.uid})? [y/N]: `) + const answer = await prompt(`\nAre you sure you want to delete "${title}" (${record.uid})? [y/N]: `) - if (confirm.toLowerCase() !== 'y') { + if (answer.toLowerCase() !== 'y') { logger.info('Delete cancelled.') return } @@ -33,13 +34,8 @@ async function deleteRecord() { logger.error(`Failed to delete record: ${result.message || 'Unknown error'}`) } } finally { - await cleanup(vault) + cleanup(vault) } } -deleteRecord() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(deleteRecord) diff --git a/examples/sdk_example/src/records/find_password.ts b/examples/sdk_example/src/records/find_password.ts index 61874f0..5d16d49 100644 --- a/examples/sdk_example/src/records/find_password.ts +++ b/examples/sdk_example/src/records/find_password.ts @@ -1,17 +1,22 @@ -import { execSync } from 'child_process' +import { execFileSync } from 'child_process' import { login, cleanup, prompt, getRecordTitle, getRecordPassword, logger, extractErrorMessage } from 'keeper-sdk' +import { runExample } from '../utils/runner' + +const CLIPBOARD_TIMEOUT_MS = 3000 function copyToClipboard(text: string): boolean { + const opts = { input: text, timeout: CLIPBOARD_TIMEOUT_MS } try { if (process.platform === 'darwin') { - execSync('pbcopy', { input: text }) + execFileSync('pbcopy', [], opts) } else if (process.platform === 'win32') { - execSync('clip', { input: text }) + execFileSync('clip', [], opts) } else { - execSync('xclip -selection clipboard', { input: text }) + execFileSync('xclip', ['-selection', 'clipboard'], opts) } return true - } catch { + } catch (err) { + logger.debug('Clipboard copy failed:', extractErrorMessage(err)) return false } } @@ -45,13 +50,8 @@ async function findPassword() { logger.error('Failed to copy to clipboard.') } } finally { - await cleanup(vault) + cleanup(vault) } } -findPassword() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(findPassword) diff --git a/examples/sdk_example/src/records/get_record.ts b/examples/sdk_example/src/records/get_record.ts index ab8e149..a37b39a 100644 --- a/examples/sdk_example/src/records/get_record.ts +++ b/examples/sdk_example/src/records/get_record.ts @@ -9,8 +9,9 @@ import { getRecordPassword, getRecordUrl, logger, - extractErrorMessage, } from 'keeper-sdk' +import { runExample } from '../utils/runner' +import { formatFieldValue, LEGACY_RECORD_MAX_VERSION } from '../utils/format' async function getRecord() { const vault = await login() @@ -23,11 +24,11 @@ async function getRecord() { return } - let searchInput = await prompt('Enter record UID or title: ') + const searchInput = await prompt('Enter record UID or title: ') if (!searchInput) { - searchInput = records[0].uid - logger.info(`No input provided. Using first record: ${searchInput}`) + logger.info('No input provided. Exiting.') + return } const record = vault.findRecord(searchInput) @@ -50,77 +51,68 @@ async function getRecord() { logger.info(` Revision: ${record.revision}`) logger.info(` Shared: ${record.shared}`) - if (version <= 2) { - logger.info(` Type: password (legacy v${version})`) - const loginVal = getRecordLogin(record) - const password = getRecordPassword(record) - const url = getRecordUrl(record) - - if (loginVal) logger.info(` Login: ${loginVal}`) - if (password) logger.info(` Password: ${'*'.repeat(password.length)}`) - if (url) logger.info(` URL: ${url}`) - - const data = record.data - if (data?.notes) logger.info(` Notes: ${data.notes}`) - - if (data?.custom && Array.isArray(data.custom)) { - logger.info('\n Custom Fields:') - for (const cf of data.custom) { - logger.info(` ${cf.name || cf.type || 'custom'}: ${cf.value}`) - } - } + if (version <= LEGACY_RECORD_MAX_VERSION) { + displayLegacyRecord(record) } else { - logger.info(` Type: ${type} (v${version})`) - - const fields = getRecordFields(record) - if (fields.length > 0) { - logger.info('\n Fields:') - for (const field of fields) { - const label = field.label || field.type - let displayValue: string - - if (field.type === 'password') { - const pw = field.value[0] - displayValue = pw ? '*'.repeat(String(pw).length) : '(empty)' - } else if (field.type === 'fileRef') { - displayValue = `[${field.value.length} file(s)]` - } else { - displayValue = field.value - .map((v: any) => { - if (typeof v === 'string') return v - if (v && typeof v === 'object') return JSON.stringify(v) - return String(v) - }) - .filter(Boolean) - .join(', ') || '(empty)' - } - - logger.info(` ${label}: ${displayValue}`) - } - } - - if (record.data?.notes) { - logger.info(`\n Notes: ${record.data.notes}`) - } - } - - const meta = vault.getRecordMetadataByUid(record.uid) - if (meta) { - logger.info('\n Permissions:') - logger.info(` Owner: ${meta.owner}`) - logger.info(` Can Share: ${meta.canShare}`) - logger.info(` Can Edit: ${meta.canEdit}`) + displayTypedRecord(record, type) } + displayMetadata(vault, record.uid) logger.info('-'.repeat(50)) } finally { - await cleanup(vault) + cleanup(vault) + } +} + +function displayLegacyRecord(record: { data?: Record }): void { + const version = (record as { version: number }).version + logger.info(` Type: password (legacy v${version})`) + const loginVal = getRecordLogin(record as Parameters[0]) + const password = getRecordPassword(record as Parameters[0]) + const url = getRecordUrl(record as Parameters[0]) + + if (loginVal) logger.info(` Login: ${loginVal}`) + if (password) logger.info(` Password: ${'*'.repeat(password.length)}`) + if (url) logger.info(` URL: ${url}`) + + const data = record.data as Record | undefined + if (data?.notes) logger.info(` Notes: ${data.notes}`) + + if (data?.custom && Array.isArray(data.custom)) { + logger.info('\n Custom Fields:') + for (const cf of data.custom) { + const entry = cf as { name?: string; type?: string; value?: string } + logger.info(` ${entry.name || entry.type || 'custom'}: ${entry.value}`) + } + } +} + +function displayTypedRecord(record: Parameters[0], type: string): void { + logger.info(` Type: ${type} (v${(record as { version: number }).version})`) + + const fields = getRecordFields(record) + if (fields.length > 0) { + logger.info('\n Fields:') + for (const field of fields) { + const label = field.label || field.type + logger.info(` ${label}: ${formatFieldValue(field)}`) + } + } + + const data = (record as { data?: { notes?: string } }).data + if (data?.notes) { + logger.info(`\n Notes: ${data.notes}`) + } +} + +function displayMetadata(vault: { getRecordMetadataByUid: (uid: string) => { owner: boolean; canShare: boolean; canEdit: boolean } | undefined }, uid: string): void { + const meta = vault.getRecordMetadataByUid(uid) + if (meta) { + logger.info('\n Permissions:') + logger.info(` Owner: ${meta.owner}`) + logger.info(` Can Share: ${meta.canShare}`) + logger.info(` Can Edit: ${meta.canEdit}`) } } -getRecord() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(getRecord) diff --git a/examples/sdk_example/src/records/list_records.ts b/examples/sdk_example/src/records/list_records.ts index 149a455..2ce95c9 100644 --- a/examples/sdk_example/src/records/list_records.ts +++ b/examples/sdk_example/src/records/list_records.ts @@ -1,4 +1,5 @@ -import { login, cleanup, formatRecord, logger, extractErrorMessage } from 'keeper-sdk' +import { login, cleanup, formatRecord, logger } from 'keeper-sdk' +import { runExample } from '../utils/runner' async function listRecords() { const vault = await login() @@ -17,13 +18,8 @@ async function listRecords() { } logger.info('-'.repeat(50)) } finally { - await cleanup(vault) + cleanup(vault) } } -listRecords() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(listRecords) diff --git a/examples/sdk_example/src/records/move_record.ts b/examples/sdk_example/src/records/move_record.ts index c0b6e6c..55beb71 100644 --- a/examples/sdk_example/src/records/move_record.ts +++ b/examples/sdk_example/src/records/move_record.ts @@ -1,4 +1,5 @@ -import { login, cleanup, prompt, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' +import { login, cleanup, suppressLogs, prompt, getRecordTitle, logger } from 'keeper-sdk' +import { runExample } from '../utils/runner' async function moveRecord() { const vault = await login() @@ -38,10 +39,18 @@ async function moveRecord() { logger.info(`\nMoving "${title}" to ${dstFolderUid || '(root)'}...`) - const result = await vault.moveRecord({ - recordUid: record.uid, - dstFolderUid: dstFolderUid || '', - }) + let result + { + const restore = suppressLogs() + try { + result = await vault.moveRecord({ + recordUid: record.uid, + dstFolderUid: dstFolderUid || '', + }) + } finally { + restore() + } + } if (result.success) { logger.info(`Record "${title}" moved successfully.`) @@ -49,13 +58,8 @@ async function moveRecord() { logger.error(`Failed to move record: ${result.message}`) } } finally { - await cleanup(vault) + cleanup(vault) } } -moveRecord() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(moveRecord) diff --git a/examples/sdk_example/src/records/record_history.ts b/examples/sdk_example/src/records/record_history.ts index 4ab0fb3..948ee9a 100644 --- a/examples/sdk_example/src/records/record_history.ts +++ b/examples/sdk_example/src/records/record_history.ts @@ -1,5 +1,7 @@ -import { login, cleanup, prompt, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' +import { login, cleanup, prompt, getRecordTitle, logger } from 'keeper-sdk' import type { HistoryEntry } from 'keeper-sdk' +import { runExample } from '../utils/runner' +import { padRight, formatFieldValue } from '../utils/format' async function recordHistory() { const vault = await login() @@ -66,7 +68,7 @@ async function recordHistory() { displayRevision(rev, `V.${revNum}`) } finally { - await cleanup(vault) + cleanup(vault) } } @@ -87,18 +89,7 @@ function displayRevision(entry: HistoryEntry, label: string) { for (const field of fields) { const fieldLabel = field.label || field.type const values = Array.isArray(field.value) ? field.value : [field.value] - let displayVal: string - - if (field.type === 'password') { - displayVal = values[0] ? '*'.repeat(String(values[0]).length) : '(empty)' - } else { - displayVal = values - .map((v: any) => (typeof v === 'string' ? v : JSON.stringify(v))) - .filter(Boolean) - .join(', ') || '(empty)' - } - - logger.info(` ${fieldLabel}: ${displayVal}`) + logger.info(` ${fieldLabel}: ${formatFieldValue({ type: field.type, value: values })}`) } if (entry.data.notes) { @@ -113,13 +104,4 @@ function displayRevision(entry: HistoryEntry, label: string) { logger.info('-'.repeat(50)) } -function padRight(str: string, len: number): string { - return str.length >= len ? str.substring(0, len) : str + ' '.repeat(len - str.length) -} - -recordHistory() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(recordHistory) diff --git a/examples/sdk_example/src/records/update_record.ts b/examples/sdk_example/src/records/update_record.ts index 9ada134..ed44e7e 100644 --- a/examples/sdk_example/src/records/update_record.ts +++ b/examples/sdk_example/src/records/update_record.ts @@ -1,14 +1,21 @@ import { login, cleanup, + suppressLogs, prompt, getRecordTitle, getRecordType, getRecordFields, logger, - extractErrorMessage, } from 'keeper-sdk' import type { TypedRecordData, RecordFieldInput } from 'keeper-sdk' +import { runExample } from '../utils/runner' + +const LEGACY_TYPE_MAPPING: Record = { legacy: 'login' } + +function normalizeRecordType(type: string): string { + return LEGACY_TYPE_MAPPING[type] || type +} async function updateRecord() { const vault = await login() @@ -57,13 +64,21 @@ async function updateRecord() { } const updateData: TypedRecordData = { - type: currentType === 'legacy' ? 'login' : currentType, + type: normalizeRecordType(currentType), title: newTitle, fields: newFields, } logger.info('\nUpdating record...') - const result = await vault.updateRecord(record.uid, updateData) + let result + { + const restore = suppressLogs() + try { + result = await vault.updateRecord(record.uid, updateData) + } finally { + restore() + } + } if (result.success) { logger.info('Record updated successfully!') @@ -73,13 +88,8 @@ async function updateRecord() { logger.error(`Failed to update record: ${result.status}`) } } finally { - await cleanup(vault) + cleanup(vault) } } -updateRecord() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(updateRecord) diff --git a/examples/sdk_example/src/sharing/share_record.ts b/examples/sdk_example/src/sharing/share_record.ts index b03f4df..97f1a43 100644 --- a/examples/sdk_example/src/sharing/share_record.ts +++ b/examples/sdk_example/src/sharing/share_record.ts @@ -1,4 +1,5 @@ -import { login, cleanup, prompt, getRecordTitle, logger, extractErrorMessage } from 'keeper-sdk' +import { login, cleanup, suppressLogs, prompt, getRecordTitle, logger } from 'keeper-sdk' +import { runExample } from '../utils/runner' async function shareRecordExample() { const vault = await login() @@ -35,12 +36,20 @@ async function shareRecordExample() { logger.info(` Can Edit: ${canEdit}`) logger.info(` Can Share: ${canShare}`) - const result = await vault.shareRecord({ - recordUid: record.uid, - email, - canEdit, - canShare, - }) + let result + { + const restore = suppressLogs() + try { + result = await vault.shareRecord({ + recordUid: record.uid, + email, + canEdit, + canShare, + }) + } finally { + restore() + } + } if (result.success) { logger.info(`\nRecord "${title}" shared with ${email} successfully.`) @@ -50,13 +59,8 @@ async function shareRecordExample() { logger.error(`Status: ${result.status}`) } } finally { - await cleanup(vault) + cleanup(vault) } } -shareRecordExample() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) +runExample(shareRecordExample) diff --git a/examples/sdk_example/src/sharing/share_report.ts b/examples/sdk_example/src/sharing/share_report.ts deleted file mode 100644 index dd5b2a0..0000000 --- a/examples/sdk_example/src/sharing/share_report.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { login, cleanup, logger, extractErrorMessage } from 'keeper-sdk' - -function padRight(str: string, len: number): string { - if (str.length >= len) return str.substring(0, len) - return str + ' '.repeat(len - str.length) -} - -async function shareReport() { - const vault = await login() - - try { - logger.info('=== Shared Records Report ===\n') - - const recordsReport = vault.getSharedRecordsReport() - if (recordsReport.length === 0) { - logger.info('No shared records found.\n') - } else { - logger.info( - padRight('Record UID', 24) + - padRight('Title', 30) + - padRight('Owner', 30) + - 'Shared With' - ) - logger.info('-'.repeat(100)) - - for (const entry of recordsReport) { - logger.info( - padRight(entry.recordUid, 24) + - padRight(entry.recordTitle, 30) + - padRight(entry.recordOwner, 30) + - String(entry.sharedWithCount) - ) - } - logger.info('') - } - - logger.info('=== Shared Folders Report ===\n') - - const foldersReport = vault.getSharedFoldersReport() - if (foldersReport.length === 0) { - logger.info('No shared folders found.\n') - } else { - logger.info( - padRight('Folder UID', 24) + - padRight('Folder Name', 25) + - padRight('Shared To', 30) + - 'Permissions' - ) - logger.info('-'.repeat(100)) - - for (const entry of foldersReport) { - logger.info( - padRight(entry.folderUid, 24) + - padRight(entry.folderName, 25) + - padRight(entry.sharedTo, 30) + - entry.permissions - ) - } - logger.info('') - } - - logger.info('=== Share Summary Report ===\n') - - const summaryReport = vault.getShareSummaryReport() - if (summaryReport.length === 0) { - logger.info('No shares found.\n') - } else { - logger.info( - padRight('Shared To', 40) + - padRight('Records', 12) + - 'Shared Folders' - ) - logger.info('-'.repeat(66)) - - for (const entry of summaryReport) { - logger.info( - padRight(entry.sharedTo, 40) + - padRight(String(entry.recordCount), 12) + - String(entry.sharedFolderCount) - ) - } - logger.info('') - } - } finally { - await cleanup(vault) - } -} - -shareReport() - .then(() => process.exit(0)) - .catch((err) => { - logger.error('Error:', extractErrorMessage(err)) - process.exit(1) - }) diff --git a/examples/sdk_example/src/utils/format.ts b/examples/sdk_example/src/utils/format.ts new file mode 100644 index 0000000..ffbee66 --- /dev/null +++ b/examples/sdk_example/src/utils/format.ts @@ -0,0 +1,28 @@ +export function padRight(str: string, len: number): string { + if (str.length > len) { + return len > 1 ? str.substring(0, len - 1) + '\u2026' : str.substring(0, len) + } + return str.length === len ? str : str + ' '.repeat(len - str.length) +} + +export function formatFieldValue(field: { type: string; value: unknown[] }): string { + if (field.type === 'password') { + const pw = field.value[0] + return pw ? '*'.repeat(String(pw).length) : '(empty)' + } + + if (field.type === 'fileRef') { + return `[${field.value.length} file(s)]` + } + + return field.value + .map((v: unknown) => { + if (typeof v === 'string') return v + if (v && typeof v === 'object') return JSON.stringify(v) + return String(v) + }) + .filter(Boolean) + .join(', ') || '(empty)' +} + +export const LEGACY_RECORD_MAX_VERSION = 2 diff --git a/examples/sdk_example/src/utils/runner.ts b/examples/sdk_example/src/utils/runner.ts new file mode 100644 index 0000000..aa8acfd --- /dev/null +++ b/examples/sdk_example/src/utils/runner.ts @@ -0,0 +1,9 @@ +import { logger, extractErrorMessage } from 'keeper-sdk' + +export function runExample(fn: () => Promise): void { + fn() + .catch((err) => { + logger.error('Error:', extractErrorMessage(err)) + process.exitCode = 1 + }) +} From 9cc05baed384cd7ce54477582f74c44c6dcefeb2 Mon Sep 17 00:00:00 2001 From: ukumar-ks Date: Wed, 8 Apr 2026 15:52:00 +0530 Subject: [PATCH 3/8] Added readme file for examples --- examples/sdk_example/README.md | 65 ++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 examples/sdk_example/README.md diff --git a/examples/sdk_example/README.md b/examples/sdk_example/README.md new file mode 100644 index 0000000..94769d3 --- /dev/null +++ b/examples/sdk_example/README.md @@ -0,0 +1,65 @@ +# Keeper SDK Examples + +Interactive examples demonstrating the Keeper JavaScript SDK. + +## Prerequisites + +- Node.js 16+ +- A Keeper account with credentials + +## Setup + +```bash +# From the repository root +cd examples/sdk_example + +# Install dependencies +npm install + +# Link the local SDK (if developing against the local KeeperSdk) +npm run link-local +``` + +## Configuration + +Examples use `~/.keeper/config.json` for saved credentials and persistent login. If the file is not found, you will be prompted for server, username, and password. + +## Available Examples + +### Authentication + +| Command | Description | +|---|---| +| `npm run auth:login` | Password-based login with vault sync | +| `npm run auth:session-token` | Login using an existing session token | + +### Records + +| Command | Description | +|---|---| +| `npm run records:list` | List all records in the vault | +| `npm run records:get` | Get details of a specific record by UID or title | +| `npm run records:add` | Add a new typed record to the vault | +| `npm run records:update` | Update fields on an existing record | +| `npm run records:delete` | Delete a record (with confirmation prompt) | +| `npm run records:history` | View revision history for a record | +| `npm run records:find-password` | Find a record's password and copy it to clipboard | +| `npm run records:move` | Move a record to a different folder | + +### Sharing + +| Command | Description | +|---|---| +| `npm run sharing:share-record` | Share a record with another Keeper user | + +## Usage + +Run any example with `npm run