diff --git a/KeeperSdk/src/auth/ConsoleLogin.ts b/KeeperSdk/src/auth/ConsoleLogin.ts index 0d57fc57..e0eba8dc 100644 --- a/KeeperSdk/src/auth/ConsoleLogin.ts +++ b/KeeperSdk/src/auth/ConsoleLogin.ts @@ -169,7 +169,7 @@ export async function resolveServer(username?: string, preloadedConfig?: KeeperJ entries.forEach(([region, host], i) => { logger.info(` ${i + 1}. ${region} (${host})`) }) - logger.info(` Or enter a hostname directly (e.g. dev.keepersecurity.com)`) + logger.info(` Or enter server details directly (e.g. dev.keepersecurity.com)`) const choice = await prompt(`Region [1 = ${DEFAULT_REGION}]: `) if (!choice) return KEEPER_PUBLIC_HOSTS[DEFAULT_REGION] diff --git a/KeeperSdk/src/auth/PersistentLogin.ts b/KeeperSdk/src/auth/PersistentLogin.ts new file mode 100644 index 00000000..87b4bf94 --- /dev/null +++ b/KeeperSdk/src/auth/PersistentLogin.ts @@ -0,0 +1,71 @@ +import { + platform, + registerEncryptedDataKeyForDeviceMessage, + setUserSettingMessage, + updateDeviceMessage, + type Auth, + type DeviceConfig, +} from '@keeper-security/keeperapi' +import { extractResultCode } from '../utils' + +export const DEFAULT_PERSISTENT_LOGIN_TIMEOUT_MINUTES: number = 60 + +function ensureDeviceConfig(auth: Auth): DeviceConfig { + const deviceConfig: DeviceConfig = auth.options.deviceConfig + if (!deviceConfig.deviceToken) throw new Error('Device token is missing') + return deviceConfig +} + +async function ensureDevicePublicKey(auth: Auth): Promise { + const deviceConfig = ensureDeviceConfig(auth) + if (deviceConfig.publicKey) return + + const keyPair: { privateKey: Uint8Array; publicKey: Uint8Array } = await platform.generateECKeyPair() + deviceConfig.privateKey = keyPair.privateKey + deviceConfig.publicKey = keyPair.publicKey + + await auth.executeRestAction( + updateDeviceMessage({ + encryptedDeviceToken: deviceConfig.deviceToken, + clientVersion: auth.clientVersion, + deviceName: deviceConfig.deviceName, + devicePublicKey: deviceConfig.publicKey, + }) + ) + + await auth.options.onDeviceConfig?.(deviceConfig, auth.options.host) +} + +async function registerDataKeyForDevice(auth: Auth): Promise { + if (!auth.dataKey) throw new Error('Data Key is missing') + const deviceConfig = ensureDeviceConfig(auth) + if (!deviceConfig.publicKey) throw new Error('Device public key is missing') + + const encryptedDeviceDataKey: Uint8Array = await platform.publicEncryptEC(auth.dataKey, deviceConfig.publicKey) + try { + await auth.executeRestAction( + registerEncryptedDataKeyForDeviceMessage({ + encryptedDeviceToken: deviceConfig.deviceToken, + encryptedDeviceDataKey, + }) + ) + } catch (error) { + if (extractResultCode(error) !== 'device_data_key_exists') throw error + } +} + +export async function setPersistentLogin( + auth: Auth, + enabled: boolean, + logoutTimerMinutes = DEFAULT_PERSISTENT_LOGIN_TIMEOUT_MINUTES +): Promise { + if (enabled) await ensureDevicePublicKey(auth) + + await auth.executeRestAction(setUserSettingMessage({ setting: 'persistent_login', value: enabled ? '1' : '0' })) + if (enabled) { + await registerDataKeyForDevice(auth) + await auth.executeRestAction( + setUserSettingMessage({ setting: 'logout_timer', value: String(logoutTimerMinutes) }) + ) + } +} diff --git a/KeeperSdk/src/auth/SessionManager.ts b/KeeperSdk/src/auth/SessionManager.ts index 3770e86f..c5da0c7d 100644 --- a/KeeperSdk/src/auth/SessionManager.ts +++ b/KeeperSdk/src/auth/SessionManager.ts @@ -8,7 +8,14 @@ import { import { getSdkPlatform } from '../platform' import { logger, extractErrorMessage } from '../utils' import type { Nullable } from '../utils' -import type { ConfigLoader, KeeperJsonConfig, ConfigurationServerConfig, ConfigurationUser } from './config' +import type { + ConfigLoader, + KeeperJsonConfig, + ConfigurationDeviceConfig, + ConfigurationServerConfig, + ConfigurationUser, + ConfigurationServer, +} from './config' import { isValidKeeperConfig } from './config' export type { @@ -16,12 +23,14 @@ export type { ConfigLoader, ConfigurationUser, ConfigurationServerConfig, + ConfigurationServer, ConfigurationDeviceConfig, } from './config' type ResolvedDevice = { deviceToken: Uint8Array privateKey: Uint8Array + publicKey?: Uint8Array serverInfo: Array> } @@ -38,6 +47,7 @@ export class SessionManager implements SessionStorage { private _deviceCache: Nullable = null private sessionDevices = new Map() private sessionCloneCodes = new Map() + private cloneCodeWrite: Promise = Promise.resolve() constructor(configDir?: string) constructor(loader: ConfigLoader) @@ -73,6 +83,7 @@ export class SessionManager implements SessionStorage { return { deviceToken: device.deviceToken, privateKey: device.privateKey, + publicKey: device.publicKey, } } } @@ -97,11 +108,47 @@ export class SessionManager implements SessionStorage { const parsed = await this.configLoader.load() const config: KeeperJsonConfig = parsed && Object.keys(parsed).length > 0 ? parsed : {} + config.last_login = username + config.last_server = host config.device_token = Buffer.from(deviceConfig.deviceToken).toString('base64url') config.private_key = Buffer.from(deviceConfig.privateKey).toString('base64url') config.user = username config.server = host + const users: Array = config.users || [] + let user: ConfigurationUser | undefined = users.find( + (entry: ConfigurationUser) => entry.user?.toLowerCase() === username.toLowerCase() + ) + if (!user) { + user = { user: username } + users.push(user) + } + user.server = host + user.last_device = { device_token: config.device_token } + config.users = users + + const devices: Array = config.devices || [] + let device: ConfigurationDeviceConfig | undefined = devices.find( + (entry: ConfigurationDeviceConfig) => entry.device_token === config.device_token + ) + if (!device) { + device = { device_token: config.device_token } + devices.push(device) + } + device.private_key = config.private_key + if (deviceConfig.publicKey) { + device.public_key = Buffer.from(deviceConfig.publicKey).toString('base64url') + } + device.server_info = device.server_info || [] + config.devices = devices + + const servers: Array = config.servers || [] + if (!servers.some((entry) => entry.server === host)) { + const server: ConfigurationServer = { server: host } + servers.push(server) + } + config.servers = servers + await this.configLoader.save(config) this._keeperConfig = null this._deviceCache = null @@ -135,7 +182,14 @@ export class SessionManager implements SessionStorage { 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) + this.cloneCodeWrite = this.cloneCodeWrite.then(() => + this.updateKeeperConfigCloneCode(String(host), username, cloneCode) + ) + await this.cloneCodeWrite + } + + public async flush(): Promise { + await this.cloneCodeWrite } private async updateKeeperConfigCloneCode(host: string, username: string, cloneCode: Uint8Array): Promise { @@ -225,6 +279,7 @@ export class SessionManager implements SessionStorage { return { deviceToken: normal64Bytes(deviceTokenStr), privateKey: normal64Bytes(device.private_key), + publicKey: device.public_key ? normal64Bytes(device.public_key) : undefined, serverInfo: (device.server_info || []).filter( (entry): entry is Required => !!entry.server && !!entry.clone_code diff --git a/KeeperSdk/src/auth/config.ts b/KeeperSdk/src/auth/config.ts index af3d2dd7..3c8e2eca 100644 --- a/KeeperSdk/src/auth/config.ts +++ b/KeeperSdk/src/auth/config.ts @@ -9,9 +9,15 @@ export type ConfigurationServerConfig = { clone_code?: string } +export type ConfigurationServer = { + server?: string + server_key_id?: number +} + export type ConfigurationDeviceConfig = { device_token?: string private_key?: string + public_key?: string server_info?: Array } @@ -24,6 +30,7 @@ export type KeeperJsonConfig = { private_key?: string clone_code?: string users?: Array + servers?: Array devices?: Array } @@ -33,10 +40,53 @@ export interface ConfigLoader { readonly configDir: string } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hasOptionalStringFields(value: Record, fields: string[]): boolean { + return fields.every((field) => value[field] === undefined || typeof value[field] === 'string') +} + +function isValidUser(value: unknown): value is ConfigurationUser { + if (!isRecord(value) || !hasOptionalStringFields(value, ['user', 'server'])) return false + return ( + value.last_device === undefined || + (isRecord(value.last_device) && hasOptionalStringFields(value.last_device, ['device_token'])) + ) +} + +function isValidServer(value: unknown): value is ConfigurationServer { + return ( + isRecord(value) && + hasOptionalStringFields(value, ['server']) && + (value.server_key_id === undefined || + (typeof value.server_key_id === 'number' && Number.isFinite(value.server_key_id))) + ) +} + +function isValidServerConfig(value: unknown): value is ConfigurationServerConfig { + return isRecord(value) && hasOptionalStringFields(value, ['server', 'clone_code']) +} + +function isValidDevice(value: unknown): value is ConfigurationDeviceConfig { + if (!isRecord(value) || !hasOptionalStringFields(value, ['device_token', 'private_key', 'public_key'])) { + return false + } + return ( + value.server_info === undefined || + (Array.isArray(value.server_info) && value.server_info.every(isValidServerConfig)) + ) +} + export function isValidKeeperConfig(value: unknown): value is KeeperJsonConfig { - if (typeof value !== 'object' || value === null) return false - const obj = value as Record - if (obj.users !== undefined && !Array.isArray(obj.users)) return false - if (obj.devices !== undefined && !Array.isArray(obj.devices)) return false - return true + if (!isRecord(value) || !hasOptionalStringFields(value, ['last_login', 'last_server', 'user', 'server'])) { + return false + } + if (!hasOptionalStringFields(value, ['device_token', 'private_key', 'clone_code'])) return false + return ( + (value.users === undefined || (Array.isArray(value.users) && value.users.every(isValidUser))) && + (value.servers === undefined || (Array.isArray(value.servers) && value.servers.every(isValidServer))) && + (value.devices === undefined || (Array.isArray(value.devices) && value.devices.every(isValidDevice))) + ) } diff --git a/KeeperSdk/src/auth/node/FileConfigLoader.ts b/KeeperSdk/src/auth/node/FileConfigLoader.ts index f80f7a0d..b5daa8c4 100644 --- a/KeeperSdk/src/auth/node/FileConfigLoader.ts +++ b/KeeperSdk/src/auth/node/FileConfigLoader.ts @@ -8,29 +8,41 @@ import { logger, extractErrorMessage, SdkDefaults } from '../../utils' /** CAUTION: This is a Node-only class. */ export class FileConfigLoader implements ConfigLoader { public readonly configDir: string + private readonly configPath: string constructor(configDir?: string) { - this.configDir = configDir || path.join(os.homedir(), SdkDefaults.CONFIG_DIR) + const configuredFile = process.env.KEEPER_CONFIG_FILE + if (configDir) { + this.configDir = configDir + this.configPath = path.join(configDir, 'config.json') + } else if (configuredFile) { + this.configPath = path.resolve(configuredFile) + this.configDir = path.dirname(this.configPath) + } else { + this.configPath = path.join(os.homedir(), SdkDefaults.CONFIG_DIR, 'config.json') + this.configDir = path.dirname(this.configPath) + } } async load(): Promise { - const configPath = path.join(this.configDir, 'config.json') try { - const content = await fs.readFile(configPath, 'utf-8') + const content = await fs.readFile(this.configPath, 'utf-8') const parsed: unknown = JSON.parse(content) if (isValidKeeperConfig(parsed)) { return parsed } } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return {} + } 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.mkdir(this.configDir, { recursive: true, mode: 0o700 }) - await fs.writeFile(configPath, JSON.stringify(config, null, 2), { + await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600, }) } diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 9540c2a2..4ee6d5bf 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -9,6 +9,7 @@ export { ConsoleAuthUI } from './auth/ConsoleAuthUI' export { UnavailableAuthUI } from './auth/UnavailableAuthUI' export { FileConfigLoader } from './auth/node/FileConfigLoader' export { SessionManager } from './auth/SessionManager' +export { setPersistentLogin, DEFAULT_PERSISTENT_LOGIN_TIMEOUT_MINUTES } from './auth/PersistentLogin' export type { KeeperJsonConfig, ConfigLoader, diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index f1a2f728..3cd4ec2a 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -14,6 +14,7 @@ import { import type { SyncResult, SyncLogFormat, VaultStorage, SessionStorage, AuthUI3 } from '@keeper-security/keeperapi' import { InMemoryStorage } from '../storage/InMemoryStorage' import { SessionManager } from '../auth/SessionManager' +import { setPersistentLogin } from '../auth/PersistentLogin' import { getSdkPlatform } from '../platform' import { toSessionParams, type SessionRestoreInput } from '../auth/sessionRestore' import { searchRecords, formatRecord, getRecordTitle, getRecordType } from '../records/RecordUtils' @@ -478,6 +479,12 @@ export class KeeperVault { return this.auth?.sessionToken || undefined } + /** Enables or disables persistent login for the current authenticated session. */ + public async setPersistentLogin(enabled: boolean, logoutTimerMinutes = 30 * 24 * 60): Promise { + const auth = this.getAuthOrThrow() + await setPersistentLogin(auth, enabled, logoutTimerMinutes) + } + /** * Resume a session from extension-exported {@link SessionRestoreInput}. * Verifies the token with a lightweight server call so an expired session @@ -581,14 +588,15 @@ export class KeeperVault { this.auth = await this.createAuth({ useSessionResumption: true }) - await this.auth.loginV3({ + const loginResult = await this.auth.loginV3({ loginType: Authentication.LoginType.NORMAL, resumeSessionOnly: true, }) + await this.sessionManager.flush() 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 (${loginResult?.result || 'unknown result'}) — clone code may be expired or persistent login not enabled. Perform a normal login.`, ResultCodes.PERSISTENT_LOGIN_FAILED ) } diff --git a/examples/sdk_example/README.md b/examples/sdk_example/README.md index 7a7477c3..ecdda234 100644 --- a/examples/sdk_example/README.md +++ b/examples/sdk_example/README.md @@ -31,6 +31,7 @@ Examples use `~/.keeper/config.json` for saved credentials and persistent login. | 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:set-persistent-login` | Logs in, asks whether to enable or disable persistent login, applies the setting, then closes the current session. | | `npm run auth:session-token` | Login using an existing session token for pre-authenticated workflows. Prompts for username, host, and session token. | ### Records diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index aba405bf..c53114d3 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -4,6 +4,7 @@ "description": "Example usage of the KeeperSdk wrapper", "scripts": { "auth:login": "ts-node src/auth/login.ts", + "auth:set-persistent-login": "ts-node src/auth/set_persistent_login.ts", "auth:session-token": "ts-node src/auth/session_token_login.ts", "auth:register-device": "ts-node src/auth/register_device.ts", "records:list": "ts-node src/records/list_records.ts", diff --git a/examples/sdk_example/src/auth/set_persistent_login.ts b/examples/sdk_example/src/auth/set_persistent_login.ts new file mode 100644 index 00000000..4007b787 --- /dev/null +++ b/examples/sdk_example/src/auth/set_persistent_login.ts @@ -0,0 +1,103 @@ +import { + KeeperVault, + KeeperSdkError, + loadKeeperConfig, + resolveServer, + prompt, + cleanup, + logger, + extractResultCode, + SdkDefaults, + ResultCodes, + setPersistentLogin as applyPersistentLogin, + DEFAULT_PERSISTENT_LOGIN_TIMEOUT_MINUTES, +} from "@keeper-security/keeper-sdk-javascript"; +import { runExample } from "../utils/runner"; + +const MAX_ATTEMPTS = 5; +async function setPersistentLogin() { + const config = await 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.", + ResultCodes.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.", + ResultCodes.MISSING_PASSWORD, + ); + + try { + await vault.login(username, password); + break; + } catch (err) { + const resultCode = extractResultCode(err); + if (resultCode === ResultCodes.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.`, + ResultCodes.MAX_ATTEMPTS_EXCEEDED, + ); + } + throw KeeperSdkError.from(err); + } + } + + const choice = ( + await prompt("Persistent login: enable or disable? [e/d]: ") + ) + .trim() + .toLowerCase(); + if ( + choice !== "e" && + choice !== "enable" && + choice !== "d" && + choice !== "disable" + ) { + throw new KeeperSdkError( + "Please choose enable or disable.", + ResultCodes.USER_CANCELLED, + ); + } + + const enabled = choice === "e" || choice === "enable"; + await applyPersistentLogin( + vault.getAuth(), + enabled, + DEFAULT_PERSISTENT_LOGIN_TIMEOUT_MINUTES, + ); + logger.info( + `Persistent login ${enabled ? "enabled" : "disabled"} successfully.`, + ); + } finally { + cleanup(vault); + } +} + +runExample(setPersistentLogin);