Skip to content
Draft
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
6 changes: 3 additions & 3 deletions examples/javascript/share-client/README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Keeper Share

This is an application that allows for anybody with a link receive a shared secret from Keeper
This application lets any user with a link receive a shared secret from Keeper.

Only the first user that clicks on the link will be able to access the secret
Only the first user who clicks the link can access the secret.

Keys are stored in the IndexedDb:
The SDK stores keys in IndexedDB:
https://blog.engelke.com/2014/09/19/saving-cryptographic-keys-in-the-browser/

12 changes: 12 additions & 0 deletions sdk/javascript/packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Change Log

## 18.0.0
- KSM-1042 - Fixed `getFolders()` failing with "appKey missing" when called as the first method on a freshly bound application. `getFolders()` now processes `encryptedAppKey` from the server binding response the same way `getSecrets()` does.
- KSM-1058 - Fixed `createFolder()` writing folder keys and data with AES-CBC instead of AES-GCM. New folders are now created with GCM. `getFolders()` detects the cipher from the encrypted key length (60 bytes = GCM, 64 bytes = CBC) so existing CBC folders continue to decrypt correctly. `updateFolder()` accepts an optional `useGcm` flag so callers can match the cipher used when the folder was created.

## 17.6.0
- KSM-1073 - Added `dbConnectionMethod` to `PamSettingsConnection`.
- KSM-1079 - Fixed `getFolders()` crashing when a folder in the response has a corrupted or missing key. The SDK now skips undecryptable folders and returns the remaining folders normally.
- KSM-1084 - Fixed `deleteSecret()` and `deleteFolder()` silently reporting success when the server rejected some UIDs. The SDK now surfaces per-item error messages from the server to the caller.
- KSM-748 - Fixed `getSecrets()` silently dropping records created by Commander or the Vault UI inside shared folders. The SDK now uses the folder key to decrypt the record key for any flat record that has `innerFolderUid` set. This matches the behavior for records in `folders[].records[]`.
- KSM-1035 - Fixed throttle retry jitter being two-sided, which could reduce a retry delay below the computed floor. Jitter is now one-sided (0 to +25%). The SDK also caps a server-supplied `retry_after` at 176s to prevent an arbitrarily long wait.
- Maintenance: Updated `minimatch`, `@babel/core`, and `handlebars` dev dependencies.

## 17.5.0
- KSM-1029 - Fixed stale pinned server key error: when the server rejects a configured custom server public key, the diagnostic message now propagates to the caller instead of being swallowed by a bare catch.
- KSM-880 - Added automatic throttle retry with exponential backoff. On HTTP 403 `{"error":"throttled"}`, `postQuery` now retries up to 5 times with exponentially increasing delays (11s, 22s, 44s, 88s, 176s) plus ±25% jitter, honoring `retry_after` from the response when present; a typed `KeeperThrottleError` is thrown once retries are exhausted. Existing key-rotation retry behavior is unchanged.
Expand Down
192 changes: 94 additions & 98 deletions sdk/javascript/packages/core/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion sdk/javascript/packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@keeper-security/secrets-manager-core",
"version": "17.5.0",
"version": "18.0.0",
"description": "Keeper Secrets Manager Javascript SDK",
"browser": "dist/index.es.js",
"main": "dist/index.cjs.js",
Expand Down
207 changes: 165 additions & 42 deletions sdk/javascript/packages/core/src/keeper.ts

Large diffs are not rendered by default.

185 changes: 184 additions & 1 deletion sdk/javascript/packages/core/test/keeper.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
KeeperHttpResponse,
getSecrets,
getFolders,
initializeStorage,
generateTransmissionKey,
platform,
SecretManagerOptions, inMemoryStorage, loadJsonConfig, getTotpCode, generatePassword
SecretManagerOptions, inMemoryStorage, loadJsonConfig, getTotpCode, generatePassword,
updateSecrets, KeeperRecord
} from '../'

