Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion KeeperSdk/src/auth/ConsoleLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
71 changes: 71 additions & 0 deletions KeeperSdk/src/auth/PersistentLogin.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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) })
)
}
}
59 changes: 57 additions & 2 deletions KeeperSdk/src/auth/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,29 @@ 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 {
KeeperJsonConfig,
ConfigLoader,
ConfigurationUser,
ConfigurationServerConfig,
ConfigurationServer,
ConfigurationDeviceConfig,
} from './config'

type ResolvedDevice = {
deviceToken: Uint8Array
privateKey: Uint8Array
publicKey?: Uint8Array
serverInfo: Array<Required<ConfigurationServerConfig>>
}

Expand All @@ -38,6 +47,7 @@ export class SessionManager implements SessionStorage {
private _deviceCache: Nullable<DeviceCacheEntry> = null
private sessionDevices = new Map<string, DeviceConfig>()
private sessionCloneCodes = new Map<string, Uint8Array>()
private cloneCodeWrite: Promise<void> = Promise.resolve()

constructor(configDir?: string)
constructor(loader: ConfigLoader)
Expand Down Expand Up @@ -73,6 +83,7 @@ export class SessionManager implements SessionStorage {
return {
deviceToken: device.deviceToken,
privateKey: device.privateKey,
publicKey: device.publicKey,
}
}
}
Expand All @@ -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<ConfigurationUser> = 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<ConfigurationDeviceConfig> = 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<ConfigurationServer> = 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
Expand Down Expand Up @@ -135,7 +182,14 @@ export class SessionManager implements SessionStorage {
public async saveCloneCode(host: KeeperHost, username: string, cloneCode: Uint8Array): Promise<void> {
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<void> {
await this.cloneCodeWrite
}

private async updateKeeperConfigCloneCode(host: string, username: string, cloneCode: Uint8Array): Promise<void> {
Expand Down Expand Up @@ -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<ConfigurationServerConfig> =>
!!entry.server && !!entry.clone_code
Expand Down
60 changes: 55 additions & 5 deletions KeeperSdk/src/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConfigurationServerConfig>
}

Expand All @@ -24,6 +30,7 @@ export type KeeperJsonConfig = {
private_key?: string
clone_code?: string
users?: Array<ConfigurationUser>
servers?: Array<ConfigurationServer>
devices?: Array<ConfigurationDeviceConfig>
}

Expand All @@ -33,10 +40,53 @@ export interface ConfigLoader {
readonly configDir: string
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function hasOptionalStringFields(value: Record<string, unknown>, 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<string, unknown>
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)))
)
}
22 changes: 17 additions & 5 deletions KeeperSdk/src/auth/node/FileConfigLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeeperJsonConfig> {
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<void> {
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,
})
}
Expand Down
1 change: 1 addition & 0 deletions KeeperSdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions KeeperSdk/src/vault/KeeperVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<void> {
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
Expand Down Expand Up @@ -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
)
}
Expand Down
1 change: 1 addition & 0 deletions examples/sdk_example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions examples/sdk_example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading