diff --git a/KeeperSdk/package.json b/KeeperSdk/package.json new file mode 100644 index 0000000..a7e57d2 --- /dev/null +++ b/KeeperSdk/package.json @@ -0,0 +1,22 @@ +{ + "name": "keeper-sdk", + "version": "1.0.0", + "description": "High-level wrapper for Keeper Security JavaScript SDK", + "main": "src/index.ts", + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "link-local": "npm link ../keeperapi", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "jest", + "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..a7b2905 --- /dev/null +++ b/KeeperSdk/src/auth/ConsoleAuthUI.ts @@ -0,0 +1,157 @@ +import readline from 'readline/promises' +import { setTimeout as delay } from 'timers/promises' +import type { AuthUI3, DeviceApprovalChannel, TwoFactorChannelData } from '@keeper-security/keeperapi' +import { Authentication } from '@keeper-security/keeperapi' +import { logger, extractErrorMessage, KeeperSdkError, AuthDefaults, ResultCodes } from '../utils' + +export class ConsoleAuthUI implements AuthUI3 { + private static readonly DEVICE_VERIFICATION = { + Email: 0, + KeeperPush: 1, + TFA: 2, + AdminApproval: 3, + } as const + + private static channelName(channel: number): string { + switch (channel) { + case ConsoleAuthUI.DEVICE_VERIFICATION.Email: + return 'Email Verification' + case ConsoleAuthUI.DEVICE_VERIFICATION.KeeperPush: + return 'Keeper Push' + case ConsoleAuthUI.DEVICE_VERIFICATION.TFA: + return 'Two-Factor Authentication' + case ConsoleAuthUI.DEVICE_VERIFICATION.AdminApproval: + return 'Admin Approval' + default: + return `Channel ${channel}` + } + } + + private static 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: + throw new KeeperSdkError(`Unsupported 2FA channel type: ${channelType}`, ResultCodes.UNSUPPORTED_2FA_CHANNEL) + } + } + + private static async waitWithCancel(timeoutMs: number, cancel?: Promise): Promise { + if (!cancel) { + await delay(timeoutMs) + return + } + await Promise.race([delay(timeoutMs), cancel]) + } + + 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}. ${ConsoleAuthUI.channelName(ch.channel)}`) + }) + + const choice = (await rl.question('\nSelect method (number): ')).trim() + 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 ${ConsoleAuthUI.channelName(selected.channel)} request...`) + await selected.sendApprovalRequest() + + if (selected.validateCode) { + const code = (await rl.question('Enter verification code: ')).trim() + if (!code) return false + await selected.validateCode(code) + } else { + logger.info('Approval request sent. Waiting for approval on your other device...') + await ConsoleAuthUI.waitWithCancel(AuthDefaults.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 = ConsoleAuthUI.twoFactorChannelName(ch.channel.channelType!) + logger.info(` ${i + 1}. ${name}`) + }) + + const choice = (await rl.question('\nSelect method (number): ')).trim() + 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 = ConsoleAuthUI.twoFactorChannelName(selected.channel.channelType!) + + if (selected.availablePushes && selected.availablePushes.length > 0) { + const pushChoice = (await rl.question(`Send push notification for ${name}? (y/n): `)).trim() + if (pushChoice.toLowerCase() === 'y' && selected.sendPush) { + selected.sendPush(selected.availablePushes[0]) + logger.info('Push sent. Waiting for approval...') + await ConsoleAuthUI.waitWithCancel(AuthDefaults.APPROVAL_TIMEOUT_MS, cancel) + return true + } + } + + const code = (await rl.question(`Enter ${name} code: `)).trim() + if (!code) return false + + selected.sendCode(code) + await ConsoleAuthUI.waitWithCancel(AuthDefaults.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 rl.question(`Enter your ${label}: `)).trim() + } finally { + rl.close() + } + } +} diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts new file mode 100644 index 0000000..bd7ab51 --- /dev/null +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -0,0 +1,301 @@ +import readline from 'readline/promises' +import { KeeperVault } from '../vault/KeeperVault' +import { + logger, + extractResultCode, + extractErrorMessage, + KeeperSdkError, + SdkDefaults, + AuthDefaults, + ResultCodes, + KEEPER_PUBLIC_HOSTS, +} from '../utils' +import { FileConfigLoader } from './SessionManager' +import type { KeeperJsonConfig } from './SessionManager' + +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 + +enum CliCharAction { + Submit, + Cancel, + Backspace, + Append, +} + +function classifyInputChar(ch: string): CliCharAction { + if (ch === '\n' || ch === '\r') return CliCharAction.Submit + if (ch === '\u0003') return CliCharAction.Cancel + if (ch === '\u007F' || ch === '\b') return CliCharAction.Backspace + return CliCharAction.Append +} + +class ReadlineManager { + 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 + } + + public async question(query: string): Promise { + const rl = this.getOrCreate() + const answer = await rl.question(query) + return answer.trim() + } + + public close(): void { + if (this.rl) { + this.rl.close() + this.rl = null + } + } +} + +function getReadlineManager(): ReadlineManager { + if (!rlManager) { + rlManager = new ReadlineManager() + } + return rlManager +} + +export function prompt(question: string, masked = false): Promise { + const mgr = getReadlineManager() + if (!masked) { + return mgr.question(question) + } + + return new Promise((resolve, reject) => { + 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) + } + + const onData = (str: string) => { + for (const ch of str) { + switch (classifyInputChar(ch)) { + case CliCharAction.Submit: + exitRawMode() + resolve(buf.trim()) + return + case CliCharAction.Cancel: + exitRawMode() + reject(new KeeperSdkError('Operation cancelled by user.', ResultCodes.USER_CANCELLED)) + return + case CliCharAction.Backspace: + if (buf.length > 0) { + buf = buf.slice(0, -1) + process.stdout.write('\b \b') + } + break + case CliCharAction.Append: + buf += ch + process.stdout.write('*') + break + } + } + } + + process.stdin.on('data', onData) + }) +} + +export async function loadKeeperConfig(preloaded?: KeeperJsonConfig): Promise { + if (preloaded) return preloaded + return defaultConfigLoader.load() +} + +export async function resolveServer(username?: string, preloadedConfig?: KeeperJsonConfig): Promise { + const config = await loadKeeperConfig(preloadedConfig) + 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 { + 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 () => { + 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() + } +} + +export async function login(): Promise { + const config = await 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 master password for ${defaultUsername}`) + username = defaultUsername + } else { + username = await prompt('Username (email): ') + } + + if (!username) { + throw new KeeperSdkError('Username is required.', ResultCodes.MISSING_USERNAME) + } + + const resolvedHost = host || await resolveServer(username, config) + return await interactiveLogin(resolvedHost, username) +} + +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 + } +} + +async function interactiveLogin(host: string, username: string): Promise { + const vault = new KeeperVault({ host, clientVersion: SdkDefaults.CLIENT_VERSION }) + + for (let attempt = 1; attempt <= AuthDefaults.MAX_LOGIN_ATTEMPTS; attempt++) { + const password = await prompt('Password: ', true) + + if (!password) { + throw new KeeperSdkError('Password is required.', ResultCodes.MISSING_PASSWORD) + } + + try { + await withSuppressedOutput(() => vault.login(username, password)) + logger.info('Successfully authenticated with Master Password\n') + return await syncVault(vault) + } catch (err) { + const resultCode = extractResultCode(err) + if (resultCode === ResultCodes.INVALID_CREDENTIALS) { + const remaining = AuthDefaults.MAX_LOGIN_ATTEMPTS - attempt + if (remaining > 0) { + logger.warn(`Invalid credentials (${remaining} attempt${remaining === 1 ? '' : 's'} remaining)`) + continue + } + throw new KeeperSdkError( + `Maximum login attempts (${AuthDefaults.MAX_LOGIN_ATTEMPTS}) exceeded.`, + ResultCodes.MAX_ATTEMPTS_EXCEEDED + ) + } + throw KeeperSdkError.from(err) + } + } + + throw new KeeperSdkError( + `Maximum login attempts (${AuthDefaults.MAX_LOGIN_ATTEMPTS}) exceeded.`, + ResultCodes.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 function cleanup(vault: KeeperVault): void { + vault.disconnect() + getReadlineManager().close() +} diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts new file mode 100644 index 0000000..b6bc933 --- /dev/null +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -0,0 +1,277 @@ +import fs from 'fs/promises' +import path from 'path' +import os from 'os' +import type { DeviceConfig, SessionStorage, KeeperHost, SessionParams } from '@keeper-security/keeperapi' +import { logger, extractErrorMessage, SdkDefaults } from '../utils' + +export type ConfigurationUser = { + user?: string + server?: string + last_device?: { device_token?: string } +} + +export type ConfigurationServerConfig = { + server?: string + clone_code?: string +} + +export type ConfigurationDeviceConfig = { + device_token?: string + private_key?: string + server_info?: Array +} + +export type KeeperJsonConfig = { + last_login?: string + last_server?: string + user?: string + server?: string + device_token?: string + private_key?: string + clone_code?: string + users?: Array + devices?: Array +} + +type ResolvedDevice = { + deviceToken: Buffer + privateKey: Buffer + serverInfo: Array> +} + +export interface ConfigLoader { + load(): Promise + save(config: KeeperJsonConfig): Promise + readonly configDir: string +} + +export class FileConfigLoader implements ConfigLoader { + public readonly configDir: string + + constructor(configDir?: string) { + this.configDir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) + } + + async load(): Promise { + const configPath = path.join(this.configDir, 'config.json') + try { + const content = await fs.readFile(configPath, 'utf-8') + const parsed: unknown = JSON.parse(content) + if (SessionManager.isValidKeeperConfig(parsed)) { + return parsed + } + } catch (err) { + logger.debug('Failed to load keeper config:', extractErrorMessage(err)) + } + return {} + } + + async save(config: KeeperJsonConfig): Promise { + const configPath = path.join(this.configDir, 'config.json') + await fs.writeFile(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }) + } +} + +export class SessionManager implements SessionStorage { + private readonly configLoader: ConfigLoader + private sessionParams: 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) + 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 + } + } + + public get configDir(): string { + return this.configLoader.configDir + } + + public get lastUsername(): string | undefined { + return this._lastUsername + } + + public async getLastUsername(): Promise { + if (this._lastUsername) return this._lastUsername + const kc = await this.loadKeeperConfig() + return kc.last_login || kc.user || undefined + } + + public async getDeviceConfig(host: string): Promise { + const username = await this.getLastUsername() + if (username) { + const device = await 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.sessionDevices.set(host, { ...deviceConfig }) + } + } + + private cloneCodeKey(host: KeeperHost, username: string): string { + return `${host}::${username}` + } + + public async getCloneCode(host: KeeperHost, username: string): Promise { + const hostStr = String(host) + + const key = this.cloneCodeKey(host, username) + const sessionCode = this.sessionCloneCodes.get(key) + if (sessionCode) return sessionCode + + const device = await 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.sessionCloneCodes.set(key, cloneCode) + await this.updateKeeperConfigCloneCode(String(host), username, cloneCode) + } + + private async updateKeeperConfigCloneCode(host: string, username: string, cloneCode: Uint8Array): Promise { + try { + const parsed = await 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) { + await 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 { + return this.sessionParams + } + + public async saveSessionParameters(params: Partial): Promise { + this.sessionParams = { ...this.sessionParams, ...params } as SessionParams + if (params.username) { + this._lastUsername = params.username + } + } + + public setLastUsername(username: string): void { + this._lastUsername = username + } + + private async loadKeeperConfig(): Promise { + if (this._keeperConfig) return this._keeperConfig + this._keeperConfig = await this.configLoader.load() + return this._keeperConfig + } + + private async findDeviceInKeeperConfig(username: string): Promise { + const normalizedUsername = username.toLowerCase() + if (this._deviceCache?.username === normalizedUsername) { + return this._deviceCache.device + } + + const device = await this.lookupDeviceInKeeperConfig(normalizedUsername) + this._deviceCache = { username: normalizedUsername, device } + return device + } + + private async lookupDeviceInKeeperConfig(normalizedUsername: string): Promise { + const kc = await this.loadKeeperConfig() + + if (kc.device_token && kc.private_key && kc.user?.toLowerCase() === normalizedUsername) { + const serverInfo: Array> = [] + 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 Required => + !!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 new file mode 100644 index 0000000..440874f --- /dev/null +++ b/KeeperSdk/src/index.ts @@ -0,0 +1,89 @@ +export { ConsoleAuthUI } from './auth/ConsoleAuthUI' +export { SessionManager, FileConfigLoader } from './auth/SessionManager' +export type { + KeeperJsonConfig, + ConfigLoader, + ConfigurationUser, + ConfigurationServerConfig, + ConfigurationDeviceConfig, +} from './auth/SessionManager' +export { + login, + cleanup, + prompt, + suppressLogs, + loadKeeperConfig, + resolveServer, +} from './auth/ConsoleLogin' + +export { InMemoryStorage } from './storage/InMemoryStorage' + +export { + Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger, + KeeperSdkError, isKeeperError, extractErrorMessage, extractResultCode, + SdkDefaults, AuthDefaults, ResultCodes, KEEPER_PUBLIC_HOSTS, +} from './utils' +export type { ILogger } from './utils' + +export { + searchRecords, + formatRecord, + 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, + TypedRecordData, + RecordFieldInput, + NewRecordInput, + AddRecordResult, + UpdateRecordResult, + DeleteRecordResult, + HistoryEntry, + RecordHistoryResult, + MoveRecordInput, + MoveRecordResult, +} from './records/RecordOperations' + +export { shareRecord, removeRecordShare } from './sharing/Sharing' +export type { + 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..a4cc219 --- /dev/null +++ b/KeeperSdk/src/records/RecordOperations.ts @@ -0,0 +1,566 @@ +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, logger } from '../utils' +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 + +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.data.title || !input.data.title.trim()) { + throw new KeeperSdkError('Record title is required.', 'missing_record_title') + } + if (input.version === 3 && !input.data.type?.trim()) { + throw new KeeperSdkError('Record type is required for v3 records.', 'missing_record_type') + } + if (input.version === 2) { + return addPasswordRecord(auth, input.data, input.folderUid) + } + return addTypedRecord(auth, input.data, input.folderUid) +} + +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, + } +} + +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 { + if (!data.title || !data.title.trim()) { + throw new KeeperSdkError('Record title is required.', 'missing_record_title') + } + if (!data.type?.trim()) { + throw new KeeperSdkError('Record type is required.', 'missing_record_type') + } + 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, + } +} + +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[] +} + +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 (err) { + logger.debug(`Failed to decrypt history revision ${entry.revision}:`, extractErrorMessage(err)) + 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: '' } + } + + if (storage.getByUid(FolderType.UserFolder, uid)) { + return { uid, folderType: FolderType.UserFolder, scopeUid: '' } + } + + if (storage.getByUid(FolderType.SharedFolder, uid)) { + return { uid, folderType: FolderType.SharedFolder, scopeUid: uid } + } + + const sfFolder = storage.getByUid(FolderType.SharedFolderFolder, 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 } +} + +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.getByUid(ObjectType.Record, 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..464db4d --- /dev/null +++ b/KeeperSdk/src/records/RecordUtils.ts @@ -0,0 +1,189 @@ +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 +} + +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 (_err) { + 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' +} + +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 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 { + login: record.data?.secret1, + password: record.data?.secret2, + url: record.data?.link, + fields, + } + } + + 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 { login, password, url, fields } +} + +export function getRecordPassword(record: DRecord): string | undefined { + return getRecordSummary(record).password +} + +export function getRecordLogin(record: DRecord): string | undefined { + return getRecordSummary(record).login +} + +export function getRecordUrl(record: DRecord): string | 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) => { + let words = wordCache.get(record) + if (!words) { + words = collectRecordWords(record) + wordCache.set(record, words) + } + 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 summary = getRecordSummary(record) + const lines: string[] = [] + + lines.push('-'.repeat(50)) + lines.push(`Title: ${title}`) + lines.push(`Record UID: ${record.uid}`) + lines.push(`Record Type: ${type}`) + + if (summary.login) lines.push(`Username: ${summary.login}`) + if (summary.url) lines.push(`URL: ${summary.url}`) + + if (showDetails) { + 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(', ') + 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..2bd0350 --- /dev/null +++ b/KeeperSdk/src/sharing/Sharing.ts @@ -0,0 +1,208 @@ +import { + Auth, + Records, + Authentication, + platform, + getPublicKeysMessage, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import { extractErrorMessage, KeeperSdkError } from '../utils/errors' + +enum ShareStatus { + Success = 'success', + PendingAccept = 'pending_accept', + MissingPublicKey = 'missing_public_key', + Error = 'error', + Unknown = 'unknown', +} + +const SHARE_UPDATE_PATH = 'vault/records_share_update' + +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 +} + +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) + ) +} + +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..91cfcd1 --- /dev/null +++ b/KeeperSdk/src/storage/InMemoryStorage.ts @@ -0,0 +1,156 @@ +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() + private arrayCache = 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) + this.arrayCache.delete(kind) + } + + 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 + : Buffer.from(uid).toString('base64url') + this.store.get(kind)?.delete(uidStr) + this.arrayCache.delete(kind) + } + + public async clear(): Promise { + this.store.clear() + this.keys.clear() + this.objects.clear() + this.deps.clear() + this.arrayCache.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)! + const seen = new Set(existing.map(d => d.uid)) + for (const child of children) { + if (!seen.has(child.uid)) { + existing.push(child) + seen.add(child.uid) + } + } + } + } + + 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 cached = this.arrayCache.get(kind) + if (cached) return cached as T[] + + const kindMap = this.store.get(kind) + if (!kindMap) return [] + + 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 + 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..de69c69 --- /dev/null +++ b/KeeperSdk/src/utils/Logger.ts @@ -0,0 +1,71 @@ +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4, +} + +export interface ILogger { + debug(...args: unknown[]): void + info(...args: unknown[]): void + warn(...args: unknown[]): void + error(...args: unknown[]): void +} + +export class ConsoleLogger implements ILogger { + 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: unknown[]): void { + if (this.level <= LogLevel.DEBUG) console.debug(...args) + } + + public info(...args: unknown[]): void { + if (this.level <= LogLevel.INFO) console.log(...args) + } + + public warn(...args: unknown[]): void { + if (this.level <= LogLevel.WARN) console.warn(...args) + } + + public error(...args: unknown[]): void { + if (this.level <= LogLevel.ERROR) console.error(...args) + } +} + +let globalLogger: ILogger = new ConsoleLogger() + +export function setLogger(newLogger: ILogger): void { + globalLogger = newLogger +} + +export function getLogger(): ILogger { + return globalLogger +} + +export function resetLogger(level: LogLevel = LogLevel.INFO): ConsoleLogger { + const c = new ConsoleLogger(level) + globalLogger = c + return c +} + +export const logger: ILogger = { + debug: (...args) => globalLogger.debug(...args), + info: (...args) => globalLogger.info(...args), + warn: (...args) => globalLogger.warn(...args), + error: (...args) => globalLogger.error(...args), +} + +export { ConsoleLogger as Logger } diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts new file mode 100644 index 0000000..3748293 --- /dev/null +++ b/KeeperSdk/src/utils/constants.ts @@ -0,0 +1,36 @@ +export const SdkDefaults = { + CLIENT_VERSION: 'c17.0.0', + DEVICE_NAME: 'JavaScript Keeper SDK', + CONFIG_DIR: '.keeper', + LOG_FORMAT: '!', +} as const + +export const AuthDefaults = { + MAX_LOGIN_ATTEMPTS: 5, + APPROVAL_TIMEOUT_MS: 60_000, + CODE_VALIDATION_DELAY_MS: 2_000, +} as const + +export const ResultCodes = { + INVALID_CREDENTIALS: 'invalid_credentials', + MISSING_USERNAME: 'missing_username', + MISSING_PASSWORD: 'missing_password', + MAX_ATTEMPTS_EXCEEDED: 'max_attempts_exceeded', + USER_CANCELLED: 'user_cancelled', + NOT_LOGGED_IN: 'not_logged_in', + DEVICE_NOT_REGISTERED: 'device_not_registered', + NO_PREVIOUS_LOGIN: 'no_previous_login', + NO_CLONE_CODE: 'no_clone_code', + PERSISTENT_LOGIN_FAILED: 'persistent_login_failed', + SESSION_TOKEN_EXPIRED: 'session_token_expired', + UNSUPPORTED_2FA_CHANNEL: 'unsupported_2fa_channel', +} as const + +export const KEEPER_PUBLIC_HOSTS: Record = { + US: 'keepersecurity.com', + EU: 'keepersecurity.eu', + AU: 'keepersecurity.com.au', + CA: 'keepersecurity.ca', + JP: 'keepersecurity.jp', + GOV: 'govcloud.keepersecurity.us', +} diff --git a/KeeperSdk/src/utils/errors.ts b/KeeperSdk/src/utils/errors.ts new file mode 100644 index 0000000..204406e --- /dev/null +++ b/KeeperSdk/src/utils/errors.ts @@ -0,0 +1,71 @@ +import type { KeeperError } from '@keeper-security/keeperapi' + +export function isKeeperError(err: unknown): 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: unknown): string | undefined { + if (isKeeperError(err)) { + return err.result_code || err.error + } + if (err instanceof Error) { + 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 + 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: 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 + 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 { + 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: unknown): 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/utils/index.ts b/KeeperSdk/src/utils/index.ts new file mode 100644 index 0000000..6b234ef --- /dev/null +++ b/KeeperSdk/src/utils/index.ts @@ -0,0 +1,4 @@ +export { SdkDefaults, AuthDefaults, ResultCodes, KEEPER_PUBLIC_HOSTS } from './constants' +export { Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger } from './Logger' +export type { ILogger } from './Logger' +export { KeeperSdkError, isKeeperError, extractErrorMessage, extractResultCode } from './errors' diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts new file mode 100644 index 0000000..fa55f7b --- /dev/null +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -0,0 +1,460 @@ +import { + Auth, + KeeperEnvironment, + syncDown, + DRecord, + DRecordMetadata, + DSharedFolder, + DTeam, + DUserFolder, + Authentication, +} from '@keeper-security/keeperapi' +import type { SyncResult, SyncLogFormat, VaultStorage, SessionStorage, AuthUI3 } from '@keeper-security/keeperapi' +import { InMemoryStorage } from '../storage/InMemoryStorage' +import { SessionManager } from '../auth/SessionManager' +import { ConsoleAuthUI } from '../auth/ConsoleAuthUI' +import { 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 { + shareRecord as shareRecordOp, + removeRecordShare as removeRecordShareOp, +} from '../sharing/Sharing' +import type { + ShareRecordInput, + ShareRecordResult, + RemoveShareInput, + RemoveShareResult, +} from '../sharing/Sharing' +import { ConsoleLogger, LogLevel, KeeperSdkError, extractErrorMessage, SdkDefaults, ResultCodes } from '../utils' +import type { ILogger } from '../utils' + +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 + autoSync?: boolean + storage?: InMemoryStorage + sessionStorage?: SessionManager + authUI?: AuthUI3 +} + +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: AuthUI3 + private readonly config: Required> + private readonly log: ILogger + private synced = false + private batchDepth = 0 + + 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, + autoSync: config?.autoSync !== false, + } + + this.log = new ConsoleLogger(this.config.logLevel) + this.storage = config?.storage || new InMemoryStorage() + this.sessionManager = config?.sessionStorage || new SessionManager(this.config.configDir || undefined) + this.authUI = config?.authUI || new ConsoleAuthUI() + } + + private async createAuth(options?: { useSessionResumption?: boolean }): Promise { + const host = this.config.host + const baseDeviceConfig = await 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, + 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.', ResultCodes.NOT_LOGGED_IN) + } + return this.auth + } + + public async login(username: string, password: string): Promise { + this.auth = await this.createAuth({ useSessionResumption: false }) + 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}`) + } + + public async loginWithSessionToken(username: string, sessionToken: string): Promise { + const deviceConfig = await 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.', + ResultCodes.DEVICE_NOT_REGISTERED + ) + } + + this.auth = await 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.', + ResultCodes.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 { + const username = await this.sessionManager.getLastUsername() + if (!username) { + throw new KeeperSdkError( + 'No previous login found. Perform a normal login first.', + ResultCodes.NO_PREVIOUS_LOGIN + ) + } + + const deviceConfig = await 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.', + ResultCodes.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.', + ResultCodes.NO_CLONE_CODE + ) + } + + this.auth = await this.createAuth({ useSessionResumption: true }) + + await this.auth.loginV3({ + 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.', + ResultCodes.PERSISTENT_LOGIN_FAILED + ) + } + + this.synced = false + this.log.info(`Session resumed for ${username} (persistent login)`) + } + + 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 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.storage.getByUid('record', uid) + } + + public findRecord(uidOrTitle: string): DRecord | undefined { + const byUid = this.getRecordByUid(uidOrTitle) + if (byUid) return byUid + + const needle = uidOrTitle.toLowerCase() + return this.getRecords().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.storage.getByUid('metadata', 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.storage.getCount('record'), + sharedFolderCount: this.storage.getCount('shared_folder'), + teamCount: this.storage.getCount('team'), + folderCount: this.storage.getCount('user_folder'), + } + } + + 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.syncIfNeeded() + 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.syncIfNeeded() + return result + } + + public async deleteRecord(recordUid: string): Promise { + const auth = this.getAuthOrThrow() + const result = await deleteRecordOp(auth, recordUid) + 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.syncIfNeeded() + return result + } + + 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.syncIfNeeded() + return result + } + + public async removeRecordShare(input: RemoveShareInput): Promise { + const auth = this.getAuthOrThrow() + const result = await removeRecordShareOp(auth, input) + if (result.success) await this.syncIfNeeded() + 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 disconnect(): void { + if (this.auth) { + 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 + } + + 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/README.md b/examples/sdk_example/README.md new file mode 100644 index 0000000..98b44ea --- /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` | Master password login with retry logic, masked input, and vault sync. Automatically attempts persistent login (via clone code from `~/.keeper/config.json`) before falling back to the password prompt. | +| `npm run auth:session-token` | Login using an existing session token for pre-authenticated workflows. Prompts for username, host, and 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