import * as fs from 'fs'
Expand Down Expand Up @@ -366,3 +368,184 @@ test('IL5 dynamic key - Layer 2: rejects malformed (too short) serverPublicKey',
initializeStorage(storage, 'IL5:ONE_TIME_TOKEN:20:tooshort')
).rejects.toThrow('IL5 token: serverPublicKey appears malformed')
})

test('getFolders skips an undecryptable folder and returns the good one', async () => {
const transmissionKey = new Uint8Array(32).fill(1)
const appKey = new Uint8Array(32).fill(2)
const folderKey = new Uint8Array(32).fill(3)

const goodFolderKeyWrapped = await platform.encryptWithKey(folderKey, appKey)
const goodFolderData = await platform.encryptWithKey(
platform.stringToBytes(JSON.stringify({ name: 'Good Folder' })), folderKey, true)
const badFolderKeyWrapped = new Uint8Array(16).fill(9)

const serverResponse = {
folders: [
{ folderUid: 'good-uid', folderKey: platform.bytesToBase64(goodFolderKeyWrapped), data: platform.bytesToBase64(goodFolderData) },
{ folderUid: 'bad-uid', folderKey: platform.bytesToBase64(badFolderKeyWrapped), data: '' }
],
records: [],
expiresOn: 0,
warnings: []
}
const encryptedResponse = await platform.encryptWithKey(
platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey)

// postQuery uses options.queryFunction (not platform.post); pin getRandomBytes so the
// transmission key matches the key used to encrypt the response above.
platform.getRandomBytes = () => transmissionKey
const queryFn = (): Promise<KeeperHttpResponse> => Promise.resolve({ data: encryptedResponse, statusCode: 200, headers: [] })

const kvs = inMemoryStorage({})
await initializeStorage(kvs, 'US:FAKE_CLIENT_KEY')
await kvs.saveBytes('appKey', appKey)

const folders = await getFolders({ storage: kvs, queryFunction: queryFn })

expect(folders.length).toBe(1)
expect(folders[0].folderUid).toBe('good-uid')
expect(folders[0].name).toBe('Good Folder')
})

test('flat record with innerFolderUid decrypts recordKey using the folder key, not the app key', async () => {
const transmissionKey = new Uint8Array(32).fill(1)
const appKey = new Uint8Array(32).fill(2)
const folderKey = new Uint8Array(32).fill(3)
const recordKey = new Uint8Array(32).fill(4)
const folderUid = 'folder-uid-1'
const recordUid = 'record-uid-1'

const wrappedFolderKey = await platform.encryptWithKey(folderKey, appKey)
const wrappedRecordKey = await platform.encryptWithKey(recordKey, folderKey)
const recordData = await platform.encryptWithKey(
platform.stringToBytes(JSON.stringify({ title: 'Shared Record', type: 'login', fields: [], custom: [] })), recordKey)

const serverResponse = {
folders: [
{ folderUid, folderKey: platform.bytesToBase64(wrappedFolderKey), data: '', records: [] }
],
records: [
{
recordUid,
recordKey: platform.bytesToBase64(wrappedRecordKey),
data: platform.bytesToBase64(recordData),
revision: 1,
files: [],
innerFolderUid: folderUid
}
],
expiresOn: 0,
warnings: []
}
const encryptedResponse = await platform.encryptWithKey(
platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey)

platform.getRandomBytes = () => transmissionKey
const queryFn = (): Promise<KeeperHttpResponse> => Promise.resolve({ data: encryptedResponse, statusCode: 200, headers: [] })

const kvs = inMemoryStorage({})
await initializeStorage(kvs, 'US:FAKE_CLIENT_KEY')
await kvs.saveBytes('appKey', appKey)

const secrets = await getSecrets({ storage: kvs, queryFunction: queryFn })

// Bug: the flat-records loop always unwraps recordKey with KEY_APP_KEY, ignoring
// innerFolderUid. Since recordKey here is wrapped with the folder key, unwrapping
// with the app key throws, the record is caught and silently skipped, and
// secrets.records comes back empty instead of containing the decrypted record.
expect(secrets.records.length).toBe(1)
expect(secrets.records[0].data.title).toBe('Shared Record')
expect(secrets.records[0].folderUid).toBe(folderUid)
})

test('flat record with innerFolderUid falls back to the app key when no matching folder is returned', async () => {
const transmissionKey = new Uint8Array(32).fill(5)
const appKey = new Uint8Array(32).fill(6)
const recordKey = new Uint8Array(32).fill(7)
const recordUid = 'record-uid-2'

const wrappedRecordKey = await platform.encryptWithKey(recordKey, appKey)
const recordData = await platform.encryptWithKey(
platform.stringToBytes(JSON.stringify({ title: 'Orphaned Record', type: 'login', fields: [], custom: [] })), recordKey)

const serverResponse = {
folders: [],
records: [
{
recordUid,
recordKey: platform.bytesToBase64(wrappedRecordKey),
data: platform.bytesToBase64(recordData),
revision: 1,
files: [],
innerFolderUid: 'folder-uid-not-in-response'
}
],
expiresOn: 0,
warnings: []
}
const encryptedResponse = await platform.encryptWithKey(
platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey)

platform.getRandomBytes = () => transmissionKey
const queryFn = (): Promise<KeeperHttpResponse> => Promise.resolve({ data: encryptedResponse, statusCode: 200, headers: [] })

const kvs = inMemoryStorage({})
await initializeStorage(kvs, 'US:FAKE_CLIENT_KEY')
await kvs.saveBytes('appKey', appKey)

const secrets = await getSecrets({ storage: kvs, queryFunction: queryFn })

expect(secrets.records.length).toBe(1)
expect(secrets.records[0].data.title).toBe('Orphaned Record')
})

test('updateSecrets batch e2e - reports partial success per record', async () => {
const transmissionKey = new Uint8Array(32).fill(8)
const appKey = new Uint8Array(32).fill(9)
const record1Uid = 'batch-record-uid-1'
const record1Key = new Uint8Array(32).fill(11)
const record2Uid = 'batch-record-uid-2'
const record2Key = new Uint8Array(32).fill(12)

const serverResponse = {
records: [
{ recordUid: record1Uid, errorMessage: '', responseCode: 'ok' },
{ recordUid: record2Uid, errorMessage: 'Record is out of sync', responseCode: 'access_denied' }
]
}
const encryptedResponse = await platform.encryptWithKey(
platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey)

// postQuery uses options.queryFunction (not platform.post); pin getRandomBytes so the
// transmission key matches the key used to encrypt the response above.
platform.getRandomBytes = () => transmissionKey
const queryFn = (): Promise<KeeperHttpResponse> => Promise.resolve({ data: encryptedResponse, statusCode: 200, headers: [] })

const kvs = inMemoryStorage({})
await initializeStorage(kvs, 'US:FAKE_CLIENT_KEY')
// unique key-id: loadKey() checks a process-wide in-memory cache before storage, and other
// tests in this file already cache the literal id 'appKey' with different bytes
const batchAppKeyId = 'batch-app-key'
await kvs.saveBytes(batchAppKeyId, appKey)

// encrypt() looks up each record's key by recordUid via loadKey(), which only succeeds
// from its in-memory cache - in real usage that cache gets populated by getSecrets()
// decrypting the record first; here we populate it directly via unwrap(), the same
// mechanism getSecrets() uses internally.
await platform.unwrap(await platform.encryptWithKey(record1Key, appKey), record1Uid, batchAppKeyId, kvs)
await platform.unwrap(await platform.encryptWithKey(record2Key, appKey), record2Uid, batchAppKeyId, kvs)

const records: KeeperRecord[] = [
{ recordUid: record1Uid, data: { title: 'Rotated 1', type: 'login', fields: [], custom: [] }, revision: 5 },
{ recordUid: record2Uid, data: { title: 'Rotated 2', type: 'login', fields: [], custom: [] }, revision: 9 }
]

const result = await updateSecrets({ storage: kvs, queryFunction: queryFn }, records)

expect(result.records.length).toBe(2)
expect(result.records[0].recordUid).toBe(record1Uid)
expect(result.records[0].responseCode).toBe('ok')
expect(result.records[1].recordUid).toBe(record2Uid)
expect(result.records[1].responseCode).toBe('access_denied')
expect(result.records[1].errorMessage).toBe('Record is out of sync')
})
2 changes: 1 addition & 1 deletion sdk/javascript/packages/core/test/record_link.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {KeeperRecordLink, getLinks, KeeperRecord, platform} from '../'
import {createCipheriv, randomBytes} from 'crypto'

// KSM-1010: KeeperRecordLink typed accessor tests (mirrors Python record_link_test.py)
// KeeperRecordLink typed accessor tests (mirrors Python record_link_test.py)

const plainLink = (payload: object, path?: string, ownerRecordUid = 'RU_owner'): KeeperRecordLink => {
const data = platform.bytesToBase64(platform.stringToBytes(JSON.stringify(payload)))
Expand Down
26 changes: 22 additions & 4 deletions sdk/javascript/packages/core/test/throttle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
KeeperThrottleError,
parseThrottle,
throttleDelay,
throttleJitter,
} from '../'

// A valid one-time token (same fixture the e2e suite uses) so initializeStorage produces a
Expand Down Expand Up @@ -59,12 +60,22 @@ describe('throttleDelay (unit)', () => {
expect(throttleDelay(0, 0, 0)).toBe(11000)
expect(throttleDelay(1, -5, 0)).toBe(22000)
})
test('jitter bounds keep the first delay in [8.25s, 13.75s]', () => {
expect(throttleDelay(0, 0, -0.25)).toBe(8250)
test('jitter bounds keep the first delay in [11s, 13.75s)', () => {
expect(throttleDelay(0, 0, 0)).toBe(11000)
expect(throttleDelay(0, 0, 0.25)).toBe(13750)
})
})

describe('throttleJitter (unit)', () => {
test('is one-sided: never pushes the delay below its floor', () => {
for (let i = 0; i < 200; i++) {
const jitter = throttleJitter()
expect(jitter).toBeGreaterThanOrEqual(0)
expect(jitter).toBeLessThan(0.25)
}
})
})

describe('parseThrottle (unit)', () => {
test('throttled via error / result_code with retry_after variants', () => {
expect(parseThrottle('{"error":"throttled"}')).toBe(0)
Expand All @@ -77,6 +88,13 @@ describe('parseThrottle (unit)', () => {
expect(parseThrottle('not json')).toBeNull()
expect(parseThrottle('')).toBeNull()
})
test('retry_after beyond the exponential ceiling is capped at 176s', () => {
// 176s = BASE_THROTTLE_DELAY_SEC * 2**(MAX_THROTTLE_RETRIES - 1) = 11 * 2**4, the same
// ceiling the exponential branch already reaches on the last retry attempt.
expect(parseThrottle('{"error":"throttled","retry_after":500}')).toBe(176)
expect(parseThrottle('{"error":"throttled","retry_after":176}')).toBe(176)
expect(parseThrottle('{"error":"throttled","retry_after":175}')).toBe(175)
})
})

describe('throttle retry (e2e via getSecrets)', () => {
Expand Down Expand Up @@ -118,8 +136,8 @@ describe('throttle retry (e2e via getSecrets)', () => {
call++ === 0 ? throttle403(3) : throttle403()
)
await expect(getSecrets(options)).rejects.toBeInstanceOf(KeeperThrottleError)
// retry_after = 3s with +/-25% jitter -> [2.25s, 3.75s]
expect(sleeps[0]).toBeGreaterThanOrEqual(2250)
// retry_after = 3s with one-sided [0, +25%) jitter -> [3s, 3.75s]; never below the 3s floor
expect(sleeps[0]).toBeGreaterThanOrEqual(3000)
expect(sleeps[0]).toBeLessThanOrEqual(3750)
})

Expand Down
Loading