From 79e0b62c71cc350957eada5858327f3bb596da20 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Thu, 2 Jul 2026 16:15:58 -0400 Subject: [PATCH 01/20] fix(sdk/javascript): KSM-1073 add dbConnectionMethod to PamSettingsConnection --- sdk/javascript/packages/core/package.json | 2 +- sdk/javascript/packages/core/src/keeper.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/javascript/packages/core/package.json b/sdk/javascript/packages/core/package.json index 683b6694b..aea0d94d0 100644 --- a/sdk/javascript/packages/core/package.json +++ b/sdk/javascript/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@keeper-security/secrets-manager-core", - "version": "17.5.0", + "version": "17.6.0", "description": "Keeper Secrets Manager Javascript SDK", "browser": "dist/index.es.js", "main": "dist/index.cjs.js", diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index 9bc2aad26..1c804c374 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -1956,6 +1956,7 @@ export type PamSettingsConnection = { ignoreCert?: boolean resizeMethod?: string colorScheme?: string + dbConnectionMethod?: string } export type PamSettingsPortForward = { From f711b856fdd97712a0dba9046a1a7f2c1e0711f7 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Tue, 7 Jul 2026 12:23:43 -0400 Subject: [PATCH 02/20] fix(javascript): KSM-1079 skip undecryptable folders in getFolders instead of throwing --- sdk/javascript/packages/core/src/keeper.ts | 36 ++++++++++++---------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index 1c804c374..e68de618e 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -957,24 +957,28 @@ const fetchAndDecryptFolders = async (options: SecretManagerOptions): Promise Date: Tue, 7 Jul 2026 14:24:57 -0400 Subject: [PATCH 03/20] test(js): KSM-1079 add getFolders crash-safety regression test --- .../packages/core/test/keeper.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/sdk/javascript/packages/core/test/keeper.test.ts b/sdk/javascript/packages/core/test/keeper.test.ts index 415d4c0fd..eb99e0ed9 100644 --- a/sdk/javascript/packages/core/test/keeper.test.ts +++ b/sdk/javascript/packages/core/test/keeper.test.ts @@ -1,6 +1,7 @@ import { KeeperHttpResponse, getSecrets, + getFolders, initializeStorage, generateTransmissionKey, platform, @@ -366,3 +367,41 @@ 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 => 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') +}) From 1c7d237018a754ae2ed92742417137f3f4d917af Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Tue, 7 Jul 2026 15:52:18 -0400 Subject: [PATCH 04/20] fix(js): KSM-1084 surface per-item error messages from deleteSecret and deleteFolder --- sdk/javascript/packages/core/src/keeper.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index e68de618e..2b16be27f 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -1296,13 +1296,25 @@ export const completeTransaction = async (options: SecretManagerOptions, recordU export const deleteSecret = async (options: SecretManagerOptions, recordUids: string[]): Promise => { const payload = await prepareDeletePayload(options.storage, recordUids) const responseData = await postQuery(options, 'delete_secret', payload) - return JSON.parse(platform.bytesToString(responseData)) as SecretsManagerDeleteResponse + const response = JSON.parse(platform.bytesToString(responseData)) as SecretsManagerDeleteResponse + for (const r of (response.records || [])) { + if (r.responseCode !== 'ok') { + console.error(`Failed to delete record ${r.recordUid}: ${r.responseCode} ${r.errorMessage}`) + } + } + return response } export const deleteFolder = async (options: SecretManagerOptions, folderUids: string[], forceDeletion?: boolean): Promise => { const payload = await prepareDeleteFolderPayload(options.storage, folderUids, forceDeletion) const responseData = await postQuery(options, 'delete_folder', payload) - return JSON.parse(platform.bytesToString(responseData)) as SecretsManagerDeleteResponse + const response = JSON.parse(platform.bytesToString(responseData)) as SecretsManagerDeleteResponse + for (const f of (response.folders || [])) { + if (f.responseCode !== 'ok') { + console.error(`Failed to delete folder ${f.folderUid}: ${f.responseCode} ${f.errorMessage}`) + } + } + return response } export const createSecret = async (options: SecretManagerOptions, folderUid: string, recordData: any): Promise => { From 75c63bfab97f9d20c90c86f2ae723362498d61ed Mon Sep 17 00:00:00 2001 From: stas-schaller Date: Fri, 31 Jul 2026 15:53:35 -0400 Subject: [PATCH 05/20] chore(javascript): humanize comments in core SDK on the release branch (#1079) * chore(javascript): humanize comments and test names on the release branch Ticket refs belong in commit messages, not code; test names now describe the behavior under test instead of the ticket that prompted it. No behavior change. --- sdk/javascript/packages/core/src/keeper.ts | 2 +- sdk/javascript/packages/core/test/record_link.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index 2b16be27f..3f0d4c372 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -899,7 +899,7 @@ const fetchAndDecryptSecrets = async (options: SecretManagerOptions, queryOption if (response.folders) { for (const folder of response.folders) { try { - if (!folder.folderKey) throw new Error(`Folder key missing for UID ${folder.folderUid} — reinitialize with a fresh One-Time Token`) + if (!folder.folderKey) throw new Error(`Folder key missing for UID ${folder.folderUid}; reinitialize with a fresh One-Time Token`) await platform.unwrap(platform.base64ToBytes(folder.folderKey), folder.folderUid, KEY_APP_KEY, storage, true) for (const record of folder.records) { try { diff --git a/sdk/javascript/packages/core/test/record_link.test.ts b/sdk/javascript/packages/core/test/record_link.test.ts index 6fd82ae03..373831a8f 100644 --- a/sdk/javascript/packages/core/test/record_link.test.ts +++ b/sdk/javascript/packages/core/test/record_link.test.ts @@ -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))) From 26264e53ab0cff2558c298baca5d53702f5f92a1 Mon Sep 17 00:00:00 2001 From: stas-schaller Date: Fri, 14 Aug 2026 15:41:39 -0400 Subject: [PATCH 06/20] fix(javascript): KSM-748 use folder key for shared-folder records in flat response array (#1076) Records created via non-SDK clients inside shared folders arrive in the flat response.records[] with innerFolderUid set, but recordKey is wrapped with the folder key, not the app key. The unconditional KEY_APP_KEY unwrap caused these records to be silently skipped. Mirrors the folderKeyMap pattern already shipped in the Java SDK (KSM-753). --- sdk/javascript/packages/core/src/keeper.ts | 23 ++++- .../packages/core/test/keeper.test.ts | 92 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index 3f0d4c372..0173b7ae1 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -883,13 +883,34 @@ const fetchAndDecryptSecrets = async (options: SecretManagerOptions, queryOption await storage.delete(KEY_CLIENT_KEY) await storage.saveString(KEY_OWNER_PUBLIC_KEY, response.appOwnerPublicKey!) } + const folderKeyIds = new Set() + if (response.folders) { + for (const folder of response.folders) { + try { + if (!folder.folderKey) continue + await platform.unwrap(platform.base64ToBytes(folder.folderKey), folder.folderUid, KEY_APP_KEY, storage, true) + folderKeyIds.add(folder.folderUid) + } catch (e: Error | any) { + // ignored here; the folders loop below re-attempts the same unwrap and logs the failure + } + } + } if (response.records) { for (const record of response.records) { try { if (record.recordKey) { - await platform.unwrap(platform.base64ToBytes(record.recordKey), record.recordUid, KEY_APP_KEY, storage, true) + // Records created outside the SDK in a shared folder arrive in the flat + // response.records[] with innerFolderUid set; their recordKey is wrapped + // with the folder key, not the app key. + const unwrappingKeyId = record.innerFolderUid && folderKeyIds.has(record.innerFolderUid) + ? record.innerFolderUid + : KEY_APP_KEY + await platform.unwrap(platform.base64ToBytes(record.recordKey), record.recordUid, unwrappingKeyId, storage, true) } const decryptedRecord = await decryptRecord(record, storage) + if (record.innerFolderUid && folderKeyIds.has(record.innerFolderUid)) { + decryptedRecord.folderUid = record.innerFolderUid + } records.push(decryptedRecord) } catch (e: Error | any) { console.error(`Record ${record.recordUid} skipped due to error: ${e.constructor.name}, ${e.message}`) diff --git a/sdk/javascript/packages/core/test/keeper.test.ts b/sdk/javascript/packages/core/test/keeper.test.ts index eb99e0ed9..1dda1de3e 100644 --- a/sdk/javascript/packages/core/test/keeper.test.ts +++ b/sdk/javascript/packages/core/test/keeper.test.ts @@ -405,3 +405,95 @@ test('getFolders skips an undecryptable folder and returns the good one', async 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 => 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 => 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') +}) From 4bdfb7c6b94119a62fddd0ed656122d6773bd2a7 Mon Sep 17 00:00:00 2001 From: stas-schaller Date: Fri, 14 Aug 2026 17:28:12 -0400 Subject: [PATCH 07/20] fix(javascript): KSM-1035 one-sided throttle jitter and retry_after cap (#1077) * fix(javascript): KSM-1035 one-sided throttle jitter and retry_after cap throttleJitter previously returned [-0.25, 0.25), so a retry could fire before the computed backoff floor and immediately re-trigger the same throttle window. Narrowed to [0, 0.25), one-sided like the already-shipped Ruby fix (KSM-883). Also caps a server-supplied retry_after at MAX_THROTTLE_DELAY_SEC (176s), the same ceiling the exponential branch already reaches on its last retry. * fix(javascript): address KSM-1035 review feedback - Fix stale JSDoc on throttleDelay: jitter range is [0, 0.25) not [-0.25, 0.25) - Update jitter-bounds unit test to reflect one-sided range (floor is 11s not 8.25s) - Add 17.6.0 CHANGELOG entry for KSM-1035 --- sdk/javascript/packages/core/CHANGELOG.md | 3 +++ sdk/javascript/packages/core/src/keeper.ts | 18 ++++++++----- .../packages/core/test/throttle.test.ts | 26 ++++++++++++++++--- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/sdk/javascript/packages/core/CHANGELOG.md b/sdk/javascript/packages/core/CHANGELOG.md index d8457ff26..c6c32aa59 100644 --- a/sdk/javascript/packages/core/CHANGELOG.md +++ b/sdk/javascript/packages/core/CHANGELOG.md @@ -1,5 +1,8 @@ # Change Log +## 17.6.0 +- KSM-1035 - Throttle backoff hardening: retry jitter is now one-sided (0 to +25%, so delays never fall below the computed floor), and a server-supplied `retry_after` is capped at 176s (the exponential ladder's last step) so it can no longer force an excessive or unbounded wait. + ## 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. diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index 0173b7ae1..630abfa55 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -20,6 +20,7 @@ const KEY_PRIVATE_KEY = 'privateKey' // The client's private key // request, so the counter only clears after 10s of silence). const MAX_THROTTLE_RETRIES = 5 const BASE_THROTTLE_DELAY_SEC = 11 // 1s safety margin over the backend's 10s memcached TTL +const MAX_THROTTLE_DELAY_SEC = 176 // same ceiling the exponential branch reaches at the last retry (11 * 2**4) const CLIENT_ID_HASH_TAG = 'KEEPER_SECRETS_MANAGER_CLIENT_ID' // Tag for hashing the client key to client id let keeperPublicKeys: Record @@ -62,14 +63,17 @@ export type SecretManagerOptions = { // utils.ts/platform code that throws them; re-exported here so the public API is unchanged. export {KeeperError, KeeperThrottleError} from './errors' -// Returns a jitter multiplier in [-0.25, 0.25). Kept separate so concurrent clients -// desynchronize their retries; unit tests exercise throttleDelay with a pinned jitter. -export const throttleJitter = (): number => Math.random() * 0.5 - 0.25 +// Returns a jitter multiplier in [0, 0.25). One-sided so the delay never drops below the +// computed floor (retrying too soon just re-triggers the throttle); kept separate so +// concurrent clients desynchronize their retries. Unit tests exercise throttleDelay with a +// pinned jitter. +export const throttleJitter = (): number => Math.random() * 0.25 /** * If `body` is a backend throttle error (`result_code`/`error` === "throttled") returns its - * `retry_after` in seconds (>= 0); otherwise returns `null` so the caller falls through to - * normal error handling. Non-JSON / non-object bodies return `null`. + * `retry_after` in seconds (>= 0, capped at MAX_THROTTLE_DELAY_SEC); otherwise returns `null` + * so the caller falls through to normal error handling. Non-JSON / non-object bodies return + * `null`. */ export const parseThrottle = (body: string): number | null => { let obj: any @@ -86,13 +90,13 @@ export const parseThrottle = (body: string): number | null => { return null } const retryAfter = Number(obj.retry_after) - return Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 0 + return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, MAX_THROTTLE_DELAY_SEC) : 0 } /** * Computes the backoff delay (milliseconds) for a 0-based `attempt`: `retryAfter` seconds when * provided (> 0), otherwise exponential backoff (BASE_THROTTLE_DELAY_SEC * 2**attempt -> 11, 22, - * 44, 88, 176s). The `jitter` fraction (typically in [-0.25, 0.25)) is then applied. + * 44, 88, 176s). The `jitter` fraction (typically in [0, 0.25)) is then applied. */ export const throttleDelay = (attempt: number, retryAfter: number, jitter: number = throttleJitter()): number => { const baseSec = retryAfter > 0 ? retryAfter : BASE_THROTTLE_DELAY_SEC * Math.pow(2, attempt) diff --git a/sdk/javascript/packages/core/test/throttle.test.ts b/sdk/javascript/packages/core/test/throttle.test.ts index 8cb830183..25d9b132b 100644 --- a/sdk/javascript/packages/core/test/throttle.test.ts +++ b/sdk/javascript/packages/core/test/throttle.test.ts @@ -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 @@ -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) @@ -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)', () => { @@ -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) }) From d06978b622b83d4ad92dcfb1c011e9c459bace01 Mon Sep 17 00:00:00 2001 From: Sergey Aldoukhov Date: Thu, 13 Aug 2026 13:48:58 -0400 Subject: [PATCH 08/20] fix(js/core): bump minimatch, @babel/core, handlebars dev-dependencies All three are dev-only in this package (ts-jest transitive chain), never shipped: - minimatch -> 9.0.9 (CVE-2026-27903, CVE-2026-27904 ReDoS) - @babel/core -> 7.29.7 (CVE-2026-49356, arbitrary file read via sourceMappingURL) - handlebars -> 4.7.9 (CVE-2026-33938, CVE-2026-33941) Lockfile-only, no package.json range changes. Cherry-picked and scoped to sdk/javascript/packages/core/package-lock.json from 831b7b48, efde007d, and b9aef2fa (Sergey Aldoukhov). KSM-1217 --- .../packages/core/package-lock.json | 192 +++++++++--------- 1 file changed, 94 insertions(+), 98 deletions(-) diff --git a/sdk/javascript/packages/core/package-lock.json b/sdk/javascript/packages/core/package-lock.json index 0c342c0e5..ae659e2e8 100644 --- a/sdk/javascript/packages/core/package-lock.json +++ b/sdk/javascript/packages/core/package-lock.json @@ -24,16 +24,19 @@ "ts-jest": "^29.4.4", "ts-node": "^10.9.2", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -42,9 +45,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", - "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -52,22 +55,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", - "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.4", - "@babel/types": "^7.28.4", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -84,14 +86,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -114,14 +116,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -188,9 +190,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -212,29 +214,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -317,9 +319,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -327,9 +329,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -337,9 +339,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -362,27 +364,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", - "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.4" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -1757,33 +1759,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", - "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -1791,14 +1793,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", - "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2869,7 +2871,6 @@ "integrity": "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.13.0" } @@ -3487,7 +3488,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -4237,9 +4237,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4540,7 +4540,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -5347,13 +5346,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5837,7 +5836,6 @@ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6340,9 +6338,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6457,7 +6455,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6532,7 +6529,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From 8ccb7afdb0df708ded95d7cf3a75336b9789b10e Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Mon, 17 Aug 2026 16:12:15 -0400 Subject: [PATCH 09/20] docs(javascript): STE pass on 17.6.0 changelog and share-client example --- examples/javascript/share-client/README.md | 6 +++--- sdk/javascript/packages/core/CHANGELOG.md | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/javascript/share-client/README.md b/examples/javascript/share-client/README.md index 2c20ec3e6..fb349f0cd 100644 --- a/examples/javascript/share-client/README.md +++ b/examples/javascript/share-client/README.md @@ -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/ diff --git a/sdk/javascript/packages/core/CHANGELOG.md b/sdk/javascript/packages/core/CHANGELOG.md index c6c32aa59..f0e620e07 100644 --- a/sdk/javascript/packages/core/CHANGELOG.md +++ b/sdk/javascript/packages/core/CHANGELOG.md @@ -1,7 +1,12 @@ # Change Log ## 17.6.0 -- KSM-1035 - Throttle backoff hardening: retry jitter is now one-sided (0 to +25%, so delays never fall below the computed floor), and a server-supplied `retry_after` is capped at 176s (the exponential ladder's last step) so it can no longer force an excessive or unbounded wait. +- 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. From 555cf0928fbc1fcdeb783e99c9d84a06ab310eff Mon Sep 17 00:00:00 2001 From: stas-schaller Date: Tue, 18 Aug 2026 12:03:30 -0400 Subject: [PATCH 10/20] fix(javascript): KSM-1128 bound server-key-rotation retries in postQuery (#1078) * fix(javascript): KSM-1128 bound server-key-rotation retries in postQuery postQuery's error === 'key' branch (default, no custom server key pinned) saved the server's suggested key_id and retried with no iteration cap. A server that keeps rejecting the suggested key would retry forever. Adds a keyRotationAttempt counter bounded by MAX_KEY_ROTATION_RETRIES (3), mirroring the existing throttleAttempt/MAX_THROTTLE_RETRIES pattern in the same loop. * fix(javascript): address KSM-1128 review feedback - Import KeeperError for use at throw sites - Validate key_id before persisting: reject non-integer or non-positive values with KeeperError - Replace plain Error with KeeperError at all key-rotation throw sites - Tighten rotation-bound test: assert calls === 4 (MAX_KEY_ROTATION_RETRIES + 1), remove loose guard - Add happy-path test: single key rotation resolves on the retry * fix(javascript): KSM-1128 complete review feedback Blocking issue: Validate suggested key_id membership in keeperPublicKeys range. Unsupported key ids (outside 7-18) now throw typed KeeperError instead of silently persisting invalid config. Membership check is gated after custom-key check to preserve IL5 deployment support. Non-blocking improvements: - Diagnostic message now names the transmission key id actually attempted, not a future suggestion that was never sent. - Runaway loop guards in all key rotation tests prevent silent jest hang if retry bound breaks. - Better adoption verification: second test uses key_id 8 (not default 7) and asserts both transmission key and storage reflect the new id. - Helper functions (FAKE_ONE_TIME_TOKEN, keyErrorResponse) reduce duplicate literals and improve test maintainability. All 57 tests pass. * fix(javascript): KSM-1128 add membership check test and changelog Add regression test for unsupported key_id rejection to prevent silent config poisoning. Server response with unsupported key_id (e.g. 99) now correctly: - Makes exactly 1 network request (no retry loop) - Throws typed KeeperError with clear message - Does not persist the invalid id to storage Also add changelog entry documenting the key rotation bounding and validation improvements in v17.6.0. All 58 tests pass. * fix(javascript): KSM-1128 move shape guard below customKey branch When the server sends {"error":"key"} to a client with a pinned custom server public key (IL5 config), the IL5 diagnostic now always fires regardless of whether key_id is present or valid. Previously the shape guard above the customKey check intercepted malformed key_id values and produced a generic error instead of the actionable IL5 message. * fix(javascript): KSM-1128 apply non-blocking cosmetic fixes - IL5 diagnostic falls back to transmissionKey.publicKeyId when storage has no serverPublicKeyId yet, so the message never reads "id null" - Membership error derives the supported range from keeperPublicKeys keys at runtime instead of the hardcoded literal "7-18", so the message stays accurate when a new key is added to the table * fix(javascript): KSM-1128 rename rotation tests to behavior-based names The three tests added for the retry bound and membership check were prefixed "IL5 dynamic key - ..." but they test generic postQuery rotation behavior, not IL5-specific code paths. Renamed to describe what each test asserts. --- sdk/javascript/packages/core/CHANGELOG.md | 1 + sdk/javascript/packages/core/src/keeper.ts | 23 ++++- .../packages/core/test/keeper.test.ts | 94 +++++++++++++++++-- 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/sdk/javascript/packages/core/CHANGELOG.md b/sdk/javascript/packages/core/CHANGELOG.md index f0e620e07..e74a54143 100644 --- a/sdk/javascript/packages/core/CHANGELOG.md +++ b/sdk/javascript/packages/core/CHANGELOG.md @@ -6,6 +6,7 @@ - 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. +- KSM-1128 - Bounded the server key-rotation retry in `postQuery`. When the server sends `{"error":"key"}`, the code retries at most 3 times before throwing a typed `KeeperError`, instead of retrying forever. Before storing a suggested `key_id`, the code validates its shape (positive integer) and its membership in the bundled key table (keys 7-18). An unsupported key id can no longer corrupt the configuration. The pinned custom-key path does not change. - Maintenance: Updated `minimatch`, `@babel/core`, and `handlebars` dev dependencies. ## 17.5.0 diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index 630abfa55..639d7f7cf 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -1,7 +1,7 @@ import {EncryptedPayload, KeeperHttpResponse, KeyValueStorage, platform, TransmissionKey} from './platform' import {webSafe64FromBytes, webSafe64ToBytes, tryParseInt} from './utils' import {parseNotation} from './notation' -import {KeeperThrottleError} from './errors' +import {KeeperError, KeeperThrottleError} from './errors' export {KeyValueStorage} from './platform' @@ -19,6 +19,10 @@ const KEY_PRIVATE_KEY = 'privateKey' // The client's private key // per clientId+endpoint (100 requests / 10s window; memcached TTL 10s that resets on every // request, so the counter only clears after 10s of silence). const MAX_THROTTLE_RETRIES = 5 +// Bounds the server-key-rotation retry (postQuery's `error === 'key'` branch, no custom key +// pinned): one legitimate rotation should resolve it, so this only needs to tolerate a little +// slack, not act as a real retry budget. +const MAX_KEY_ROTATION_RETRIES = 3 const BASE_THROTTLE_DELAY_SEC = 11 // 1s safety margin over the backend's 10s memcached TTL const MAX_THROTTLE_DELAY_SEC = 176 // same ceiling the exponential branch reaches at the last retry (11 * 2**4) const CLIENT_ID_HASH_TAG = 'KEEPER_SECRETS_MANAGER_CLIENT_ID' // Tag for hashing the client key to client id @@ -788,6 +792,7 @@ const postQuery = async (options: SecretManagerOptions, path: string, payload: A const url = `https://${hostName}/api/rest/sm/v1/${path}` const sleep = options.throttleSleep || ((ms: number) => new Promise(resolve => setTimeout(resolve, ms))) let throttleAttempt = 0 + let keyRotationAttempt = 0 while (true) { const transmissionKey = await generateTransmissionKey(options.storage) const encryptedPayload = await encryptAndSignPayload(options.storage, transmissionKey, payload) @@ -815,12 +820,24 @@ const postQuery = async (options: SecretManagerOptions, path: string, payload: A let errorObj: KeeperApiError | null = null try { errorObj = JSON.parse(errorMessage) } catch {} if (errorObj?.error === 'key') { + const suggestedKeyId = errorObj.key_id const customKey = await options.storage.getString(KEY_SERVER_PUBLIC_KEY) if (customKey) { const currentKeyId = await options.storage.getString(KEY_SERVER_PUBLIC_KEY_ID) - throw new Error(`Server rejected the custom server public key (id ${currentKeyId}). The server suggested key id ${errorObj.key_id}. Please update your IL5 KSM configuration.`) + throw new KeeperError(`Server rejected the custom server public key (id ${currentKeyId ?? transmissionKey.publicKeyId}). The server suggested key id ${suggestedKeyId}. Please update your IL5 KSM configuration.`) } - await options.storage.saveString(KEY_SERVER_PUBLIC_KEY_ID, errorObj.key_id!.toString()) + if (typeof suggestedKeyId !== 'number' || !Number.isInteger(suggestedKeyId) || suggestedKeyId <= 0) { + throw new KeeperError(`Server key error response contains invalid key_id: ${JSON.stringify(suggestedKeyId)}`) + } + if (!(suggestedKeyId in keeperPublicKeys)) { + const supported = Object.keys(keeperPublicKeys) + throw new KeeperError(`Server suggested unsupported key id ${suggestedKeyId}; this SDK version supports key ids ${supported[0]}-${supported[supported.length - 1]}`) + } + if (keyRotationAttempt >= MAX_KEY_ROTATION_RETRIES) { + throw new KeeperError(`Server key rotation exhausted ${MAX_KEY_ROTATION_RETRIES} retries; transmission key id ${transmissionKey.publicKeyId} was not accepted`) + } + await options.storage.saveString(KEY_SERVER_PUBLIC_KEY_ID, suggestedKeyId.toString()) + keyRotationAttempt++ continue } } else { diff --git a/sdk/javascript/packages/core/test/keeper.test.ts b/sdk/javascript/packages/core/test/keeper.test.ts index 1dda1de3e..c37f8580a 100644 --- a/sdk/javascript/packages/core/test/keeper.test.ts +++ b/sdk/javascript/packages/core/test/keeper.test.ts @@ -10,6 +10,10 @@ import { import * as fs from 'fs' +const FAKE_ONE_TIME_TOKEN = 'YyIhK5wXFHj36wGBAOmBsxI3v5rIruINrC8KXjyM58c' + +const keyErrorResponse = (keyId: number) => JSON.stringify({ error: 'key', key_id: keyId }) + test('Get secrets e2e', async () => { const responses: { transmissionKey: string, data: string, statusCode: number } [] = JSON.parse(fs.readFileSync('../../../fake_data.json').toString()) @@ -317,20 +321,96 @@ test('IL5 dynamic key - rotation suppression: server key_id hint ignored when se expect(await storage.getString('serverPublicKeyId')).toBe('20') }) +test('key rotation - retries are bounded, not infinite', async () => { + const storage = inMemoryStorage({}) + await initializeStorage(storage, FAKE_ONE_TIME_TOKEN, 'fake.keepersecurity.com') + let calls = 0 + const enc = new TextEncoder() + const options: SecretManagerOptions = { + storage, + queryFunction: async () => { + calls++ + if (calls > 50) { + throw new Error('runaway loop detected in key rotation retry') + } + return { statusCode: 400, data: enc.encode(keyErrorResponse(7)), headers: [] } + } + } + await expect(getSecrets(options)).rejects.toThrow(/key rotation exhausted/i) + // MAX_KEY_ROTATION_RETRIES = 3: initial attempt + 3 retries = 4 total calls. + expect(calls).toBe(4) +}) + +test('key rotation - suggested key id is adopted and persisted', async () => { + const storage = inMemoryStorage({}) + await initializeStorage(storage, FAKE_ONE_TIME_TOKEN, 'fake.keepersecurity.com') + let calls = 0 + const enc = new TextEncoder() + const emptyResponse = enc.encode(JSON.stringify({ records: [], folders: [], expiresOn: 0, warnings: [] })) + const options: SecretManagerOptions = { + storage, + queryFunction: async (_url, tk) => { + calls++ + if (calls > 50) { + throw new Error('runaway loop detected in key rotation retry') + } + if (calls === 1) { + return { statusCode: 400, data: enc.encode(keyErrorResponse(8)), headers: [] } + } + // Verify the rotation was adopted: second request should use key_id 8. + expect(tk.publicKeyId).toBe(8) + return { statusCode: 200, data: await platform.encryptWithKey(emptyResponse, tk.key), headers: [] } + } + } + const secrets = await getSecrets(options) + expect(secrets.records).toEqual([]) + expect(calls).toBe(2) + // Verify the suggested key_id 8 was persisted to storage. + expect(await storage.getString('serverPublicKeyId')).toBe('8') +}) + +test('key rotation - unsupported suggested key id is rejected, not persisted', async () => { + const storage = inMemoryStorage({}) + await initializeStorage(storage, FAKE_ONE_TIME_TOKEN, 'fake.keepersecurity.com') + let calls = 0 + const enc = new TextEncoder() + const options: SecretManagerOptions = { + storage, + queryFunction: async () => { + calls++ + if (calls > 50) { + throw new Error('runaway loop detected in key rotation retry') + } + return { statusCode: 400, data: enc.encode(keyErrorResponse(99)), headers: [] } + } + } + await expect(getSecrets(options)).rejects.toThrow(/unsupported key id 99/) + // Rejected before the retry loop persists anything: one request, config untouched. + expect(calls).toBe(1) + expect(await storage.getString('serverPublicKeyId')).toBeUndefined() +}) + test('stale pinned server key: diagnostic message propagates to caller, key preserved', async () => { const fakeKey = 'BK9w6TZFxE6nFNbMfIpULCup2a8xc6w2tUTABjxny7yFmxW0dAEojwC6j6zb5nTlmb1dAx8nwo3qF7RPYGmloRM' const storage = inMemoryStorage({}) - await initializeStorage(storage, 'YyIhK5wXFHj36wGBAOmBsxI3v5rIruINrC8KXjyM58c', 'fake.keepersecurity.com') + await initializeStorage(storage, FAKE_ONE_TIME_TOKEN, 'fake.keepersecurity.com') await storage.saveString('serverPublicKey', fakeKey) await storage.saveString('serverPublicKeyId', '20') - const keyError = JSON.stringify({ error: 'key', key_id: 7 }) + let calls = 0 + const enc = new TextEncoder() const options: SecretManagerOptions = { storage, - queryFunction: async () => ({ - statusCode: 400, - data: new TextEncoder().encode(keyError), - headers: [] - }) + queryFunction: async () => { + calls++ + if (calls > 50) { + throw new Error('runaway loop detected') + } + return { + statusCode: 400, + data: enc.encode(keyErrorResponse(7)), + headers: [] + } + } } await expect(getSecrets(options)).rejects.toThrow(/Server rejected the custom server public key/) await expect(getSecrets(options)).rejects.toThrow(/Please update your IL5 KSM configuration/) From 1ffba2b55ceb0adeda05dec1b49c6d3e64b0385c Mon Sep 17 00:00:00 2001 From: stas-schaller Date: Thu, 27 Aug 2026 17:56:40 -0400 Subject: [PATCH 11/20] JavaScript SDK: fix Node platform hash() ignoring tag parameter (KSM-1254) (#1135) * fix(javascript): Node platform hash() ignores tag parameter (KSM-1254) hash() hardcoded 'KEEPER_SECRETS_MANAGER_CLIENT_ID' instead of hashing with its tag parameter, unlike the browser implementation and the Platform contract. No behavior change for the SDK's only caller, which already passed that same string as the tag. * test(javascript): pin the client id digest and guard Node/browser hash parity The two tests already on this branch prove hash() honors its tag, but nothing holds the result to a value computed outside the SDK, and nothing holds the two platform implementations to each other. Both gaps are what let the Node implementation hardcode its tag unnoticed since the initial commit: TypeScript accepts a lower-arity function for a higher-arity signature, so the compiler never objected, and every call site inside the SDK passes the one tag the buggy code hardcoded, so no integration-level test could tell the two apart. Add a separate file so the stacked KSM-1209 work, which appends to nodePlatform.test.ts, rebases without a conflict. - Pin the client id digest for a fixed key against a value cross-checked with the Python SDK's hmac.new(client_key_bytes, CLIENT_ID_HASH_TAG, 'sha512'), rather than recomputing it with the same call the implementation makes. - Assert the Node and browser implementations agree on a tag that appears nowhere in the SDK, which is the only input that separates an implementation honoring its tag from one hardcoding CLIENT_ID_HASH_TAG. Verified against the pre-fix implementation: the parity test fails, and the pinned digest still passes, which is the evidence for this branch's claim that the fix changes no behavior for existing callers. --------- Co-authored-by: Mateo Gallego --- sdk/javascript/packages/core/CHANGELOG.md | 1 + .../packages/core/src/node/nodePlatform.ts | 4 +-- .../core/test/hashCompatibility.test.ts | 31 +++++++++++++++++++ .../packages/core/test/nodePlatform.test.ts | 17 ++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 sdk/javascript/packages/core/test/hashCompatibility.test.ts create mode 100644 sdk/javascript/packages/core/test/nodePlatform.test.ts diff --git a/sdk/javascript/packages/core/CHANGELOG.md b/sdk/javascript/packages/core/CHANGELOG.md index e74a54143..d112b3cd0 100644 --- a/sdk/javascript/packages/core/CHANGELOG.md +++ b/sdk/javascript/packages/core/CHANGELOG.md @@ -7,6 +7,7 @@ - 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. - KSM-1128 - Bounded the server key-rotation retry in `postQuery`. When the server sends `{"error":"key"}`, the code retries at most 3 times before throwing a typed `KeeperError`, instead of retrying forever. Before storing a suggested `key_id`, the code validates its shape (positive integer) and its membership in the bundled key table (keys 7-18). An unsupported key id can no longer corrupt the configuration. The pinned custom-key path does not change. +- KSM-1254 - Fixed the Node platform's `hash()` ignoring its `tag` parameter and always hashing with a hardcoded string; it now hashes with the caller-supplied tag, matching the browser implementation and the `Platform` contract. No behavior change for existing callers (the SDK's only caller already passed that same string). - Maintenance: Updated `minimatch`, `@babel/core`, and `handlebars` dev dependencies. ## 17.5.0 diff --git a/sdk/javascript/packages/core/src/node/nodePlatform.ts b/sdk/javascript/packages/core/src/node/nodePlatform.ts index ced9b381f..f8e98a503 100644 --- a/sdk/javascript/packages/core/src/node/nodePlatform.ts +++ b/sdk/javascript/packages/core/src/node/nodePlatform.ts @@ -166,8 +166,8 @@ const decrypt = async (data: Uint8Array, keyId: string, storage?: KeyValueStorag return await _decrypt(data, key, useCBC) } -function hash(data: Uint8Array): Promise { - const hash = createHmac('sha512', data).update('KEEPER_SECRETS_MANAGER_CLIENT_ID').digest() +function hash(data: Uint8Array, tag: string): Promise { + const hash = createHmac('sha512', data).update(tag).digest() return Promise.resolve(hash) } diff --git a/sdk/javascript/packages/core/test/hashCompatibility.test.ts b/sdk/javascript/packages/core/test/hashCompatibility.test.ts new file mode 100644 index 000000000..9b0cfbc5c --- /dev/null +++ b/sdk/javascript/packages/core/test/hashCompatibility.test.ts @@ -0,0 +1,31 @@ +import {nodePlatform} from '../src/node/nodePlatform' +import {browserPlatform} from '../src/browser/browserPlatform' + +// hash() is HMAC-SHA512 keyed by `data`, over the message `tag`, and not the other way round. +// The client id every SDK sends at binding time is that digest, so what this file pins is wire +// format shared with the Python, Java, .NET, Go, Rust and Ruby SDKs, not a Node-local detail. +const CLIENT_ID_HASH_TAG = 'KEEPER_SECRETS_MANAGER_CLIENT_ID' + +// A pinned digest rather than one recomputed with the same crypto call the implementation makes: +// a recomputed expectation drifts along with the implementation, and this value must not drift. +// Cross-checked against the Python SDK's +// hmac.new(client_key_bytes, b'KEEPER_SECRETS_MANAGER_CLIENT_ID', 'sha512'). +test('hash produces the client id digest the other SDKs produce for the same client key', async () => { + const clientKey = Buffer.from('0123456789abcdef0123456789abcdef', 'hex') + const digest = await nodePlatform.hash(clientKey, CLIENT_ID_HASH_TAG) + expect(Buffer.from(digest).toString('hex')).toBe( + 'e25a52879c9913c1ee272ea381e6fab73ad219a61f45fe74633dfa37496b187b' + + '7b1c3d1fafdf1ab8738a4e5802b80d3acbb5356c6c54884b8e2fdc46e8e79f5e') +}) + +// The guard against a Node hash() that ignores its tag again. Every call site inside the SDK +// passes CLIENT_ID_HASH_TAG, so an implementation that hardcoded that string would still agree +// with the browser everywhere the SDK itself looks; only a tag the code does not contain +// separates the two. A caller outside the SDK is free to pass one. +test('node and browser hash agree on a tag the SDK does not use internally', async () => { + const data = new TextEncoder().encode('client-key-bytes') + const tag = 'SOME_OTHER_TAG' + const nodeDigest = await nodePlatform.hash(data, tag) + const browserDigest = await browserPlatform.hash(data, tag) + expect(Buffer.from(nodeDigest).equals(Buffer.from(browserDigest))).toBe(true) +}) diff --git a/sdk/javascript/packages/core/test/nodePlatform.test.ts b/sdk/javascript/packages/core/test/nodePlatform.test.ts new file mode 100644 index 000000000..ff9202cae --- /dev/null +++ b/sdk/javascript/packages/core/test/nodePlatform.test.ts @@ -0,0 +1,17 @@ +import {nodePlatform} from '../src/node/nodePlatform' +import {createHmac} from 'crypto' + +test('hash produces different digests for different tags with the same data', async () => { + const data = new TextEncoder().encode('client-key-bytes') + const digestA = await nodePlatform.hash(data, 'TAG_A') + const digestB = await nodePlatform.hash(data, 'TAG_B') + expect(Buffer.from(digestA).equals(Buffer.from(digestB))).toBe(false) +}) + +test('hash matches an independently computed HMAC-SHA512 over data and tag', async () => { + const data = new TextEncoder().encode('client-key-bytes') + const tag = 'KEEPER_SECRETS_MANAGER_CLIENT_ID' + const digest = await nodePlatform.hash(data, tag) + const expected = createHmac('sha512', data).update(tag).digest() + expect(Buffer.from(digest).equals(expected)).toBe(true) +}) From 6531c11d42a614f65c168dbb5f34f8edb424ea7e Mon Sep 17 00:00:00 2001 From: Mateo Gallego Date: Fri, 28 Aug 2026 10:30:50 -0700 Subject: [PATCH 12/20] fix(ci): KSM-1334 run JS test matrix on release-branch PRs (#1140) test.js.yml triggered only on pull_request into master, so every JS core fix that goes to a release branch first, which is all of them, merged with no test signal. The only checks on those PRs are the Socket Security scans, and a green tick there says nothing about whether the SDK still passes. Add release/sdk/javascript/core/** to the pull_request branch filter. The workflow already lists itself under paths, so this change gates itself. Deliberately not adding a push trigger on release/**: that is the duplicate-run pattern KSM-1302 tracks in test.ruby.yml and the four JavaScript KMS workflows, where the pull_request run has already tested the same commit. Follows PR #1119, which made the same change to test.java.yml for KSM-1269. Both are legs of KSM-1284, which tracks the remaining SDK core workflows. --- .github/workflows/test.js.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.js.yml b/.github/workflows/test.js.yml index 0cce09802..5ccca119d 100644 --- a/.github/workflows/test.js.yml +++ b/.github/workflows/test.js.yml @@ -1,8 +1,13 @@ name: Test-JS on: + # Runs on the merge result (head merged into base), so it gates a change before it lands on + # a JS core release branch as well as on master. Deliberately not also on push to release/**, + # which would only re-test a commit this pull_request run already tested. pull_request: - branches: [ master ] + branches: + - master + - 'release/sdk/javascript/core/**' paths: - 'sdk/javascript/packages/core/**' - '.github/workflows/test.js.yml' From 96b60f99aaf62dff7c29f3ec2958183c729bcbb9 Mon Sep 17 00:00:00 2001 From: Mateo Gallego Date: Fri, 28 Aug 2026 10:38:11 -0700 Subject: [PATCH 13/20] fix(javascript): KSM-1332 reject instead of hanging on an IndexedDB failure (#1141) Every IndexedDB call in the browser config storage wired only onsuccess, plus onupgradeneeded on the two open calls. A failing IDBRequest fires onerror and never onsuccess, so any storage failure left the returned promise pending forever: the caller waited with no error, no rejection and no timeout. Four of the eight wrappers even destructured reject from the Promise executor and never called it. secureStorage is the worst case. It awaits a read before it returns the storage object, so a failure hung the constructor itself and never handed the caller anything to catch. - Wire onerror on all eight requests, rejecting with a typed KeeperError that carries the underlying DOMException name and message. - Wire onblocked on both indexedDB.open calls. It fires when another live connection holds the database during a version change, and neither onsuccess nor onerror follows it. - Catch the synchronous throw from transaction() when the object store is missing. A throw inside an event handler is not caught by the enclosing Promise executor, so that path left the promise pending too. The cause is duck-typed on name and message rather than tested with instanceof Error: a DOMException only satisfies instanceof Error in the realm it was constructed in, so a cross-realm failure would otherwise lose the diagnostic. Adds test/browserConfigStorage.test.ts, covering both exported storages with a minimal IndexedDB double and no new dependency. Each failure test races a short timer so a regression fails fast with "promise never settled" instead of stalling the suite. Verified against the unfixed file: all eight failure tests fail there, and both happy-path tests pass on either side. --- sdk/javascript/packages/core/CHANGELOG.md | 1 + .../core/src/browser/localConfigStorage.ts | 68 +++++++- .../core/test/browserConfigStorage.test.ts | 165 ++++++++++++++++++ 3 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 sdk/javascript/packages/core/test/browserConfigStorage.test.ts diff --git a/sdk/javascript/packages/core/CHANGELOG.md b/sdk/javascript/packages/core/CHANGELOG.md index d112b3cd0..0e8d1d2eb 100644 --- a/sdk/javascript/packages/core/CHANGELOG.md +++ b/sdk/javascript/packages/core/CHANGELOG.md @@ -8,6 +8,7 @@ - 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. - KSM-1128 - Bounded the server key-rotation retry in `postQuery`. When the server sends `{"error":"key"}`, the code retries at most 3 times before throwing a typed `KeeperError`, instead of retrying forever. Before storing a suggested `key_id`, the code validates its shape (positive integer) and its membership in the bundled key table (keys 7-18). An unsupported key id can no longer corrupt the configuration. The pinned custom-key path does not change. - KSM-1254 - Fixed the Node platform's `hash()` ignoring its `tag` parameter and always hashing with a hardcoded string; it now hashes with the caller-supplied tag, matching the browser implementation and the `Platform` contract. No behavior change for existing callers (the SDK's only caller already passed that same string). +- KSM-1332 - Fixed the browser IndexedDB storage hanging forever on a storage failure. `localConfigStorage` and `secureStorage` wired only `onsuccess`, so a failed IndexedDB open, read, write or delete left the promise pending with no error, no rejection and no timeout. All eight wrappers now reject with a typed `KeeperError`, and the blocked-upgrade and missing-object-store paths reject too instead of hanging. - Maintenance: Updated `minimatch`, `@babel/core`, and `handlebars` dev dependencies. ## 17.5.0 diff --git a/sdk/javascript/packages/core/src/browser/localConfigStorage.ts b/sdk/javascript/packages/core/src/browser/localConfigStorage.ts index 1cf457bfc..c7d429def 100644 --- a/sdk/javascript/packages/core/src/browser/localConfigStorage.ts +++ b/sdk/javascript/packages/core/src/browser/localConfigStorage.ts @@ -1,15 +1,56 @@ import {EncryptedPayload, KeeperHttpResponse, KeyValueStorage, TransmissionKey, platform} from "../platform"; +import {KeeperError} from "../errors"; + +type Reject = (reason: Error) => void + +// Duck-typed rather than `instanceof Error`. IndexedDB reports failures as a DOMException, and +// whether that satisfies `instanceof Error` depends on the realm it was constructed in, so a +// cross-realm failure (a worker, an iframe) would otherwise lose the diagnostic entirely. +const describeCause = (cause: unknown): string => { + const {name, message} = (cause ?? {}) as { name?: unknown, message?: unknown } + if (typeof name === 'string' && typeof message === 'string') return `${name}: ${message}` + if (typeof message === 'string') return message + return 'unknown error' +} + +const idbFailure = (operation: string, cause: unknown): KeeperError => + new KeeperError(`IndexedDB ${operation} failed: ${describeCause(cause)}`) + +// A failing IDBRequest fires onerror and never onsuccess, so a promise that only handles +// onsuccess stays pending forever. The caller then hangs with no error, no rejection and no +// timeout, which is far worse than failing. Every request in this file is wired through here. +const rejectOnError = (request: IDBRequest, reject: Reject, operation: string): void => { + request.onerror = () => reject(idbFailure(operation, request.error)) +} + +const rejectOnOpenFailure = (request: IDBOpenDBRequest, reject: Reject, dbName: string): void => { + rejectOnError(request, reject, `open of database '${dbName}'`) + // onblocked fires when another live connection holds the database at the old version during + // an upgrade. Neither onsuccess nor onerror follows it, so this is the only chance to settle. + request.onblocked = () => reject(new KeeperError( + `IndexedDB open of database '${dbName}' is blocked by another open connection to it`)) +} export const localConfigStorage = (client: string, useObjects: boolean): KeyValueStorage => { + const STORE_NAME = 'secrets' + const getObjectStore = async (mode: IDBTransactionMode): Promise => new Promise(((resolve, reject) => { const request = indexedDB.open(client, 1) + rejectOnOpenFailure(request, reject, client) request.onupgradeneeded = () => { - request.result.createObjectStore('secrets'); + request.result.createObjectStore(STORE_NAME); } request.onsuccess = () => { - resolve(request.result.transaction('secrets', mode).objectStore('secrets')) + // transaction() throws synchronously when the store is missing, and a throw + // inside an event handler is not caught by the Promise executor, so it has to + // be turned into a rejection here or the promise never settles. + try { + resolve(request.result.transaction(STORE_NAME, mode).objectStore(STORE_NAME)) + } catch (e) { + reject(idbFailure(`transaction on store '${STORE_NAME}'`, e)) + } } })) @@ -17,6 +58,7 @@ export const localConfigStorage = (client: string, useObjects: boolean): KeyValu const objectStore = await getObjectStore('readonly') return new Promise(((resolve, reject) => { const request = objectStore.get(key) + rejectOnError(request, reject, `read of key '${key}'`) request.onsuccess = () => { resolve(request.result) } @@ -32,6 +74,7 @@ export const localConfigStorage = (client: string, useObjects: boolean): KeyValu const objectStore = await getObjectStore('readwrite') return new Promise(((resolve, reject) => { const request = objectStore.put(value, key) + rejectOnError(request, reject, `write of key '${key}'`) request.onsuccess = () => { resolve() } @@ -42,6 +85,7 @@ export const localConfigStorage = (client: string, useObjects: boolean): KeyValu const objectStore = await getObjectStore('readwrite') return new Promise(((resolve, reject) => { const request = objectStore.delete(key) + rejectOnError(request, reject, `delete of key '${key}'`) request.onsuccess = () => { resolve() } @@ -72,32 +116,42 @@ export const secureStorage = async (dbName: string): Promise => const META_KEY = '__secureKey__' const getObjectStore = async (mode: IDBTransactionMode): Promise => - new Promise((resolve) => { + new Promise((resolve, reject) => { const req = indexedDB.open(dbName, 1) + rejectOnOpenFailure(req, reject, dbName) req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME) - req.onsuccess = () => resolve(req.result.transaction(STORE_NAME, mode).objectStore(STORE_NAME)) + req.onsuccess = () => { + try { + resolve(req.result.transaction(STORE_NAME, mode).objectStore(STORE_NAME)) + } catch (e) { + reject(idbFailure(`transaction on store '${STORE_NAME}'`, e)) + } + } }) const getRaw = async (key: string): Promise => { const store = await getObjectStore('readonly') - return new Promise(resolve => { + return new Promise((resolve, reject) => { const r = store.get(key) + rejectOnError(r, reject, `read of key '${key}'`) r.onsuccess = () => resolve(r.result) }) } const putRaw = async (key: string, value: any): Promise => { const store = await getObjectStore('readwrite') - return new Promise(resolve => { + return new Promise((resolve, reject) => { const r = store.put(value, key) + rejectOnError(r, reject, `write of key '${key}'`) r.onsuccess = () => resolve() }) } const delRaw = async (key: string): Promise => { const store = await getObjectStore('readwrite') - return new Promise(resolve => { + return new Promise((resolve, reject) => { const r = store.delete(key) + rejectOnError(r, reject, `delete of key '${key}'`) r.onsuccess = () => resolve() }) } diff --git a/sdk/javascript/packages/core/test/browserConfigStorage.test.ts b/sdk/javascript/packages/core/test/browserConfigStorage.test.ts new file mode 100644 index 000000000..757435bba --- /dev/null +++ b/sdk/javascript/packages/core/test/browserConfigStorage.test.ts @@ -0,0 +1,165 @@ +import {localConfigStorage, secureStorage} from '../src/browser/localConfigStorage' +import {KeeperError} from '../src/errors' + +// How the fake database behaves. Each mode drives one of the paths that, before this fix, left a +// promise pending forever instead of rejecting. +type FakeMode = 'ok' | 'openError' | 'openBlocked' | 'requestError' | 'missingStore' + +const later = (fn: () => void) => setTimeout(fn, 0) + +// Minimal IndexedDB double covering only the surface this module touches: indexedDB.open, the +// open request's four handlers, db.transaction().objectStore(), and the store's get/put/delete +// requests. Handlers fire on a later task, as the real implementation does, so a wrapper that +// never attaches one simply never settles, which is exactly the defect under test. +const installFakeIndexedDB = (mode: FakeMode) => { + const data = new Map() + + const request = (run: (req: any) => void) => { + const req: any = {onsuccess: null, onerror: null, result: undefined, error: null} + later(() => { + if (mode === 'requestError') { + req.error = new DOMException('the quota has been exceeded', 'QuotaExceededError') + req.onerror?.() + return + } + run(req) + req.onsuccess?.() + }) + return req + } + + const store = { + get: (key: string) => request(req => { req.result = data.get(key) }), + put: (value: any, key: string) => request(() => { data.set(key, value) }), + delete: (key: string) => request(() => { data.delete(key) }) + } + + const db = { + createObjectStore: () => store, + transaction: () => { + if (mode === 'missingStore') { + throw new DOMException('One of the specified object stores was not found.', 'NotFoundError') + } + return {objectStore: () => store} + } + } + + ;(globalThis as any).indexedDB = { + open: () => { + const req: any = { + onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null, + result: db, error: null + } + later(() => { + if (mode === 'openError') { + req.error = new DOMException('access to storage is denied', 'SecurityError') + req.onerror?.() + } else if (mode === 'openBlocked') { + req.onblocked?.() + } else { + req.onupgradeneeded?.() + req.onsuccess?.() + } + }) + return req + } + } +} + +// Every failure case here is a promise that used to never settle. Racing a short timer turns a +// regression into a fast, legible failure instead of a suite that stalls until Jest's timeout. +const rejectionFrom = async (p: Promise): Promise => { + let timer: ReturnType | undefined + const neverSettled = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('promise never settled: the failure path still hangs')), 500) + }) + try { + await Promise.race([p, neverSettled]) + return new Error('expected a rejection but the promise resolved') + } catch (e) { + return e + } finally { + clearTimeout(timer) + } +} + +const expectKeeperError = (err: unknown, pattern: RegExp) => { + expect(err).toBeInstanceOf(KeeperError) + expect((err as Error).message).toMatch(pattern) +} + +afterEach(() => { + delete (globalThis as any).indexedDB +}) + +describe('browser localConfigStorage', () => { + const storage = () => localConfigStorage('test-db', false) + + test('rejects when opening the database fails', async () => { + installFakeIndexedDB('openError') + expectKeeperError(await rejectionFrom(storage().getString('key')), + /open of database 'test-db' failed: SecurityError/) + }) + + test('rejects when the open is blocked by another connection', async () => { + installFakeIndexedDB('openBlocked') + expectKeeperError(await rejectionFrom(storage().getString('key')), + /open of database 'test-db' is blocked/) + }) + + test('rejects when the object store is missing', async () => { + installFakeIndexedDB('missingStore') + expectKeeperError(await rejectionFrom(storage().getString('key')), + /transaction on store 'secrets' failed: NotFoundError/) + }) + + test('rejects when a read fails', async () => { + installFakeIndexedDB('requestError') + expectKeeperError(await rejectionFrom(storage().getString('key')), + /read of key 'key' failed: QuotaExceededError/) + }) + + test('rejects when a write fails', async () => { + installFakeIndexedDB('requestError') + expectKeeperError(await rejectionFrom(storage().saveString('key', 'value')), + /write of key 'key' failed: QuotaExceededError/) + }) + + test('rejects when a delete fails', async () => { + installFakeIndexedDB('requestError') + expectKeeperError(await rejectionFrom(storage().delete('key')), + /delete of key 'key' failed: QuotaExceededError/) + }) + + test('still reads back what it wrote when IndexedDB is healthy', async () => { + installFakeIndexedDB('ok') + const s = storage() + await s.saveString('key', 'value') + expect(await s.getString('key')).toBe('value') + await s.delete('key') + expect(await s.getString('key')).toBeUndefined() + }) +}) + +describe('browser secureStorage', () => { + // secureStorage awaits a read before it returns the storage object, so an IndexedDB failure + // used to hang the constructor itself and never hand the caller anything to catch. + test('rejects when opening the database fails', async () => { + installFakeIndexedDB('openError') + expectKeeperError(await rejectionFrom(secureStorage('secure-db')), + /open of database 'secure-db' failed: SecurityError/) + }) + + test('rejects when a read fails', async () => { + installFakeIndexedDB('requestError') + expectKeeperError(await rejectionFrom(secureStorage('secure-db')), + /read of key '__secureKey__' failed: QuotaExceededError/) + }) + + test('still round-trips through encryption when IndexedDB is healthy', async () => { + installFakeIndexedDB('ok') + const s = await secureStorage('secure-db') + await s.saveString('key', 'value') + expect(await s.getString('key')).toBe('value') + }) +}) From d5de2bd6071c104e62e48fd29522f27f37fb429e Mon Sep 17 00:00:00 2001 From: Mateo Gallego Date: Tue, 1 Sep 2026 11:11:24 -0400 Subject: [PATCH 14/20] fix(javascript): require a real JSON parse in the readable-JSON heuristic _isReadableJson only checked whether the base64-decoded text started with '{' or '['. Genuine ciphertext has a ~1-in-128 chance of coincidentally decoding to a leading '{' or '[' byte, which made hasEncryptedData() (and hasReadableData()) misclassify real encrypted data as not encrypted. _isReadableJson now requires the text to actually parse as valid JSON, not just start with the right character, closing the gap consistently across hasReadableData, hasEncryptedData, and getLinkData. --- sdk/javascript/packages/core/src/keeper.ts | 11 ++++++++++- .../packages/core/test/record_link.test.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index 639d7f7cf..0c4cd1cbc 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -371,8 +371,17 @@ export class KeeperRecordLink { return typeof value === 'number' && !Number.isNaN(value) ? Math.trunc(value) : null } + // The leading-character check alone is not enough: encrypted data has roughly a 1-in-128 + // chance of coincidentally decoding to a leading '{' or '[' byte, which would otherwise get + // misread as plaintext JSON. Requiring an actual successful parse closes that gap. private static _isReadableJson(text: string): boolean { - return text.startsWith('{') || text.startsWith('[') + if (!(text.startsWith('{') || text.startsWith('['))) return false + try { + JSON.parse(text) + return true + } catch { + return false + } } private static _isPrintableText(text: string): boolean { diff --git a/sdk/javascript/packages/core/test/record_link.test.ts b/sdk/javascript/packages/core/test/record_link.test.ts index 373831a8f..64f9eea73 100644 --- a/sdk/javascript/packages/core/test/record_link.test.ts +++ b/sdk/javascript/packages/core/test/record_link.test.ts @@ -342,3 +342,21 @@ test('ciphertext coincidentally starting with { or [ still decrypts (fallthrough // Plain JSON fast path is unaffected expect(await plainLink({a: 1}).getLinkData()).toEqual({a: 1}) }) + +// ── test 18 ──────────────────────────────────────────────────────────────────── +test('hasEncryptedData is not fooled by ciphertext coincidentally starting with { or [', () => { + const key = platform.getRandomBytes(32) + const plaintext = platform.stringToBytes(JSON.stringify({secret: 'value'})) + + for (const marker of [0x7b /* { */, 0x5b /* [ */]) { + const iv = new Uint8Array(12) + iv[0] = marker + randomBytes(11).copy(Buffer.from(iv.buffer), 1) + + const ciphertext = encryptWithCustomIv(plaintext, key, iv) + const link = new KeeperRecordLink({recordUid: 'RU', data: platform.bytesToBase64(ciphertext), path: undefined}, 'owner') + + expect(link.getDecodedData()!.charCodeAt(0)).toBe(marker) + expect(link.hasEncryptedData()).toBe(true) + } +}) From e8638752c969c61c7d7448644dddce0ee65e70cb Mon Sep 17 00:00:00 2001 From: stas-schaller Date: Tue, 1 Sep 2026 12:52:08 -0400 Subject: [PATCH 15/20] fix(javascript): config file permissions not corrected after each write (KSM-1263) (#1131) fs.openSync's mode argument only takes effect when the file is created, so a config file that already existed with looser permissions kept them. Permissions are now explicitly reset to 0600 after every write. --- sdk/javascript/packages/core/CHANGELOG.md | 1 + .../core/src/node/localConfigStorage.ts | 6 +++- .../core/test/localConfigStorage.test.ts | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 sdk/javascript/packages/core/test/localConfigStorage.test.ts diff --git a/sdk/javascript/packages/core/CHANGELOG.md b/sdk/javascript/packages/core/CHANGELOG.md index 0e8d1d2eb..687fa9ee8 100644 --- a/sdk/javascript/packages/core/CHANGELOG.md +++ b/sdk/javascript/packages/core/CHANGELOG.md @@ -9,6 +9,7 @@ - KSM-1128 - Bounded the server key-rotation retry in `postQuery`. When the server sends `{"error":"key"}`, the code retries at most 3 times before throwing a typed `KeeperError`, instead of retrying forever. Before storing a suggested `key_id`, the code validates its shape (positive integer) and its membership in the bundled key table (keys 7-18). An unsupported key id can no longer corrupt the configuration. The pinned custom-key path does not change. - KSM-1254 - Fixed the Node platform's `hash()` ignoring its `tag` parameter and always hashing with a hardcoded string; it now hashes with the caller-supplied tag, matching the browser implementation and the `Platform` contract. No behavior change for existing callers (the SDK's only caller already passed that same string). - KSM-1332 - Fixed the browser IndexedDB storage hanging forever on a storage failure. `localConfigStorage` and `secureStorage` wired only `onsuccess`, so a failed IndexedDB open, read, write or delete left the promise pending with no error, no rejection and no timeout. All eight wrappers now reject with a typed `KeeperError`, and the blocked-upgrade and missing-object-store paths reject too instead of hanging. +- KSM-1263 - Fixed config and cache file permissions not being re-applied on every write. `fs.openSync`'s mode argument only takes effect when a file is created, so a config or cache file that already existed with looser permissions kept them; permissions are now explicitly reset to 0600 after every write. - Maintenance: Updated `minimatch`, `@babel/core`, and `handlebars` dev dependencies. ## 17.5.0 diff --git a/sdk/javascript/packages/core/src/node/localConfigStorage.ts b/sdk/javascript/packages/core/src/node/localConfigStorage.ts index 5aaa715c2..012affffe 100644 --- a/sdk/javascript/packages/core/src/node/localConfigStorage.ts +++ b/sdk/javascript/packages/core/src/node/localConfigStorage.ts @@ -1,6 +1,10 @@ import {EncryptedPayload, KeeperHttpResponse, KeyValueStorage, platform, TransmissionKey, inMemoryStorage} from "../platform"; import * as fs from 'fs'; +// fs.openSync's mode argument is only honored when the file is created; it is a no-op on an +// existing file, so permissions must be re-asserted after every write, not just the first one. +const chmodSecure = (filePath: string) => fs.chmodSync(filePath, 0o600) + export const localConfigStorage = (configName?: string): KeyValueStorage => { const readStorage = (): any => { @@ -21,13 +25,13 @@ export const localConfigStorage = (configName?: string): KeyValueStorage => { if (!configName) { return } - // Create file with secure permissions (0600) const fd = fs.openSync(configName, 'w', 0o600) try { fs.writeSync(fd, JSON.stringify(storageData, null, 2)) } finally { fs.closeSync(fd) } + chmodSecure(configName) } return { diff --git a/sdk/javascript/packages/core/test/localConfigStorage.test.ts b/sdk/javascript/packages/core/test/localConfigStorage.test.ts new file mode 100644 index 000000000..2cbec1288 --- /dev/null +++ b/sdk/javascript/packages/core/test/localConfigStorage.test.ts @@ -0,0 +1,28 @@ +import {localConfigStorage} from '../' + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ksm-cache-test-')) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('localConfigStorage file permissions (KSM-1263)', () => { + test('re-asserts 0600 on save even if the file started more permissive', async () => { + const configPath = path.join(tmpDir, 'config.json') + fs.writeFileSync(configPath, '{}') + fs.chmodSync(configPath, 0o644) + + const kvs = localConfigStorage(configPath) + await kvs.saveString('foo', 'bar') + + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600) + }) +}) From d8e2b2d8fe421a956ca2317cb03367170c869acd Mon Sep 17 00:00:00 2001 From: Mateo Gallego Date: Tue, 1 Sep 2026 13:18:39 -0700 Subject: [PATCH 16/20] fix(javascript): KSM-1267 classify getFolders() decryption failures (#1152) * fix(javascript): KSM-1267 classify and surface getFolders() decryption failures getFolders() now classifies why an undecryptable folder was skipped (integrity, format, missing-key, malformed-data, or unknown) instead of logging an opaque, unclassified error, and logs one summary line naming every folder UID it had to omit. Adds an optional onDecryptionError callback to SecretManagerOptions, invoked once per skipped folder, so a caller can react to or throw to fail closed on a partial result; existing callers that do not set it see no behavior change. Both the Node and browser platforms' unwrap() now reject an unwrapped key of the wrong length immediately instead of caching it and failing later at an unrelated call site. The underlying finding (the shared-folder key wrap uses unauthenticated AES-256-CBC, a format fixed server-side that the SDK cannot change unilaterally) was reviewed and confirmed low-impact: a manipulated folder key is still caught by the existing AES-GCM authentication on the record keys inside that folder. * fix(javascript): KSM-1267 remove nonexistent getFolders2 reference in comment onDecryptionError's doc comment on SecretManagerOptions referenced getFolders2, which does not exist (unlike getSecrets/getSecrets2, getFolders has no "2" counterpart). --- sdk/javascript/packages/core/CHANGELOG.md | 1 + .../core/src/browser/browserPlatform.ts | 37 +- sdk/javascript/packages/core/src/errors.ts | 44 +++ sdk/javascript/packages/core/src/keeper.ts | 88 ++++- .../packages/core/src/node/nodePlatform.ts | 21 +- .../core/test/browserPlatform.test.ts | 171 +++++++++ .../packages/core/test/errors.test.ts | 38 ++ .../folderDecryptionCrossPlatform.test.ts | 213 +++++++++++ .../core/test/folderDecryptionErrors.test.ts | 350 ++++++++++++++++++ .../packages/core/test/nodePlatform.test.ts | 98 +++++ 10 files changed, 1047 insertions(+), 14 deletions(-) create mode 100644 sdk/javascript/packages/core/test/browserPlatform.test.ts create mode 100644 sdk/javascript/packages/core/test/errors.test.ts create mode 100644 sdk/javascript/packages/core/test/folderDecryptionCrossPlatform.test.ts create mode 100644 sdk/javascript/packages/core/test/folderDecryptionErrors.test.ts diff --git a/sdk/javascript/packages/core/CHANGELOG.md b/sdk/javascript/packages/core/CHANGELOG.md index 687fa9ee8..244acabff 100644 --- a/sdk/javascript/packages/core/CHANGELOG.md +++ b/sdk/javascript/packages/core/CHANGELOG.md @@ -10,6 +10,7 @@ - KSM-1254 - Fixed the Node platform's `hash()` ignoring its `tag` parameter and always hashing with a hardcoded string; it now hashes with the caller-supplied tag, matching the browser implementation and the `Platform` contract. No behavior change for existing callers (the SDK's only caller already passed that same string). - KSM-1332 - Fixed the browser IndexedDB storage hanging forever on a storage failure. `localConfigStorage` and `secureStorage` wired only `onsuccess`, so a failed IndexedDB open, read, write or delete left the promise pending with no error, no rejection and no timeout. All eight wrappers now reject with a typed `KeeperError`, and the blocked-upgrade and missing-object-store paths reject too instead of hanging. - KSM-1263 - Fixed config and cache file permissions not being re-applied on every write. `fs.openSync`'s mode argument only takes effect when a file is created, so a config or cache file that already existed with looser permissions kept them; permissions are now explicitly reset to 0600 after every write. +- KSM-1267 - `getFolders()` now classifies why an undecryptable folder was skipped (`integrity`, `format`, `missing-key`, or `malformed-data`) instead of logging an opaque, unclassified error, and logs one summary line naming every folder UID it had to omit. Added an optional `onDecryptionError` callback to `SecretManagerOptions`, invoked once per skipped folder, so a caller can react to or throw to fail closed on a partial result; existing callers that do not set it see no behavior change. Both the Node and browser platforms' `unwrap()` now reject an unwrapped key of the wrong length immediately (a corrupted-but-plausible 16- or 24-byte result was previously accepted by both platforms and cached, failing later at an unrelated call site with a much harder to diagnose error). The underlying finding (the shared-folder key wrap uses unauthenticated AES-256-CBC, a format fixed server-side that the SDK cannot change unilaterally) was reviewed and confirmed low-impact: a manipulated folder key is still caught by the existing AES-GCM authentication on the record keys inside that folder. - Maintenance: Updated `minimatch`, `@babel/core`, and `handlebars` dev dependencies. ## 17.5.0 diff --git a/sdk/javascript/packages/core/src/browser/browserPlatform.ts b/sdk/javascript/packages/core/src/browser/browserPlatform.ts index 097e8daa8..e256f6342 100644 --- a/sdk/javascript/packages/core/src/browser/browserPlatform.ts +++ b/sdk/javascript/packages/core/src/browser/browserPlatform.ts @@ -1,6 +1,6 @@ import {KeeperHttpResponse, KeyValueStorage, Platform} from '../platform' import {privateDerToPublicRaw} from '../utils' -import {KeeperError} from '../errors' +import {KeeperError, KeeperCryptoError} from '../errors' const bytesToBase64 = (data: Uint8Array): string => { const chunkSize = 0x8000 // String.fromCharCode has limitations @@ -56,7 +56,7 @@ const loadPrivateKey = async (keyId: string, storage: KeyValueStorage): Promise< } } if (!privateKey) { - throw new Error(`Unable to load the private key ${keyId}`) + throw new KeeperCryptoError(`Unable to load the private key ${keyId}`, 'missing-key', keyId) } keyCache[keyId] = privateKey return privateKey @@ -82,7 +82,10 @@ const loadKey = async (keyId: string, storage?: KeyValueStorage, useCBC?: boolea } } if (!key) { - throw new Error(`Unable to load the key ${cacheKey}`) + // Report the caller-facing keyId, not cacheKey: the `cbc:` cache-slot prefix is an + // internal detail of this platform's dual GCM/CBC key cache (see unwrap below), not + // something a caller of SecretManagerOptions.onDecryptionError should have to know about. + throw new KeeperCryptoError(`Unable to load the key ${keyId}`, 'missing-key', keyId) } keyCache[cacheKey] = key return key @@ -208,6 +211,9 @@ const _encrypt = async (data: Uint8Array, key: Uint8Array, useCBC?: boolean): Pr return __encrypt(data, _key, useCBC) } +// useCBC selects AES-256-CBC, which carries no MAC: this mode exists only because the vault's +// wire format for shared-folder key wraps and folder data is fixed to CBC server-side (KSM-1267) +// - it is not a template for new code; prefer the AES-256-GCM (default) path. const __encrypt = async (data: Uint8Array, key: CryptoKey, useCBC?: boolean): Promise => { const ivLen = useCBC ? 16 : 12 const algorithmName = useCBC ? 'AES-CBC' : 'AES-GCM' @@ -222,6 +228,22 @@ const __encrypt = async (data: Uint8Array, key: CryptoKey, useCBC?: boolean): Pr return encrypted } +const UNWRAPPED_KEY_LENGTH_BITS = 256 // every key this SDK unwraps is AES-256 + +// WebCrypto's unwrapKey only validates that the recovered raw bytes are a valid AES key size +// (128/192/256 bits, i.e. 16/24/32 bytes) - it does not know or enforce that this SDK's keys must +// specifically be AES-256, so a corrupted-but-plausible 16- or 24-byte result would otherwise be +// cached and used without error (see nodePlatform.ts's UNWRAPPED_KEY_LENGTH check, which performs +// the equivalent guard on Node's raw key bytes). CryptoKey has no raw bytes to check directly +// (unless exported, which requires extractable), but AesKeyAlgorithm.length reports the key size +// in bits without needing to export, so that is checked here instead. +const assertUnwrappedKeyLength = (unwrappedKey: CryptoKey, keyId: string): void => { + const lengthBits = (unwrappedKey.algorithm as AesKeyAlgorithm).length + if (lengthBits !== UNWRAPPED_KEY_LENGTH_BITS) { + throw new Error(`Unwrapped key ${keyId} has invalid length ${lengthBits / 8}, expected ${UNWRAPPED_KEY_LENGTH_BITS / 8}`) + } +} + const unwrap = async (key: Uint8Array, keyId: string, unwrappingKeyId: string, storage?: KeyValueStorage, memoryOnly?: boolean, useCBC?: boolean): Promise => { const loadKeyCBC = unwrappingKeyId === "appKey" ? false : useCBC const unwrappingKey = await loadKey(unwrappingKeyId, storage, loadKeyCBC) @@ -239,12 +261,18 @@ const unwrap = async (key: Uint8Array, keyId: string, unwrappingKeyId: string, s const unwrappedKeyGCM = await crypto.subtle.unwrapKey('raw', key.subarray(ivLen) as Uint8Array, unwrappingKey, unwrappingAlgo, 'AES-GCM', storage ? !storage.saveObject : false, ['encrypt', 'decrypt', 'unwrapKey']) + assertUnwrappedKeyLength(unwrappedKeyGCM, keyId) keyCache[keyId] = unwrappedKeyGCM if (useCBC) { + // A WebCrypto CryptoKey is bound to one algorithm, unlike Node's raw key bytes which work + // with either cipher - so the same unwrapped folder key must be imported a second time + // under the AES-CBC algorithm (cached separately as 'cbc:'+keyId) to also decrypt folder.data, + // which the vault always wraps in CBC regardless of which mode wrapped the folder key itself. const unwrappedKeyCBC = await crypto.subtle.unwrapKey('raw', key.subarray(ivLen) as Uint8Array, unwrappingKey, unwrappingAlgo, 'AES-CBC', storage ? !storage.saveObject : false, ['encrypt', 'decrypt', 'unwrapKey']) + assertUnwrappedKeyLength(unwrappedKeyCBC, keyId) keyCache[keyIdCBC] = unwrappedKeyCBC } @@ -278,6 +306,9 @@ const _decrypt = async (data: Uint8Array, key: Uint8Array, useCBC?: boolean): Pr return __decrypt(data, _key, useCBC) } +// See __encrypt above: no MAC, fixed server-side format, not a template for new code. A failure +// here means malformed input, not a verified integrity failure - and a decrypt success proves +// nothing about integrity either, since CBC has no MAC to fail (see KeeperCryptoError in errors.ts). const __decrypt = async (data: Uint8Array, key: CryptoKey, useCBC?: boolean): Promise => { const ivLen = useCBC ? 16 : 12 const algorithmName = useCBC ? 'AES-CBC' : 'AES-GCM' diff --git a/sdk/javascript/packages/core/src/errors.ts b/sdk/javascript/packages/core/src/errors.ts index 0040b5413..a45895186 100644 --- a/sdk/javascript/packages/core/src/errors.ts +++ b/sdk/javascript/packages/core/src/errors.ts @@ -29,3 +29,47 @@ export class KeeperThrottleError extends KeeperError { Object.setPrototypeOf(this, KeeperThrottleError.prototype) } } + +/** + * Why a crypto operation on one key/item failed, classified by the caller (which mode it + * requested) rather than by inspecting the thrown error - Node's OpenSSL and the browser's + * WebCrypto throw differently-shaped errors for the same failure, so sniffing error text or + * class names would classify the two platforms inconsistently. + * + * - 'integrity': an authenticated (AES-GCM) decrypt failed its tag check - the ciphertext or key + * was modified or does not match. + * - 'format': an unauthenticated (AES-256-CBC) decrypt failed - CBC has no MAC, so this means + * malformed input or padding, never a verified integrity failure. A CBC decrypt can also + * succeed on tampered input (see 'malformed-data'), so CBC success proves nothing either way. + * - 'missing-key': the key needed to perform the operation could not be found. + * - 'malformed-data': decryption succeeded but the resulting plaintext was not the expected + * shape (for example, not valid JSON). Most often reached via a CBC decrypt that succeeded on + * tampered ciphertext without producing the original plaintext. + * - 'unknown': the failure did not originate from a classified step. + */ +export type KeeperCryptoFailureReason = 'integrity' | 'format' | 'missing-key' | 'malformed-data' | 'unknown' + +/** + * Thrown for a classifiable crypto failure while processing one identified item (a folder, + * record, etc.), so a caller can distinguish e.g. a tampered/corrupt key (`integrity`) from one + * that is simply not present yet (`missing-key`) instead of seeing an opaque, unclassified error. + */ +export class KeeperCryptoError extends KeeperError { + constructor(message: string, public readonly failure: KeeperCryptoFailureReason, public readonly uid: string) { + super(message) + this.name = 'KeeperCryptoError' + // Restore the prototype chain so `instanceof` works across transpilation targets. + Object.setPrototypeOf(this, KeeperCryptoError.prototype) + } +} + +/** + * Passed to `SecretManagerOptions.onDecryptionError` for each item skipped because it could not + * be decrypted, so a caller can react (e.g. log, alert, or throw to fail closed) instead of only + * seeing a partial result with no indication anything was omitted. + */ +export type KeeperDecryptionErrorInfo = { + uid: string + failure: KeeperCryptoFailureReason + message: string +} diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index 0c4cd1cbc..baf857019 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -1,7 +1,7 @@ import {EncryptedPayload, KeeperHttpResponse, KeyValueStorage, platform, TransmissionKey} from './platform' import {webSafe64FromBytes, webSafe64ToBytes, tryParseInt} from './utils' import {parseNotation} from './notation' -import {KeeperError, KeeperThrottleError} from './errors' +import {KeeperError, KeeperThrottleError, KeeperCryptoError, KeeperCryptoFailureReason, KeeperDecryptionErrorInfo} from './errors' export {KeyValueStorage} from './platform' @@ -61,11 +61,16 @@ export type SecretManagerOptions = { serverPublicKeyId?: string // Override the sleep between throttle retries (primarily for tests). Defaults to setTimeout. throttleSleep?: (milliseconds: number) => Promise + // Called for each item skipped because it could not be decrypted (currently: folders from + // getFolders). Optional and additive: existing callers see no behavior change. + // Throw from this callback to abort the call instead of returning a partial result (fail closed). + onDecryptionError?: (info: KeeperDecryptionErrorInfo) => void } // Error classes live in a dependency-free module (errors.ts) to avoid a circular import with // utils.ts/platform code that throws them; re-exported here so the public API is unchanged. -export {KeeperError, KeeperThrottleError} from './errors' +export {KeeperError, KeeperThrottleError, KeeperCryptoError} from './errors' +export type {KeeperCryptoFailureReason, KeeperDecryptionErrorInfo} from './errors' // Returns a jitter multiplier in [0, 0.25). One-sided so the delay never drops below the // computed floor (retrying too soon just re-triggers the throttle); kept separate so @@ -1000,15 +1005,45 @@ const getSharedFolderUid = (folders: SecretsManagerResponseFolder[], parent: str } }; +// Converts a raw crypto/parse failure into a KeeperCryptoError classified by which mode this +// call site requested (CBC -> 'format', GCM -> 'integrity') - unless it is already a +// KeeperCryptoError (e.g. 'missing-key', thrown deep inside platform.unwrap/decrypt when the key +// itself can't be found), in which case that mode-independent classification is kept as is. +// Classifying by requested mode rather than by inspecting the thrown error is what keeps Node and +// the browser - whose crypto libraries throw differently-shaped errors for the same failure - +// agreeing on the classification (KSM-1267). +// Native crypto errors (OpenSSL in Node, WebCrypto in the browser) are not reliably `instanceof +// Error` in every host environment - notably, Node's own crypto bindings throw errors from a +// different realm than the one Jest's default test environment exposes as the global `Error`, +// so `instanceof Error` silently fails there even though `.message` is a plain string either way. +// Reading `.message` directly sidesteps that, on top of already being how every sibling catch +// block in this file (e.g. the console.error calls above) extracts a message from a caught error. +const errorMessage = (e: unknown): string => typeof (e as any)?.message === 'string' ? (e as any).message : String(e) + +const classifyCryptoFailure = (e: unknown, modeFailure: KeeperCryptoFailureReason, uid: string): KeeperCryptoError => + e instanceof KeeperCryptoError ? e : new KeeperCryptoError(errorMessage(e), modeFailure, uid) + +const runCrypto = async (op: () => Promise, modeFailure: KeeperCryptoFailureReason, uid: string): Promise => { + try { + return await op() + } catch (e) { + throw classifyCryptoFailure(e, modeFailure, uid) + } +} + const fetchAndDecryptFolders = async (options: SecretManagerOptions): Promise => { const storage = options.storage const payload = await prepareGetPayload(storage) const responseData = await postQuery(options, 'get_folders', payload) const response = JSON.parse(platform.bytesToString(responseData)) as SecretsManagerResponse const folders: KeeperFolder[] = [] + const skippedUids: string[] = [] if (response.folders) { for (const folder of response.folders) { try { + if (!folder.folderKey) { + throw new KeeperCryptoError(`Folder key missing for UID ${folder.folderUid}`, 'missing-key', folder.folderUid) + } let decryptedData: Uint8Array const decryptedFolder: KeeperFolder = { folderUid: folder.folderUid @@ -1017,21 +1052,56 @@ const fetchAndDecryptFolders = async (options: SecretManagerOptions): Promise platform.unwrap(platform.base64ToBytes(folder.folderKey), folder.folderUid, sharedFolderUid, storage, true, true), 'format', folder.folderUid) } else { - await platform.unwrap(platform.base64ToBytes(folder.folderKey), folder.folderUid, KEY_APP_KEY, storage, true, true) - decryptedData = await platform.decrypt(platform.base64ToBytes(folder.data), folder.folderUid, storage, true) + // unwrappingKeyId === appKey always forces the WRAPPING key to be loaded as + // GCM, regardless of useCBC (see nodePlatform.ts, browserPlatform.ts) - so this + // call never has a real CBC path for the folder key itself (KSM-1267). useCBC + // is still passed as true, though: on the browser platform it also makes + // unwrap() cache a second, CBC-mode copy of the just-unwrapped folder key + // (browserPlatform.ts's dual keyCache[keyId] / keyCache['cbc:'+keyId]), which + // the always-CBC folder.data decrypt below needs, since a WebCrypto CryptoKey + // is bound to one algorithm. Node's raw-byte keys need no second copy, so + // dropping this argument would silently break browser-only folder decryption. + await runCrypto(() => platform.unwrap(platform.base64ToBytes(folder.folderKey), folder.folderUid, KEY_APP_KEY, storage, true, true), 'integrity', folder.folderUid) } - decryptedFolder.name = JSON.parse(platform.bytesToString(decryptedData))['name'] + // folder.data is always wrapped in CBC by the vault, regardless of which mode + // wrapped the folder key above (KSM-1267), so a decrypt failure here is 'format', + // not 'integrity'. A CBC decrypt that succeeds on tampered ciphertext without + // recovering the original plaintext is caught below as 'malformed-data' rather + // than treated as a verified result - CBC success proves nothing about integrity. + decryptedData = await runCrypto(() => platform.decrypt(platform.base64ToBytes(folder.data), folder.folderUid, storage, true), 'format', folder.folderUid) + let parsedData: any + try { + parsedData = JSON.parse(platform.bytesToString(decryptedData)) + } catch { + throw new KeeperCryptoError(`Folder ${folder.folderUid} decrypted data is not valid JSON`, 'malformed-data', folder.folderUid) + } + decryptedFolder.name = parsedData['name'] folders.push(decryptedFolder) } catch (e: Error | any) { - console.error(`Folder ${folder.folderUid} skipped due to error: ${e.constructor.name}, ${e.message}`) + const failure: KeeperCryptoFailureReason = e instanceof KeeperCryptoError ? e.failure : 'unknown' + console.error(`Folder ${folder.folderUid} skipped due to error (${failure}): ${e.constructor.name}, ${e.message}`) + skippedUids.push(folder.folderUid) + if (options.onDecryptionError) { + // Not caught: throwing from the caller's callback aborts getFolders() instead + // of returning a partial result, letting a caller that needs strict integrity + // guarantees fail closed rather than silently continue on partial data. + options.onDecryptionError({uid: folder.folderUid, failure, message: e.message}) + } } } } + if (skippedUids.length > 0) { + console.error(`getFolders: ${skippedUids.length} of ${response.folders.length} folder(s) could not be decrypted and were omitted from the result: ${skippedUids.join(', ')}`) + } return folders } diff --git a/sdk/javascript/packages/core/src/node/nodePlatform.ts b/sdk/javascript/packages/core/src/node/nodePlatform.ts index f8e98a503..eddd67edd 100644 --- a/sdk/javascript/packages/core/src/node/nodePlatform.ts +++ b/sdk/javascript/packages/core/src/node/nodePlatform.ts @@ -1,6 +1,6 @@ import {KeeperHttpResponse, KeyValueStorage, Platform} from '../platform' import {privateDerToPublicRaw} from '../utils' -import {KeeperError} from '../errors' +import {KeeperError, KeeperCryptoError} from '../errors' import {request, RequestOptions} from 'https' import { createCipheriv, @@ -44,7 +44,7 @@ const loadKey = async (keyId: string, storage?: KeyValueStorage): Promise { let iv = randomBytes(16); let cipher = createCipheriv("aes-256-cbc", key, iv).setAutoPadding(true); @@ -141,6 +146,7 @@ const _decrypt = async (data: Uint8Array, key: Uint8Array, useCBC?: boolean): Pr return Buffer.concat([cipher.update(encrypted), cipher.final()]) } +// See _encryptCBC above: no MAC, fixed server-side format, not a template for new code. const _decryptCBC = (data: Uint8Array, key: Uint8Array): Uint8Array => { let iv = data.subarray(0, 16) let encrypted = data.subarray(16) @@ -148,10 +154,21 @@ const _decryptCBC = (data: Uint8Array, key: Uint8Array): Uint8Array => { return Buffer.concat([cipher.update(encrypted), cipher.final()]) } +const UNWRAPPED_KEY_LENGTH = 32 // every key this SDK unwraps is AES-256 + const unwrap = async (key: Uint8Array, keyId: string, unwrappingKeyId: string, storage?: KeyValueStorage, memoryOnly?: boolean, useCBC?: boolean): Promise => { const cbcDecrypt = unwrappingKeyId === "appKey" ? false : useCBC const unwrappingKey = await loadKey(unwrappingKeyId, storage) const unwrappedKey = await _decrypt(key, unwrappingKey, cbcDecrypt) + if (unwrappedKey.length !== UNWRAPPED_KEY_LENGTH) { + // WebCrypto's unwrapKey/importKey only validates that a raw AES key is a valid AES size + // (128/192/256 bits, i.e. 16/24/32 bytes) - it happily accepts a corrupted-but-plausible + // 16- or 24-byte result, so the browser platform needs this same explicit AES-256 check + // (see browserPlatform.ts's unwrap, which checks algorithm.length on the CryptoKey since + // it never has the raw bytes). Without this check here, Node would cache a malformed key + // and only fail later, at an unrelated call site, with a much harder to diagnose error. + throw new Error(`Unwrapped key ${keyId} has invalid length ${unwrappedKey.length}, expected ${UNWRAPPED_KEY_LENGTH}`) + } keyCache[keyId] = unwrappedKey if (memoryOnly) { return diff --git a/sdk/javascript/packages/core/test/browserPlatform.test.ts b/sdk/javascript/packages/core/test/browserPlatform.test.ts new file mode 100644 index 000000000..a632e0dc3 --- /dev/null +++ b/sdk/javascript/packages/core/test/browserPlatform.test.ts @@ -0,0 +1,171 @@ +import {browserPlatform} from '../src/browser/browserPlatform' +import {KeeperCryptoError} from '../src/errors' +import {KeyValueStorage} from '../src/platform' + +// These tests exercise browserPlatform directly against Node's real, built-in WebCrypto +// (crypto.subtle), which is a genuine, accurate stand-in for a browser's implementation - no +// jsdom or mock browser required (Node >=20, this package's engines requirement). + +// Minimal KeyValueStorage backed by plain Maps, so these tests can call browserPlatform's +// functions directly without depending on the module-level platform singleton (inMemoryStorage +// needs connectPlatform to have run first). Deliberately has no getObject/saveObject, so loadKey +// and loadPrivateKey exercise the getBytes/raw-import path rather than the object-storage path. +const makeStorage = (initial: Record = {}): KeyValueStorage => { + const bytes = new Map(Object.entries(initial)) + const strings = new Map() + return { + getString: async key => strings.get(key), + saveString: async (key, value) => { + strings.set(key, value) + }, + getBytes: async key => bytes.get(key), + saveBytes: async (key, value) => { + bytes.set(key, value) + }, + delete: async key => { + bytes.delete(key) + strings.delete(key) + }, + } +} + +// browserPlatform's key cache is a module-level singleton (see keyCache in browserPlatform.ts). +// unwrap()'s wrapping-key handling special-cases the literal id "appKey" (it forces that key to +// load as AES-GCM regardless of useCBC), so several tests below must reuse that exact string as +// the wrapping key id. Clearing the cache before each test keeps them independent of run order. +beforeEach(() => { + browserPlatform.cleanKeyCache() +}) + +test('unwrap throws a KeeperCryptoError with failure "missing-key" when the wrapping key (appKey) is not in storage', async () => { + const storage = makeStorage() + const wrappedFolderKey = new Uint8Array(28).fill(9) // never reached; loadKey throws first + const err = await browserPlatform + .unwrap(wrappedFolderKey, 'folder-uid-missing-appkey', 'appKey', storage, true, true) + .catch(e => e) + expect(err).toBeInstanceOf(KeeperCryptoError) + expect(err.failure).toBe('missing-key') + expect(err.uid).toBe('appKey') +}) + +test('unwrap reports the clean wrapping-key id, not the internal "cbc:" cache key, when a CBC-mode wrapping key is missing', async () => { + const storage = makeStorage() + const wrappedFolderKey = new Uint8Array(28).fill(9) // never reached; loadKey throws first + // Mirrors the nested-folder call shape (unwrappingKeyId is the shared folder's uid, not + // "appKey"), so useCBC=true really does make loadKey look up the "cbc:"-prefixed cache key. + const err = await browserPlatform + .unwrap(wrappedFolderKey, 'folder-uid-nested-missing', 'shared-folder-uid-missing', storage, true, true) + .catch(e => e) + expect(err).toBeInstanceOf(KeeperCryptoError) + expect(err.failure).toBe('missing-key') + // The lookup itself used the "cbc:"-prefixed cache key internally (see loadKey in + // browserPlatform.ts), but the reported uid must be the caller-facing id, not that detail. + expect(err.uid).toBe('shared-folder-uid-missing') +}) + +test('decrypt reports the clean keyId, not the internal "cbc:" cache key, for a folder key that was never unwrapped', async () => { + const storage = makeStorage() + const data = new Uint8Array(28).fill(9) // never reached; loadKey throws first + const err = await browserPlatform + .decrypt(data, 'never-unwrapped-folder-uid', storage, true) + .catch(e => e) + expect(err).toBeInstanceOf(KeeperCryptoError) + expect(err.failure).toBe('missing-key') + expect(err.uid).toBe('never-unwrapped-folder-uid') +}) + +test('sign throws a KeeperCryptoError with failure "missing-key" when the private key is not in storage (loadPrivateKey)', async () => { + const storage = makeStorage() + const data = new TextEncoder().encode('data to sign') + const err = await browserPlatform + .sign(data, 'device-private-key-missing', storage) + .catch(e => e) + expect(err).toBeInstanceOf(KeeperCryptoError) + expect(err.failure).toBe('missing-key') + expect(err.uid).toBe('device-private-key-missing') +}) + +test('unwrap still throws for a structurally too-short wrapped-key ciphertext, and it is not a KeeperCryptoError', async () => { + const storage = makeStorage({appKey: new Uint8Array(32).fill(2)}) + // A 16-byte wrapped value is 12 bytes of IV plus only 4 bytes of "ciphertext", well under + // AES-GCM's 16-byte authentication tag, so crypto.subtle.unwrapKey rejects it outright. This + // pins the native length enforcement WebCrypto already provided before this fix; unlike a + // missing key or a bad CBC padding, this is not one of the classified failure reasons. + const tooShortWrappedKey = new Uint8Array(16).fill(9) + const promise = browserPlatform.unwrap(tooShortWrappedKey, 'folder-uid-too-short', 'appKey', storage, true, true) + await expect(promise).rejects.toThrow() + await expect(promise).rejects.not.toBeInstanceOf(KeeperCryptoError) +}) + +test('unwrap rejects an unwrapped key whose length is not 32 bytes, even though WebCrypto itself accepts it', async () => { + const appKeyBytes = new Uint8Array(32).fill(2) + const storage = makeStorage({appKey: appKeyBytes}) + + // A 16-byte payload is itself a structurally valid AES-128 key, so - unlike the + // "structurally too short" case above - crypto.subtle.unwrapKey does NOT reject this on its + // own (confirmed directly against Node's real WebCrypto: it returns a CryptoKey with + // algorithm {name:'AES-GCM', length:128}, no error). Only this SDK's explicit AES-256 length + // check (assertUnwrappedKeyLength in browserPlatform.ts) catches it. + const shortPayload = new Uint8Array(16).fill(7) + const wrapped = await browserPlatform.encryptWithKey(shortPayload, appKeyBytes) + + const err = await browserPlatform + .unwrap(wrapped, 'folder-uid-short-unwrapped', 'appKey', storage, true, true) + .catch(e => e) + expect(err.message).toMatch(/length/i) + // Classifying format vs integrity happens at the keeper.ts call site, not in the platform, + // so this stays a plain Error rather than a KeeperCryptoError (matches nodePlatform's + // equivalent check). + expect(err).not.toBeInstanceOf(KeeperCryptoError) +}) + +test('unwrap with useCBC=true caches both a GCM and a CBC key for the same keyId (appKey-wrapped folder key needs both)', async () => { + const appKeyBytes = new Uint8Array(32).fill(2) + const folderKeyBytes = new Uint8Array(32).fill(3) + const storage = makeStorage({appKey: appKeyBytes}) + + // The vault always wraps a top-level folder's key in GCM under the app key. + const wrappedFolderKey = await browserPlatform.encryptWithKey(folderKeyBytes, appKeyBytes) + + // Exact 6-argument shape keeper.ts uses at the appKey-wrapped (top-level folder) call site: + // unwrappingKeyId is the literal "appKey", memoryOnly=true, useCBC=true. useCBC=true here is + // purely to populate the second (CBC) cache slot below - the wrapping-key load itself is + // still forced to GCM internally because unwrappingKeyId is "appKey". + await browserPlatform.unwrap(wrappedFolderKey, 'folder-uid-dual-cache', 'appKey', storage, true, true) + + // folder.data is always wrapped in CBC regardless of how the folder key itself was wrapped, + // so decrypting it needs a CBC-mode CryptoKey cached under the same keyId as the GCM one. + const gcmCiphertext = await browserPlatform.encryptWithKey( + new TextEncoder().encode('gcm payload'), folderKeyBytes) + const cbcCiphertext = await browserPlatform.encryptWithKey( + new TextEncoder().encode('{"name":"Folder Data"}'), folderKeyBytes, true) + + const decryptedGcm = await browserPlatform.decrypt(gcmCiphertext, 'folder-uid-dual-cache', storage) + const decryptedCbc = await browserPlatform.decrypt(cbcCiphertext, 'folder-uid-dual-cache', storage, true) + + expect(new TextDecoder().decode(decryptedGcm)).toBe('gcm payload') + expect(new TextDecoder().decode(decryptedCbc)).toBe('{"name":"Folder Data"}') +}) + +test('unwrap without useCBC only caches the GCM key; the CBC slot for the same keyId stays missing', async () => { + const appKeyBytes = new Uint8Array(32).fill(2) + const folderKeyBytes = new Uint8Array(32).fill(6) + const storage = makeStorage({appKey: appKeyBytes}) + + const wrappedFolderKey = await browserPlatform.encryptWithKey(folderKeyBytes, appKeyBytes) + + // Same call shape as the test above, but useCBC omitted: the exact mistake that broke + // browser-only folder decryption during implementation (see unwrap() in browserPlatform.ts). + await browserPlatform.unwrap(wrappedFolderKey, 'folder-uid-gcm-only', 'appKey', storage, true) + + const gcmCiphertext = await browserPlatform.encryptWithKey(new TextEncoder().encode('ok'), folderKeyBytes) + const decryptedGcm = await browserPlatform.decrypt(gcmCiphertext, 'folder-uid-gcm-only', storage) + expect(new TextDecoder().decode(decryptedGcm)).toBe('ok') + + const cbcCiphertext = await browserPlatform.encryptWithKey(new TextEncoder().encode('{}'), folderKeyBytes, true) + const err = await browserPlatform + .decrypt(cbcCiphertext, 'folder-uid-gcm-only', storage, true) + .catch(e => e) + expect(err).toBeInstanceOf(KeeperCryptoError) + expect(err.failure).toBe('missing-key') +}) diff --git a/sdk/javascript/packages/core/test/errors.test.ts b/sdk/javascript/packages/core/test/errors.test.ts new file mode 100644 index 000000000..d7219d5f1 --- /dev/null +++ b/sdk/javascript/packages/core/test/errors.test.ts @@ -0,0 +1,38 @@ +import { KeeperCryptoError, KeeperError, KeeperCryptoFailureReason } from '../' + +describe('KeeperCryptoError', () => { + // The setPrototypeOf chain must survive transpilation, otherwise consumers' `instanceof KeeperError` + // catches would silently break when a KeeperCryptoError is thrown. + test('is instanceof KeeperCryptoError, KeeperError, and Error', () => { + const err = new KeeperCryptoError('bad key', 'integrity', 'uid-1') + expect(err).toBeInstanceOf(KeeperCryptoError) + expect(err).toBeInstanceOf(KeeperError) + expect(err).toBeInstanceOf(Error) + }) + + test('.name is KeeperCryptoError', () => { + const err = new KeeperCryptoError('bad key', 'integrity', 'uid-1') + expect(err.name).toBe('KeeperCryptoError') + }) + + test('carries message, failure, and uid exactly as constructed, for every failure reason', () => { + const reasons: KeeperCryptoFailureReason[] = ['integrity', 'format', 'missing-key', 'malformed-data', 'unknown'] + for (const failure of reasons) { + const err = new KeeperCryptoError(`failed: ${failure}`, failure, `uid-${failure}`) + expect(err.message).toBe(`failed: ${failure}`) + expect(err.failure).toBe(failure) + expect(err.uid).toBe(`uid-${failure}`) + } + }) + + test('two instances do not share state', () => { + const a = new KeeperCryptoError('message a', 'missing-key', 'uid-a') + const b = new KeeperCryptoError('message b', 'format', 'uid-b') + expect(a.message).toBe('message a') + expect(a.failure).toBe('missing-key') + expect(a.uid).toBe('uid-a') + expect(b.message).toBe('message b') + expect(b.failure).toBe('format') + expect(b.uid).toBe('uid-b') + }) +}) diff --git a/sdk/javascript/packages/core/test/folderDecryptionCrossPlatform.test.ts b/sdk/javascript/packages/core/test/folderDecryptionCrossPlatform.test.ts new file mode 100644 index 000000000..ef45dcc20 --- /dev/null +++ b/sdk/javascript/packages/core/test/folderDecryptionCrossPlatform.test.ts @@ -0,0 +1,213 @@ +// KSM-1267 cross-platform regression coverage for getFolders()'s crypto failure classification. +// +// The goal here is narrower than a general getFolders test: it is to prove that node and the +// browser platform agree on the classification (KeeperCryptoFailureReason) for the same input, +// even though their underlying crypto libraries (OpenSSL vs WebCrypto) throw completely +// differently-shaped errors for the same logical failure. Every scenario below therefore runs +// once per platform against otherwise-identical fixtures, and assertions only ever look at +// KeeperDecryptionErrorInfo.failure / folder shape, never at raw error message text. +// +// This file also regression-pins the specific bug caught during implementation: dropping the +// useCBC argument from the appKey-wrapped (top-level) folder's unwrap() call broke browser-only +// folder decryption, because that call's only job (on the browser platform) besides unwrapping +// the folder's own key is to populate the second, CBC-mode cache slot that both the folder's own +// data decrypt and any child folder's unwrap later depend on. Scenario 1 exercises exactly that +// two-folder (shared + nested) shape on the browser platform, not just node, since node's +// raw-byte keys never needed that second cache slot and would not have caught this bug. +// +// Per the project's gotchas (see the KSM-1267 implementation notes): +// - Native crypto errors are not reliably `instanceof Error` under Jest's test environment, so +// only the SDK's own KeeperCryptoError / KeeperDecryptionErrorInfo shapes are asserted on here. +// - getFolders/fetchAndDecryptFolders read the shared module-level `platform` singleton, so each +// test explicitly reconnects the platform it needs instead of relying on import side effects. + +import {getFolders, initialize, initializeStorage, SecretManagerOptions} from '../src/keeper' +import {connectPlatform, inMemoryStorage, KeeperHttpResponse, Platform, platform} from '../src/platform' +import {nodePlatform} from '../src/node/nodePlatform' +import {browserPlatform} from '../src/browser/browserPlatform' +import {KeeperDecryptionErrorInfo} from '../src/errors' + +// A one-time token whose client-key segment decodes to exactly 32 bytes. The browser platform's +// crypto.subtle.importKey validates raw AES key length immediately (unlike node's, which never +// validates at import time), so an arbitrary ad hoc string here would throw only on browser. +const FAKE_ONE_TIME_TOKEN = 'YyIhK5wXFHj36wGBAOmBsxI3v5rIruINrC8KXjyM58c' + +// One-time module setup: initialize() populates the module-level Keeper public key table used by +// generateTransmissionKey. It only decodes fixed base64 strings, so it does not matter which +// platform is connected when it runs; every test below reconnects the platform it actually needs. +connectPlatform(nodePlatform) +initialize() + +const enc = (s: string): Uint8Array => platform.stringToBytes(s) + +const flipLastByte = (data: Uint8Array): Uint8Array => { + const tampered = new Uint8Array(data) + tampered[tampered.length - 1] ^= 0xff + return tampered +} + +const SHARED_FOLDER_UID = 'shared-folder-uid' +const NESTED_FOLDER_UID = 'nested-folder-uid' + +type Tamper = 'shared' | 'nested' | undefined + +// Builds a fresh, self-contained fixture (storage + queryFunction) entirely against whichever +// platform is currently connected: a top-level shared folder wrapped by the app key, and a nested +// subfolder of that shared folder whose key is wrapped by the shared folder's key. `tamper` +// deterministically corrupts one of the two wrapped folder keys by flipping its last byte +// (KSM-1267 gotcha: this reliably breaks CBC padding for the nested wrap and always breaks the +// GCM tag for the app-key-wrapped top-level wrap, so neither failure depends on tamper odds). +const buildFixture = async (tamper?: Tamper): Promise<{ + storage: ReturnType + queryFunction: SecretManagerOptions['queryFunction'] +}> => { + const transmissionKey = new Uint8Array(32).fill(1) + const appKey = new Uint8Array(32).fill(2) + const sharedFolderKey = new Uint8Array(32).fill(3) + const nestedFolderKey = new Uint8Array(32).fill(4) + + let wrappedSharedFolderKey = await platform.encryptWithKey(sharedFolderKey, appKey) + if (tamper === 'shared') { + wrappedSharedFolderKey = flipLastByte(wrappedSharedFolderKey) + } + + // Wrapped by the shared folder's key, in CBC: this is the real KSM-1267 exposure (the vault's + // wire format for this wrap is fixed to unauthenticated AES-256-CBC server-side). + let wrappedNestedFolderKey = await platform.encryptWithKey(nestedFolderKey, sharedFolderKey, true) + if (tamper === 'nested') { + wrappedNestedFolderKey = flipLastByte(wrappedNestedFolderKey) + } + + const sharedFolderData = await platform.encryptWithKey(enc(JSON.stringify({name: 'Shared Folder'})), sharedFolderKey, true) + const nestedFolderData = await platform.encryptWithKey(enc(JSON.stringify({name: 'Nested Folder'})), nestedFolderKey, true) + + // Order matters: the shared folder must be processed before the nested one so that, on the + // browser platform, its CBC-mode key cache slot ('cbc:' + sharedFolderUid) is already + // populated by the time the nested folder's unwrap needs it as the wrapping key. + const serverResponse = { + folders: [ + { + folderUid: SHARED_FOLDER_UID, + folderKey: platform.bytesToBase64(wrappedSharedFolderKey), + data: platform.bytesToBase64(sharedFolderData) + }, + { + folderUid: NESTED_FOLDER_UID, + folderKey: platform.bytesToBase64(wrappedNestedFolderKey), + data: platform.bytesToBase64(nestedFolderData), + parent: SHARED_FOLDER_UID + } + ], + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey(enc(JSON.stringify(serverResponse)), transmissionKey) + + // postQuery uses options.queryFunction (not platform.post); pin getRandomBytes so the + // transmission key generateTransmissionKey produces matches the key used to encrypt the + // response above. + platform.getRandomBytes = () => transmissionKey + const queryFunction = (): Promise => + Promise.resolve({data: encryptedResponse, statusCode: 200, headers: []}) + + const storage = inMemoryStorage({}) + await initializeStorage(storage, FAKE_ONE_TIME_TOKEN, 'fake.keepersecurity.com') + await storage.saveBytes('appKey', appKey) + + return {storage, queryFunction} +} + +const platforms: [string, Platform][] = [ + ['node', nodePlatform], + ['browser', browserPlatform] +] + +describe.each(platforms)('getFolders folder decryption classification (%s platform)', (_name, plat) => { + beforeEach(() => { + connectPlatform(plat) + }) + + test('shared folder and its nested subfolder both decrypt successfully', async () => { + const {storage, queryFunction} = await buildFixture() + const errors: KeeperDecryptionErrorInfo[] = [] + + const folders = await getFolders({ + storage, + queryFunction, + onDecryptionError: info => errors.push(info) + }) + + // No failures at all: this is the regression pin for the useCBC-dropped bug. If that bug + // were reintroduced, the shared folder would fail its own data decrypt (missing the + // second, CBC-mode cache slot) and the nested folder would fail to unwrap entirely + // (missing the shared folder's cached CBC key), so both folders would disappear. + expect(errors).toEqual([]) + expect(folders.length).toBe(2) + + const shared = folders.find(f => f.folderUid === SHARED_FOLDER_UID) + const nested = folders.find(f => f.folderUid === NESTED_FOLDER_UID) + + expect(shared).toBeDefined() + expect(shared!.name).toBe('Shared Folder') + + expect(nested).toBeDefined() + expect(nested!.name).toBe('Nested Folder') + expect(nested!.parentUid).toBe(SHARED_FOLDER_UID) + }) + + test('a tampered nested-folder key wrap is classified as format', async () => { + const {storage, queryFunction} = await buildFixture('nested') + const errors: KeeperDecryptionErrorInfo[] = [] + + const folders = await getFolders({ + storage, + queryFunction, + onDecryptionError: info => errors.push(info) + }) + + // The shared folder is wrapped and processed independently, so it still decrypts fine; + // only the nested folder (whose key wrap was tampered) is skipped. + expect(folders.length).toBe(1) + expect(folders[0].folderUid).toBe(SHARED_FOLDER_UID) + + const nestedError = errors.find(e => e.uid === NESTED_FOLDER_UID) + expect(nestedError).toBeDefined() + expect(nestedError!.failure).toBe('format') + }) + + test('a tampered top-level shared-folder key wrap is classified as integrity', async () => { + const {storage, queryFunction} = await buildFixture('shared') + const errors: KeeperDecryptionErrorInfo[] = [] + + const folders = await getFolders({ + storage, + queryFunction, + onDecryptionError: info => errors.push(info) + }) + + const sharedError = errors.find(e => e.uid === SHARED_FOLDER_UID) + expect(sharedError).toBeDefined() + expect(sharedError!.failure).toBe('integrity') + + // The nested folder cascades to a skip too (it needs the shared folder's key, which + // never got cached once the shared folder's own unwrap threw), so neither folder survives. + expect(folders.length).toBe(0) + + // The cascading nested-folder failure is classified 'missing-key', NOT 'format', even + // though the nested folder's own key wrap is a real, untampered CBC wrap. getSharedFolderUid + // resolves purely off the raw response.folders array (folderUid/parent fields only), so it + // still successfully resolves SHARED_FOLDER_UID as the nested folder's shared-folder uid + // even though the shared folder's own unwrap threw before ever reaching + // keyCache[keyId]=... / storage.saveBytes(...). The nested folder's subsequent + // platform.unwrap(..., sharedFolderUid, ...) call then fails inside loadKey (the shared + // folder's key was never cached or persisted), which throws a KeeperCryptoError already + // tagged 'missing-key' - and runCrypto's classifyCryptoFailure only overrides the mode + // ('format' here) when the caught error is NOT already a KeeperCryptoError, so 'missing-key' + // passes through unchanged. A future change to getSharedFolderUid or the cache-population + // order that silently altered this cascade classification would only be caught here. + const nestedError = errors.find(e => e.uid === NESTED_FOLDER_UID) + expect(nestedError).toBeDefined() + expect(nestedError!.failure).toBe('missing-key') + }) +}) diff --git a/sdk/javascript/packages/core/test/folderDecryptionErrors.test.ts b/sdk/javascript/packages/core/test/folderDecryptionErrors.test.ts new file mode 100644 index 000000000..96a6948e5 --- /dev/null +++ b/sdk/javascript/packages/core/test/folderDecryptionErrors.test.ts @@ -0,0 +1,350 @@ +import { + getFolders, + initializeStorage, + platform, + inMemoryStorage, + SecretManagerOptions, + KeeperHttpResponse, +} from '../' + +// Every AES key in this SDK (app key, folder keys, record keys, client key) is 32 raw bytes; a +// fixed Uint8Array(32).fill(N) is the existing convention for a deterministic fake key (see +// test/keeper.test.ts). +const TRANSMISSION_KEY = new Uint8Array(32).fill(1) +const APP_KEY = new Uint8Array(32).fill(2) + +// Builds SecretManagerOptions wired to a fake encrypted server response containing the given raw +// (pre-encryption) folders, following test/keeper.test.ts's +// 'getFolders skips an undecryptable folder and returns the good one' construction exactly: pin +// platform.getRandomBytes to a fixed transmission key, encrypt the JSON response with that same +// key, stub queryFunction (not platform.post - postQuery reads options.queryFunction) to return +// it, initializeStorage with a fake one-time token, then save the app key directly into storage. +const setupFolders = async (rawFolders: any[]): Promise => { + const serverResponse = { + folders: rawFolders, + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), TRANSMISSION_KEY) + + platform.getRandomBytes = () => TRANSMISSION_KEY + const queryFn = (): Promise => + Promise.resolve({data: encryptedResponse, statusCode: 200, headers: []}) + + const kvs = inMemoryStorage({}) + await initializeStorage(kvs, 'US:FAKE_CLIENT_KEY') + await kvs.saveBytes('appKey', APP_KEY) + + return {storage: kvs, queryFunction: queryFn} +} + +// folder.data is always wrapped in CBC by the vault regardless of which mode wrapped the folder +// key (see keeper.ts's fetchAndDecryptFolders), so every folder.data fixture below is CBC. +const encryptFolderData = (name: string, folderKey: Uint8Array): Promise => + platform.encryptWithKey(platform.stringToBytes(JSON.stringify({name})), folderKey, true) + +// Flips the last byte of a wrapped-key ciphertext so an unwrap deterministically fails instead of +// relying on random tamper odds. Against a CBC wrap this breaks PKCS7 padding (padding lives +// entirely in the final ciphertext block, so a non-final-byte tamper leaves padding valid about +// half the time - only the final byte reliably breaks it). Against a GCM wrap the last byte falls +// inside the trailing authentication tag, so this also reliably fails the tag check. +const tamperLastByte = (bytes: Uint8Array): Uint8Array => { + const tampered = new Uint8Array(bytes) + tampered[tampered.length - 1] ^= 0xff + return tampered +} + +test('nested folder: tampering the last byte of its shared-folder-key-wrapped key is skipped with failure "format"', async () => { + const sharedFolderUid = 'shared-folder-uid' + const nestedFolderUid = 'nested-folder-uid' + const sharedFolderKey = new Uint8Array(32).fill(10) + const nestedFolderKey = new Uint8Array(32).fill(11) + + // Top-level (shared) folder: wrapped by appKey, GCM. Listed first so its key lands in the + // platform's key cache before the nested folder below needs it as its wrapping key. + const sharedFolderKeyWrapped = await platform.encryptWithKey(sharedFolderKey, APP_KEY) + const sharedFolderData = await encryptFolderData('Shared Folder', sharedFolderKey) + + // Nested folder: wrapped by the shared folder's key, CBC - this is the real KSM-1267 + // exposure. Tamper the last byte so the unwrap deterministically fails. + const nestedFolderKeyWrapped = await platform.encryptWithKey(nestedFolderKey, sharedFolderKey, true) + const tamperedNestedFolderKeyWrapped = tamperLastByte(nestedFolderKeyWrapped) + + const options = await setupFolders([ + { + folderUid: sharedFolderUid, + folderKey: platform.bytesToBase64(sharedFolderKeyWrapped), + data: platform.bytesToBase64(sharedFolderData) + }, + { + folderUid: nestedFolderUid, + parent: sharedFolderUid, + folderKey: platform.bytesToBase64(tamperedNestedFolderKeyWrapped), + data: '' + } + ]) + + const onDecryptionError = jest.fn() + const folders = await getFolders({...options, onDecryptionError}) + + expect(folders.length).toBe(1) + expect(folders[0].folderUid).toBe(sharedFolderUid) + expect(onDecryptionError).toHaveBeenCalledTimes(1) + expect(onDecryptionError.mock.calls[0][0].uid).toBe(nestedFolderUid) + expect(onDecryptionError.mock.calls[0][0].failure).toBe('format') +}) + +test('top-level folder: tampering the last byte of its appKey-wrapped key is skipped with failure "integrity"', async () => { + const folderUid = 'top-folder-uid' + const folderKey = new Uint8Array(32).fill(20) + const wrapped = await platform.encryptWithKey(folderKey, APP_KEY) + const tampered = tamperLastByte(wrapped) + + const options = await setupFolders([ + {folderUid, folderKey: platform.bytesToBase64(tampered), data: ''} + ]) + + const onDecryptionError = jest.fn() + const folders = await getFolders({...options, onDecryptionError}) + + expect(folders.length).toBe(0) + expect(onDecryptionError).toHaveBeenCalledTimes(1) + expect(onDecryptionError.mock.calls[0][0].uid).toBe(folderUid) + expect(onDecryptionError.mock.calls[0][0].failure).toBe('integrity') +}) + +test('folder whose folderKey unwraps fine but whose decrypted data is not valid JSON is skipped with failure "malformed-data"', async () => { + const folderUid = 'not-json-folder-uid' + const folderKey = new Uint8Array(32).fill(30) + const wrappedFolderKey = await platform.encryptWithKey(folderKey, APP_KEY) + // A real key and real (CBC) ciphertext, so the unwrap and decrypt both succeed; the + // recovered plaintext is simply not valid JSON. + const notJsonData = await platform.encryptWithKey(platform.stringToBytes('not valid json at all'), folderKey, true) + + const options = await setupFolders([ + {folderUid, folderKey: platform.bytesToBase64(wrappedFolderKey), data: platform.bytesToBase64(notJsonData)} + ]) + + const onDecryptionError = jest.fn() + const folders = await getFolders({...options, onDecryptionError}) + + expect(folders.length).toBe(0) + expect(onDecryptionError).toHaveBeenCalledTimes(1) + expect(onDecryptionError.mock.calls[0][0].uid).toBe(folderUid) + expect(onDecryptionError.mock.calls[0][0].failure).toBe('malformed-data') +}) + +test('folder whose decrypted data is valid JSON but not an object is skipped with failure "unknown"', async () => { + // JSON.parse('null') succeeds (null is valid JSON), so the explicit malformed-data catch + // around JSON.parse in fetchAndDecryptFolders never fires; the following property access + // (parsedData['name']) then throws a plain TypeError reading a property off null, which is + // not a KeeperCryptoError, so the catch block's fallback classification applies: failure + // instanceof KeeperCryptoError ? e.failure : 'unknown'. This is the only reachable path that + // ever produces the 'unknown' bucket, since every other throw in the try block is already an + // explicit KeeperCryptoError (either from runCrypto or thrown directly). + const folderUid = 'null-json-folder-uid' + const folderKey = new Uint8Array(32).fill(31) + const wrappedFolderKey = await platform.encryptWithKey(folderKey, APP_KEY) + const nullJsonData = await platform.encryptWithKey(platform.stringToBytes('null'), folderKey, true) + + const options = await setupFolders([ + {folderUid, folderKey: platform.bytesToBase64(wrappedFolderKey), data: platform.bytesToBase64(nullJsonData)} + ]) + + const onDecryptionError = jest.fn() + const folders = await getFolders({...options, onDecryptionError}) + + expect(folders.length).toBe(0) + expect(onDecryptionError).toHaveBeenCalledTimes(1) + expect(onDecryptionError.mock.calls[0][0].uid).toBe(folderUid) + expect(onDecryptionError.mock.calls[0][0].failure).toBe('unknown') +}) + +test('folder with an empty (falsy) folderKey is skipped with failure "missing-key"', async () => { + const folderUid = 'empty-key-folder-uid' + const options = await setupFolders([ + {folderUid, folderKey: '', data: ''} + ]) + + const onDecryptionError = jest.fn() + const folders = await getFolders({...options, onDecryptionError}) + + expect(folders.length).toBe(0) + expect(onDecryptionError).toHaveBeenCalledTimes(1) + expect(onDecryptionError.mock.calls[0][0].uid).toBe(folderUid) + expect(onDecryptionError.mock.calls[0][0].failure).toBe('missing-key') +}) + +test('nested folder whose parent uid matches no folder in the response (orphaned) is skipped with failure "missing-key"', async () => { + const folderUid = 'orphan-folder-uid' + // Never read: getSharedFolderUid fails to resolve the parent before any unwrap is attempted, + // so the wrapped-key bytes below do not matter, only that the field is truthy. + const unusedWrappedKey = platform.bytesToBase64(new Uint8Array(48).fill(9)) + + const options = await setupFolders([ + {folderUid, parent: 'no-such-parent-uid', folderKey: unusedWrappedKey, data: ''} + ]) + + const onDecryptionError = jest.fn() + const folders = await getFolders({...options, onDecryptionError}) + + expect(folders.length).toBe(0) + expect(onDecryptionError).toHaveBeenCalledTimes(1) + expect(onDecryptionError.mock.calls[0][0].uid).toBe(folderUid) + expect(onDecryptionError.mock.calls[0][0].failure).toBe('missing-key') +}) + +test('onDecryptionError is called with the exact {uid, failure, message} shape once per skipped folder, for multiple simultaneous failures', async () => { + const goodFolderUid = 'good-folder-uid-multi' + const goodFolderKey = new Uint8Array(32).fill(50) + const goodFolderKeyWrapped = await platform.encryptWithKey(goodFolderKey, APP_KEY) + const goodFolderData = await encryptFolderData('Good Folder', goodFolderKey) + + const emptyKeyFolderUid = 'empty-key-folder-uid-multi' + const orphanFolderUid = 'orphan-folder-uid-multi' + const notJsonFolderUid = 'not-json-folder-uid-multi' + const notJsonFolderKey = new Uint8Array(32).fill(51) + const notJsonFolderKeyWrapped = await platform.encryptWithKey(notJsonFolderKey, APP_KEY) + const notJsonData = await platform.encryptWithKey(platform.stringToBytes('not valid json at all'), notJsonFolderKey, true) + + const options = await setupFolders([ + {folderUid: goodFolderUid, folderKey: platform.bytesToBase64(goodFolderKeyWrapped), data: platform.bytesToBase64(goodFolderData)}, + {folderUid: emptyKeyFolderUid, folderKey: '', data: ''}, + {folderUid: orphanFolderUid, parent: 'no-such-parent-uid-multi', folderKey: platform.bytesToBase64(new Uint8Array(48).fill(9)), data: ''}, + {folderUid: notJsonFolderUid, folderKey: platform.bytesToBase64(notJsonFolderKeyWrapped), data: platform.bytesToBase64(notJsonData)} + ]) + + const onDecryptionError = jest.fn() + const folders = await getFolders({...options, onDecryptionError}) + + expect(folders.length).toBe(1) + expect(folders[0].folderUid).toBe(goodFolderUid) + + expect(onDecryptionError).toHaveBeenCalledTimes(3) + expect(onDecryptionError).toHaveBeenCalledWith({ + uid: emptyKeyFolderUid, + failure: 'missing-key', + message: `Folder key missing for UID ${emptyKeyFolderUid}` + }) + expect(onDecryptionError).toHaveBeenCalledWith({ + uid: orphanFolderUid, + failure: 'missing-key', + message: `Folder data inconsistent - unable to locate shared folder for ${orphanFolderUid}` + }) + expect(onDecryptionError).toHaveBeenCalledWith({ + uid: notJsonFolderUid, + failure: 'malformed-data', + message: `Folder ${notJsonFolderUid} decrypted data is not valid JSON` + }) +}) + +test('fail closed: getFolders rejects with the callback error when onDecryptionError throws', async () => { + const folderUid = 'empty-key-folder-uid-failclosed' + const options = await setupFolders([ + {folderUid, folderKey: '', data: ''} + ]) + + const distinctiveMessage = 'KSM-1267 fail-closed probe: onDecryptionError deliberately threw' + const onDecryptionError = () => { + throw new Error(distinctiveMessage) + } + + await expect(getFolders({...options, onDecryptionError})).rejects.toThrow(distinctiveMessage) +}) + +test('backward compatibility: getFolders with no onDecryptionError set still resolves with just the good folder', async () => { + const goodFolderUid = 'good-folder-uid-backcompat' + const goodFolderKey = new Uint8Array(32).fill(60) + const goodFolderKeyWrapped = await platform.encryptWithKey(goodFolderKey, APP_KEY) + const goodFolderData = await encryptFolderData('Good Folder', goodFolderKey) + const badFolderUid = 'bad-folder-uid-backcompat' + + const options = await setupFolders([ + {folderUid: goodFolderUid, folderKey: platform.bytesToBase64(goodFolderKeyWrapped), data: platform.bytesToBase64(goodFolderData)}, + {folderUid: badFolderUid, folderKey: '', data: ''} + ]) + + const folders = await getFolders(options) + + expect(folders.length).toBe(1) + expect(folders[0].folderUid).toBe(goodFolderUid) + expect(folders[0].name).toBe('Good Folder') +}) + +test('logs a partial-list summary line naming the skipped uid when 1 or more folders are skipped', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + try { + const goodFolderUid = 'good-folder-uid-summary' + const goodFolderKey = new Uint8Array(32).fill(70) + const goodFolderKeyWrapped = await platform.encryptWithKey(goodFolderKey, APP_KEY) + const goodFolderData = await encryptFolderData('Good Folder', goodFolderKey) + const badFolderUid = 'bad-folder-uid-summary' + + const options = await setupFolders([ + {folderUid: goodFolderUid, folderKey: platform.bytesToBase64(goodFolderKeyWrapped), data: platform.bytesToBase64(goodFolderData)}, + {folderUid: badFolderUid, folderKey: '', data: ''} + ]) + + await getFolders(options) + + const summaryLine = consoleErrorSpy.mock.calls + .map(args => args.join(' ')) + .find(line => /getFolders:.*could not be decrypted/.test(line)) + expect(summaryLine).toBeDefined() + expect(summaryLine).toContain(badFolderUid) + } finally { + consoleErrorSpy.mockRestore() + } +}) + +test('top-level folder whose appKey-wrapped key decrypts cleanly to the wrong byte length is skipped with failure "integrity" (Node UNWRAPPED_KEY_LENGTH check, end-to-end)', async () => { + // nodePlatform.unwrap()'s UNWRAPPED_KEY_LENGTH check (see nodePlatform.ts) is otherwise only + // unit-tested directly against nodePlatform.unwrap() (see nodePlatform.test.ts); nothing + // confirms it produces the correct caller-facing classification through a real getFolders() + // call. Build a real GCM-wrapped 16-byte payload under APP_KEY (a real key, real ciphertext, + // so the decrypt itself succeeds cleanly) - the same way nodePlatform.test.ts's own + // "unwrap rejects an unwrapped key whose length is not 32 bytes" test builds its fixture - + // and confirm the folder is skipped via the top-level (appKey) call site's 'integrity' + // classification, not silently accepted with a malformed cached key. + const folderUid = 'wrong-length-unwrapped-key-folder-uid' + const shortPayload = new Uint8Array(16).fill(40) + const wrappedShortKey = await platform.encryptWithKey(shortPayload, APP_KEY) + + const options = await setupFolders([ + {folderUid, folderKey: platform.bytesToBase64(wrappedShortKey), data: ''} + ]) + + const onDecryptionError = jest.fn() + const folders = await getFolders({...options, onDecryptionError}) + + expect(folders.length).toBe(0) + expect(onDecryptionError).toHaveBeenCalledTimes(1) + expect(onDecryptionError.mock.calls[0][0].uid).toBe(folderUid) + expect(onDecryptionError.mock.calls[0][0].failure).toBe('integrity') + expect(onDecryptionError.mock.calls[0][0].message).toMatch(/length/i) +}) + +test('does not log a partial-list summary line when zero folders are skipped', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + try { + const goodFolderUid = 'good-folder-uid-nosummary' + const goodFolderKey = new Uint8Array(32).fill(71) + const goodFolderKeyWrapped = await platform.encryptWithKey(goodFolderKey, APP_KEY) + const goodFolderData = await encryptFolderData('Good Folder', goodFolderKey) + + const options = await setupFolders([ + {folderUid: goodFolderUid, folderKey: platform.bytesToBase64(goodFolderKeyWrapped), data: platform.bytesToBase64(goodFolderData)} + ]) + + await getFolders(options) + + const summaryLine = consoleErrorSpy.mock.calls + .map(args => args.join(' ')) + .find(line => /getFolders:.*could not be decrypted/.test(line)) + expect(summaryLine).toBeUndefined() + } finally { + consoleErrorSpy.mockRestore() + } +}) diff --git a/sdk/javascript/packages/core/test/nodePlatform.test.ts b/sdk/javascript/packages/core/test/nodePlatform.test.ts index ff9202cae..de3f90a56 100644 --- a/sdk/javascript/packages/core/test/nodePlatform.test.ts +++ b/sdk/javascript/packages/core/test/nodePlatform.test.ts @@ -1,5 +1,7 @@ import {nodePlatform} from '../src/node/nodePlatform' import {createHmac} from 'crypto' +import {KeeperCryptoError} from '../src/errors' +import {KeyValueStorage} from '../src/platform' test('hash produces different digests for different tags with the same data', async () => { const data = new TextEncoder().encode('client-key-bytes') @@ -15,3 +17,99 @@ test('hash matches an independently computed HMAC-SHA512 over data and tag', asy const expected = createHmac('sha512', data).update(tag).digest() expect(Buffer.from(digest).equals(expected)).toBe(true) }) + +// Minimal KeyValueStorage backed by plain Maps, so these tests can call nodePlatform's functions +// directly without depending on the module-level platform singleton (inMemoryStorage needs +// connectPlatform to have run first). +const makeStorage = (initial: Record = {}): KeyValueStorage => { + const bytes = new Map(Object.entries(initial)) + const strings = new Map() + return { + getString: async key => strings.get(key), + saveString: async (key, value) => { + strings.set(key, value) + }, + getBytes: async key => bytes.get(key), + saveBytes: async (key, value) => { + bytes.set(key, value) + }, + delete: async key => { + bytes.delete(key) + strings.delete(key) + }, + } +} + +test('unwrap throws a KeeperCryptoError with failure "missing-key" when the unwrapping key is not in storage', async () => { + const storage = makeStorage() + const wrappedKeyBytes = new Uint8Array([1, 2, 3, 4]) // never reached; loadKey throws first + const err = await nodePlatform + .unwrap(wrappedKeyBytes, 'target-key-id', 'nonexistent-key-id', storage) + .catch(e => e) + expect(err).toBeInstanceOf(KeeperCryptoError) + expect(err.failure).toBe('missing-key') + expect(err.uid).toBe('nonexistent-key-id') +}) + +test('decrypt throws a KeeperCryptoError with failure "missing-key" when the key is not in storage', async () => { + const storage = makeStorage() + const data = new Uint8Array(28).fill(9) // never reached; loadKey throws first + const err = await nodePlatform + .decrypt(data, 'nonexistent-key-id-decrypt', storage) + .catch(e => e) + expect(err).toBeInstanceOf(KeeperCryptoError) + expect(err.failure).toBe('missing-key') + expect(err.uid).toBe('nonexistent-key-id-decrypt') +}) + +test('unwrap rejects an unwrapped key whose length is not 32 bytes', async () => { + const wrappingKeyId = 'wrapping-key-id-length-check' + const wrappingKey = new Uint8Array(32).fill(9) + const storage = makeStorage() + await storage.saveBytes(wrappingKeyId, wrappingKey) + + // A real GCM-wrapped 16-byte payload decrypts cleanly, so the failure asserted below is + // purely the post-decrypt length check, not a decrypt error. + const shortPayload = new Uint8Array(16).fill(7) + const wrapped = await nodePlatform.encryptWithKey(shortPayload, wrappingKey) + + const err = await nodePlatform + .unwrap(wrapped, 'short-key-id', wrappingKeyId, storage) + .catch(e => e) + expect(err.message).toMatch(/length/i) + // Classifying format vs integrity happens at the keeper.ts call site, not in the platform, + // so this stays a plain Error rather than a KeeperCryptoError. + expect(err).not.toBeInstanceOf(KeeperCryptoError) +}) + +test('unwrap rejects a short unwrapped key even when the wrap used CBC', async () => { + const wrappingKeyId = 'wrapping-key-id-cbc-length-check' + const wrappingKey = new Uint8Array(32).fill(4) + const storage = makeStorage() + await storage.saveBytes(wrappingKeyId, wrappingKey) + + const shortPayload = new Uint8Array(16).fill(8) + const wrapped = await nodePlatform.encryptWithKey(shortPayload, wrappingKey, true) + + await expect( + nodePlatform.unwrap(wrapped, 'short-key-id-cbc', wrappingKeyId, storage, false, true) + ).rejects.toThrow(/length/i) +}) + +test('unwrap accepts a 32-byte key, and the unwrapped key round-trips through encrypt/decrypt', async () => { + const wrappingKeyId = 'wrapping-key-id-roundtrip' + const wrappingKey = new Uint8Array(32).fill(3) + const storage = makeStorage() + await storage.saveBytes(wrappingKeyId, wrappingKey) + + const originalKey = new Uint8Array(32).fill(5) + const wrapped = await nodePlatform.encryptWithKey(originalKey, wrappingKey) + + const unwrappedKeyId = 'unwrapped-key-id-roundtrip' + await nodePlatform.unwrap(wrapped, unwrappedKeyId, wrappingKeyId, storage) + + const plaintext = new TextEncoder().encode('hello keeper') + const encrypted = await nodePlatform.encrypt(plaintext, unwrappedKeyId, storage) + const decrypted = await nodePlatform.decrypt(encrypted, unwrappedKeyId, storage) + expect(new TextDecoder().decode(decrypted)).toBe('hello keeper') +}) From 6638568b3053681970f39d6e42d4ef03b3c0e607 Mon Sep 17 00:00:00 2001 From: stas-schaller Date: Tue, 1 Sep 2026 16:47:28 -0400 Subject: [PATCH 17/20] fix(javascript): remove unconditional TLS verification bypass from examples (#1149) hello-secret and proxy-support set NODE_TLS_REJECT_UNAUTHORIZED='0' process-wide as their first line, with no self-signed-cert scenario in either example to justify it. Neither example needs the bypass; delete it rather than swap in a scoped alternative. KSM-1316 --- examples/javascript/hello-secret/hello.js | 2 -- examples/javascript/proxy-support/hello.js | 2 -- 2 files changed, 4 deletions(-) diff --git a/examples/javascript/hello-secret/hello.js b/examples/javascript/hello-secret/hello.js index 6bf329bc9..44c18e365 100644 --- a/examples/javascript/hello-secret/hello.js +++ b/examples/javascript/hello-secret/hello.js @@ -1,5 +1,3 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - const { getSecrets, initializeStorage, diff --git a/examples/javascript/proxy-support/hello.js b/examples/javascript/proxy-support/hello.js index a6c98a1c5..b46e90dda 100644 --- a/examples/javascript/proxy-support/hello.js +++ b/examples/javascript/proxy-support/hello.js @@ -1,5 +1,3 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - const { getSecrets, initializeStorage, From 9a4ebfe30a28486ea375ad6603abf9b84eb769b5 Mon Sep 17 00:00:00 2001 From: stas-schaller Date: Tue, 1 Sep 2026 16:51:20 -0400 Subject: [PATCH 18/20] feat(javascript): add notation, folders, file-upload, totp, and pam-linked-records examples (#1151) Five example areas the 2026-08-25 examples audit found genuinely missing from examples/javascript - no existing directory had any partial coverage of any of them. Each new directory mirrors hello-secret's existing style (plain CommonJS, Node-only, no build tooling) with its own README, since none of the three undocumented existing directories had one either. - notation: getNotationResults/tryGetNotationResults, throwing vs. non-throwing lookup against a missing field - folders: getFolders/createFolder/updateFolder/deleteFolder, with a README note on createOptions.folderUid needing a shared-folder UID - file-upload: uploadFile paired with downloadFile for a round-trip byte-compare, complementing hello-secret's existing download-only coverage - totp: getTotpCode against a record's oneTimeCode field, including the unixTimeSeconds override for deterministic use - pam-linked-records: getLinks()/KeeperRecordLink, the SDK's typed accessor for a PAM resource's linked credential/metadata/JIT/AI records via record.links - confirmed against the SDK's own record_link.test.ts rather than the recordRef-based mechanism the ticket description assumed, which doesn't exist as described All five verified: destructured import names checked against the published SDK, hello.js files pass node --check. KSM-1328 --- examples/javascript/file-upload/.gitignore | 3 + examples/javascript/file-upload/README.md | 24 ++++++++ examples/javascript/file-upload/hello.js | 58 +++++++++++++++++++ examples/javascript/file-upload/package.json | 12 ++++ examples/javascript/folders/.gitignore | 2 + examples/javascript/folders/README.md | 21 +++++++ examples/javascript/folders/hello.js | 38 ++++++++++++ examples/javascript/folders/package.json | 12 ++++ examples/javascript/notation/.gitignore | 2 + examples/javascript/notation/README.md | 26 +++++++++ examples/javascript/notation/hello.js | 37 ++++++++++++ examples/javascript/notation/package.json | 12 ++++ .../javascript/pam-linked-records/.gitignore | 2 + .../javascript/pam-linked-records/README.md | 36 ++++++++++++ .../javascript/pam-linked-records/hello.js | 47 +++++++++++++++ .../pam-linked-records/package.json | 12 ++++ examples/javascript/totp/.gitignore | 2 + examples/javascript/totp/README.md | 20 +++++++ examples/javascript/totp/hello.js | 39 +++++++++++++ examples/javascript/totp/package.json | 12 ++++ 20 files changed, 417 insertions(+) create mode 100644 examples/javascript/file-upload/.gitignore create mode 100644 examples/javascript/file-upload/README.md create mode 100644 examples/javascript/file-upload/hello.js create mode 100644 examples/javascript/file-upload/package.json create mode 100644 examples/javascript/folders/.gitignore create mode 100644 examples/javascript/folders/README.md create mode 100644 examples/javascript/folders/hello.js create mode 100644 examples/javascript/folders/package.json create mode 100644 examples/javascript/notation/.gitignore create mode 100644 examples/javascript/notation/README.md create mode 100644 examples/javascript/notation/hello.js create mode 100644 examples/javascript/notation/package.json create mode 100644 examples/javascript/pam-linked-records/.gitignore create mode 100644 examples/javascript/pam-linked-records/README.md create mode 100644 examples/javascript/pam-linked-records/hello.js create mode 100644 examples/javascript/pam-linked-records/package.json create mode 100644 examples/javascript/totp/.gitignore create mode 100644 examples/javascript/totp/README.md create mode 100644 examples/javascript/totp/hello.js create mode 100644 examples/javascript/totp/package.json diff --git a/examples/javascript/file-upload/.gitignore b/examples/javascript/file-upload/.gitignore new file mode 100644 index 000000000..2f9ba1a7a --- /dev/null +++ b/examples/javascript/file-upload/.gitignore @@ -0,0 +1,3 @@ +node_modules +config.json +upload-me.txt diff --git a/examples/javascript/file-upload/README.md b/examples/javascript/file-upload/README.md new file mode 100644 index 000000000..7961ddfa7 --- /dev/null +++ b/examples/javascript/file-upload/README.md @@ -0,0 +1,24 @@ +# File upload + +Uploads a local file to a record, then downloads it back and confirms the bytes round-trip. + +## Function demonstrated + +`uploadFile(options, ownerRecord, file)`: attaches `file` to `ownerRecord` and returns the new file's UID. +`file` is a `KeeperFileUpload`: `{ name, title, type?, data }`, where `data` is a `Uint8Array`. + +Files always attach to a record - there's no way to upload a file to a folder directly. This complements +`downloadFile`, already shown in the `hello-secret` example, which only covers reading an existing file. + +The script polls briefly after uploading: the server can take a moment to populate the new file's download +URL in a `getSecrets()` response, so fetching once immediately after `uploadFile()` returns can find a file +entry with no `url` yet. It also calls `process.exit(0)` explicitly at the end, since `uploadFile()`'s +underlying HTTP response is never read and leaves the socket (and the process) open otherwise. + +## Running + +1. Replace the placeholder token in `hello.js` with a real one-time access token for your vault. +2. `npm install` +3. `npm run run` + +Expected output: the uploaded file's UID, then `true` confirming the downloaded bytes match what was uploaded. diff --git a/examples/javascript/file-upload/hello.js b/examples/javascript/file-upload/hello.js new file mode 100644 index 000000000..bdfee0d31 --- /dev/null +++ b/examples/javascript/file-upload/hello.js @@ -0,0 +1,58 @@ +const { + getSecrets, + initializeStorage, + localConfigStorage, + uploadFile, + downloadFile +} = require('@keeper-security/secrets-manager-core') +const fs = require('fs') + +const main = async () => { + const storage = localConfigStorage("config.json") + // if your Keeper Account is in other region than US, update the hostname accordingly + await initializeStorage(storage, 'US:EXAMPLE_ONE_TIME_TOKEN', 'keepersecurity.com') + + const {records} = await getSecrets({storage: storage}) + const ownerRecord = records[0] + + // Files attach to a record - there's no way to upload a file without an owner record. + const localFilePath = './upload-me.txt' + if (!fs.existsSync(localFilePath)) { + fs.writeFileSync(localFilePath, 'hello from the file-upload example\n') + } + const data = fs.readFileSync(localFilePath) + + const fileUid = await uploadFile({storage: storage}, ownerRecord, { + name: 'upload-me.txt', + title: 'upload-me.txt', + type: 'text/plain', + data: data + }) + console.log(`uploaded file UID: ${fileUid}`) + + // Round-trip: re-fetch the record (uploadFile doesn't mutate the in-memory copy) and + // download the file we just uploaded to prove the bytes round-trip correctly. + // + // The server can take a moment after uploadFile() returns before the file's download + // URL is populated in a getSecrets() response, so poll briefly rather than fetching once. + let uploadedFile + for (let attempt = 1; attempt <= 5 && !uploadedFile?.url; attempt++) { + const {records: refreshedRecords} = await getSecrets({storage: storage}, [ownerRecord.recordUid]) + uploadedFile = refreshedRecords[0].files.find(f => f.fileUid === fileUid) + if (!uploadedFile?.url) { + await new Promise(resolve => setTimeout(resolve, 1000)) + } + } + if (!uploadedFile?.url) { + throw new Error(`Uploaded file ${fileUid} has no download URL yet after 5 attempts`) + } + + const downloaded = await downloadFile(uploadedFile) + const matches = Buffer.compare(data, Buffer.from(downloaded)) === 0 + console.log(`downloaded bytes match uploaded bytes: ${matches}`) +} + +// uploadFile()'s underlying HTTP response is never drained, which leaves the process +// alive after main() resolves - exit explicitly rather than leave a script that appears +// to hang after printing its result. +main().finally(() => process.exit(0)) diff --git a/examples/javascript/file-upload/package.json b/examples/javascript/file-upload/package.json new file mode 100644 index 000000000..9107c1ed8 --- /dev/null +++ b/examples/javascript/file-upload/package.json @@ -0,0 +1,12 @@ +{ + "name": "file-upload", + "version": "1.0.0", + "description": "Secrets Manager file upload sample for Node", + "license": "ISC", + "scripts": { + "run": "node hello.js" + }, + "dependencies": { + "@keeper-security/secrets-manager-core": "17.6.0" + } +} diff --git a/examples/javascript/folders/.gitignore b/examples/javascript/folders/.gitignore new file mode 100644 index 000000000..f7d4a3c34 --- /dev/null +++ b/examples/javascript/folders/.gitignore @@ -0,0 +1,2 @@ +node_modules +config.json diff --git a/examples/javascript/folders/README.md b/examples/javascript/folders/README.md new file mode 100644 index 000000000..39a31b348 --- /dev/null +++ b/examples/javascript/folders/README.md @@ -0,0 +1,21 @@ +# Folders + +Lists folders, creates a new one, renames it, then deletes it. + +## Functions demonstrated + +- `getFolders(options)`: returns every folder the application has access to, as `KeeperFolder[]` (`{ folderUid, parentUid?, name? }`). +- `createFolder(options, createOptions, folderName)`: creates a new folder. `createOptions` is `{ folderUid, subFolderUid? }`: + - `folderUid` must be the UID of a shared folder (a `KeeperFolder` entry with no `parentUid`, which is what distinguishes a shared folder from a regular sub-folder in the `getFolders()` result). It becomes the new folder's shared-folder association, not its direct visual parent. + - `subFolderUid` is optional; set it to an existing regular folder's UID to nest the new folder under it instead of directly under the shared folder. +- `updateFolder(options, folderUid, folderName)`: renames a folder. +- `deleteFolder(options, folderUids, forceDeletion?)`: deletes one or more folders by UID. + +## Running + +1. Replace the placeholder token in `hello.js` with a real one-time access token for your vault. +2. Make sure the vault has at least one shared folder. +3. `npm install` +4. `npm run run` + +Expected output: the current folder list, the new folder's UID, a rename confirmation, then the delete result. diff --git a/examples/javascript/folders/hello.js b/examples/javascript/folders/hello.js new file mode 100644 index 000000000..d283618c4 --- /dev/null +++ b/examples/javascript/folders/hello.js @@ -0,0 +1,38 @@ +const { + getFolders, + createFolder, + updateFolder, + deleteFolder, + initializeStorage, + localConfigStorage +} = require('@keeper-security/secrets-manager-core') + +const main = async () => { + const storage = localConfigStorage("config.json") + // if your Keeper Account is in other region than US, update the hostname accordingly + await initializeStorage(storage, 'US:EXAMPLE_ONE_TIME_TOKEN', 'keepersecurity.com') + + const folders = await getFolders({storage: storage}) + console.log(folders) + + // A folder with no parentUid is itself a shared folder. New folders must be created + // inside one - pass its UID as createOptions.folderUid (it becomes the new folder's + // sharedFolderUid, not a direct parent; pass createOptions.subFolderUid too to nest + // under an existing regular folder instead of directly under the shared folder). + const sharedFolder = folders.find(f => !f.parentUid) + if (!sharedFolder) { + console.log('No shared folder found - create one in the vault first') + return + } + + const newFolderUid = await createFolder({storage: storage}, {folderUid: sharedFolder.folderUid}, 'Example folder') + console.log(`created folder UID: ${newFolderUid}`) + + await updateFolder({storage: storage}, newFolderUid, 'Example folder (renamed)') + console.log('renamed folder') + + const deleteResult = await deleteFolder({storage: storage}, [newFolderUid]) + console.log(`delete result: ${JSON.stringify(deleteResult)}`) +} + +main().finally() diff --git a/examples/javascript/folders/package.json b/examples/javascript/folders/package.json new file mode 100644 index 000000000..dc9a8f13f --- /dev/null +++ b/examples/javascript/folders/package.json @@ -0,0 +1,12 @@ +{ + "name": "folders", + "version": "1.0.0", + "description": "Secrets Manager folder CRUD sample for Node", + "license": "ISC", + "scripts": { + "run": "node hello.js" + }, + "dependencies": { + "@keeper-security/secrets-manager-core": "17.6.0" + } +} diff --git a/examples/javascript/notation/.gitignore b/examples/javascript/notation/.gitignore new file mode 100644 index 000000000..f7d4a3c34 --- /dev/null +++ b/examples/javascript/notation/.gitignore @@ -0,0 +1,2 @@ +node_modules +config.json diff --git a/examples/javascript/notation/README.md b/examples/javascript/notation/README.md new file mode 100644 index 000000000..d25350d4c --- /dev/null +++ b/examples/javascript/notation/README.md @@ -0,0 +1,26 @@ +# Notation + +Looks up a single secret value by notation instead of paging through a full `getSecrets()` result. + +## Notation format + +``` +keeper:///field/ +keeper:///custom_field/ +keeper:///file/ +``` + +The `keeper://` prefix is optional. + +## Functions demonstrated + +- `getNotationResults(options, notation)`: resolves a notation string to a list of values, throws if the notation is invalid or the target isn't found. +- `tryGetNotationResults(options, notation)`: same lookup, but logs and returns an empty array instead of throwing. + +## Running + +1. Replace the placeholder token in `hello.js` with a real one-time access token for your vault. +2. `npm install` +3. `npm run run` + +Expected output: the first record's `login` field value resolved via notation, an empty-array result from `tryGetNotationResults` against a field that doesn't exist, and a caught error from `getNotationResults` against the same missing field. diff --git a/examples/javascript/notation/hello.js b/examples/javascript/notation/hello.js new file mode 100644 index 000000000..9d6340e35 --- /dev/null +++ b/examples/javascript/notation/hello.js @@ -0,0 +1,37 @@ +const { + getSecrets, + initializeStorage, + localConfigStorage, + getNotationResults, + tryGetNotationResults +} = require('@keeper-security/secrets-manager-core') + +const main = async () => { + const storage = localConfigStorage("config.json") + // if your Keeper Account is in other region than US, update the hostname accordingly + await initializeStorage(storage, 'US:EXAMPLE_ONE_TIME_TOKEN', 'keepersecurity.com') + + const {records} = await getSecrets({storage: storage}) + const firstRecord = records[0] + + // Notation addresses a single value by record UID/title + selector, without paging + // through a full getSecrets() result yourself: keeper:///field/ + const loginNotation = `keeper://${firstRecord.recordUid}/field/login` + const [login] = await getNotationResults({storage: storage}, loginNotation) + console.log(`login via notation: ${login}`) + + // tryGetNotationResults() never throws - it logs and returns an empty array on error, + // so it's safe to call speculatively against a field that may not be present. + const missingNotation = `keeper://${firstRecord.recordUid}/field/does_not_exist` + const missing = await tryGetNotationResults({storage: storage}, missingNotation) + console.log(`missing field via tryGetNotationResults: ${JSON.stringify(missing)} (empty array, no throw)`) + + // getNotationResults() is the throwing variant - same lookup, surfaced as a real error. + try { + await getNotationResults({storage: storage}, missingNotation) + } catch (e) { + console.log(`getNotationResults threw as expected: ${e.message}`) + } +} + +main().finally() diff --git a/examples/javascript/notation/package.json b/examples/javascript/notation/package.json new file mode 100644 index 000000000..00e979dab --- /dev/null +++ b/examples/javascript/notation/package.json @@ -0,0 +1,12 @@ +{ + "name": "notation", + "version": "1.0.0", + "description": "Secrets Manager notation lookup sample for Node", + "license": "ISC", + "scripts": { + "run": "node hello.js" + }, + "dependencies": { + "@keeper-security/secrets-manager-core": "17.6.0" + } +} diff --git a/examples/javascript/pam-linked-records/.gitignore b/examples/javascript/pam-linked-records/.gitignore new file mode 100644 index 000000000..f7d4a3c34 --- /dev/null +++ b/examples/javascript/pam-linked-records/.gitignore @@ -0,0 +1,2 @@ +node_modules +config.json diff --git a/examples/javascript/pam-linked-records/README.md b/examples/javascript/pam-linked-records/README.md new file mode 100644 index 000000000..68f1e6bab --- /dev/null +++ b/examples/javascript/pam-linked-records/README.md @@ -0,0 +1,36 @@ +# PAM linked records + +Reads a PAM record's linked records - the mechanism PAM resources use to associate a +credential, connection/rotation metadata, JIT elevation settings, and AI risk settings +with a resource record. + +## Concept + +In the Keeper vault, a PAM resource (e.g. a machine or database) links to other records rather than +embedding their data directly. Those links are exposed on `record.links` as a raw +`{ recordUid, data?, path? }[]`. `path` identifies what kind of link it is: + +- `'meta'`: rotation/connection permission metadata (plain JSON) +- `'jit_settings'`: just-in-time elevation settings (encrypted) +- `'ai_settings'`: AI risk-level settings (encrypted) +- no path: a credential link (admin/IAM/launch-credential flags) + +## Functions demonstrated + +- `getLinks(record)`: wraps `record.links` as `KeeperRecordLink[]`, one typed accessor per link. Decryption + keys are pulled automatically from the SDK's internal key cache (already populated by the preceding + `getSecrets()` call), so none of the accessor methods below need a key argument in normal use. +- `KeeperRecordLink` accessors used here: `getMetaData()`, `allowsRotation()`, `allowsConnections()`, + `getJitSettingsData()`, `getAiSettingsData()`, `isAdminUser()`, `isLaunchCredential()`. Several more + exist (`isIamUser()`, `belongsTo()`, `allowsPortForwards()`, `getRotationSettings()`, `getAllowedSettings()`, + and the generic `getLinkData()`/`getDecryptedData()` for reading a link's raw payload directly). + +## Running + +1. Replace the placeholder token in `hello.js` with a real one-time access token for your vault. +2. Make sure the vault has at least one PAM record with linked records. +3. `npm install` +4. `npm run run` + +Expected output: the linked-record count for the first record that has any, followed by each link's UID, +path, and the relevant typed accessor values for that path. diff --git a/examples/javascript/pam-linked-records/hello.js b/examples/javascript/pam-linked-records/hello.js new file mode 100644 index 000000000..5b9413e5d --- /dev/null +++ b/examples/javascript/pam-linked-records/hello.js @@ -0,0 +1,47 @@ +const { + getSecrets, + getLinks, + initializeStorage, + localConfigStorage +} = require('@keeper-security/secrets-manager-core') + +const main = async () => { + const storage = localConfigStorage("config.json") + // if your Keeper Account is in other region than US, update the hostname accordingly + await initializeStorage(storage, 'US:EXAMPLE_ONE_TIME_TOKEN', 'keepersecurity.com') + + const {records} = await getSecrets({storage: storage}) + + // A PAM record's linked records (a credential, rotation/connection metadata, JIT + // elevation settings, AI risk settings, ...) live in record.links, not a dedicated + // field. getLinks() wraps each raw link in a typed accessor. Decryption keys are + // pulled automatically from the same key cache getSecrets() just populated, so + // there's no need to supply one explicitly for any of the calls below. + const record = records.find(r => (r.links || []).length > 0) + if (!record) { + console.log('No record with linked records found') + return + } + + const links = getLinks(record) + console.log(`${record.recordUid} has ${links.length} linked record(s)`) + + for (const link of links) { + console.log(`- linked record ${link.recordUid} (path: ${link.path ?? 'none'})`) + + if (link.path === 'meta') { + console.log(` metadata: ${JSON.stringify(await link.getMetaData())}`) + console.log(` allows rotation: ${link.allowsRotation()}, allows connections: ${link.allowsConnections()}`) + } else if (link.path === 'jit_settings') { + console.log(` JIT elevation settings: ${JSON.stringify(await link.getJitSettingsData())}`) + } else if (link.path === 'ai_settings') { + console.log(` AI risk settings: ${JSON.stringify(await link.getAiSettingsData())}`) + } else { + // A credential-type link has no path - flags are read directly off its + // decoded link data via the boolean accessors. + console.log(` is admin user: ${link.isAdminUser()}, is launch credential: ${link.isLaunchCredential()}`) + } + } +} + +main().finally() diff --git a/examples/javascript/pam-linked-records/package.json b/examples/javascript/pam-linked-records/package.json new file mode 100644 index 000000000..741bb9177 --- /dev/null +++ b/examples/javascript/pam-linked-records/package.json @@ -0,0 +1,12 @@ +{ + "name": "pam-linked-records", + "version": "1.0.0", + "description": "Secrets Manager PAM linked record sample for Node", + "license": "ISC", + "scripts": { + "run": "node hello.js" + }, + "dependencies": { + "@keeper-security/secrets-manager-core": "17.6.0" + } +} diff --git a/examples/javascript/totp/.gitignore b/examples/javascript/totp/.gitignore new file mode 100644 index 000000000..f7d4a3c34 --- /dev/null +++ b/examples/javascript/totp/.gitignore @@ -0,0 +1,2 @@ +node_modules +config.json diff --git a/examples/javascript/totp/README.md b/examples/javascript/totp/README.md new file mode 100644 index 000000000..6fa481f0f --- /dev/null +++ b/examples/javascript/totp/README.md @@ -0,0 +1,20 @@ +# TOTP + +Generates a time-based one-time password code from a record's `oneTimeCode` field. + +## Function demonstrated + +`getTotpCode(url, unixTimeSeconds?)`: takes the `otpauth://` URL stored in a record's `oneTimeCode` field +and returns `{ code, timeLeft, period }`, or `null` if the URL isn't a valid otpauth URL. + +`timeLeft` is how many seconds remain before `code` rotates; `period` is the rotation interval the URL specifies (usually 30s). +The optional `unixTimeSeconds` argument computes the code for a specific point in time instead of "now", useful for deterministic tests. + +## Running + +1. Replace the placeholder token in `hello.js` with a real one-time access token for your vault. +2. Make sure the vault has at least one record with a TOTP field configured. +3. `npm install` +4. `npm run run` + +Expected output: the current TOTP code and time remaining, followed by the code for a fixed point in time. diff --git a/examples/javascript/totp/hello.js b/examples/javascript/totp/hello.js new file mode 100644 index 000000000..0f6d5a0bf --- /dev/null +++ b/examples/javascript/totp/hello.js @@ -0,0 +1,39 @@ +const { + getSecrets, + initializeStorage, + localConfigStorage, + getTotpCode +} = require('@keeper-security/secrets-manager-core') + +const main = async () => { + const storage = localConfigStorage("config.json") + // if your Keeper Account is in other region than US, update the hostname accordingly + await initializeStorage(storage, 'US:EXAMPLE_ONE_TIME_TOKEN', 'keepersecurity.com') + + const {records} = await getSecrets({storage: storage}) + + // The otpauth:// URL lives in a record's "oneTimeCode" field, not the record itself. + const record = records.find(r => r.data.fields.some(f => f.type === 'oneTimeCode')) + if (!record) { + console.log('No record with a oneTimeCode field found') + return + } + + const otpField = record.data.fields.find(f => f.type === 'oneTimeCode') + const otpUrl = otpField.value[0] + + const totp = await getTotpCode(otpUrl) + if (!totp) { + console.log('getTotpCode returned null - the field value was not a valid otpauth:// URL') + return + } + console.log(`code: ${totp.code}, time left: ${totp.timeLeft}s, period: ${totp.period}s`) + + // unixTimeSeconds lets a caller compute the code for a specific moment (e.g. for + // deterministic tests) instead of "now". + const fixedTime = 1700000000 + const totpAtFixedTime = await getTotpCode(otpUrl, fixedTime) + console.log(`code at ${fixedTime}: ${totpAtFixedTime.code}`) +} + +main().finally() diff --git a/examples/javascript/totp/package.json b/examples/javascript/totp/package.json new file mode 100644 index 000000000..c21e4f817 --- /dev/null +++ b/examples/javascript/totp/package.json @@ -0,0 +1,12 @@ +{ + "name": "totp", + "version": "1.0.0", + "description": "Secrets Manager TOTP code generation sample for Node", + "license": "ISC", + "scripts": { + "run": "node hello.js" + }, + "dependencies": { + "@keeper-security/secrets-manager-core": "17.6.0" + } +} From 308b06e8e78697197b721b29ced0b22f63c77674 Mon Sep 17 00:00:00 2001 From: Mateo Gallego Date: Wed, 2 Sep 2026 08:10:25 -0700 Subject: [PATCH 19/20] fix(javascript): stop getSharedFolderUid hanging on a cycle (KSM-1297) (#1153) * fix(javascript): stop getSharedFolderUid from hanging on a folder parent cycle (KSM-1297) getSharedFolderUid walked the folder parent chain in an unbounded while loop with no visited set, so a cycle in server-supplied folder data (two folders naming each other as parent, or a folder naming itself) spun forever. Because the loop is synchronous with no await, this blocked the entire JS event loop in Node and froze the tab in a browser, not just the calling promise. Bound the walk with a visited set and throw a descriptive error naming the folder UID where the cycle closes. The existing per-folder try/catch in fetchAndDecryptFolders (added by KSM-1079) already skips and continues on any thrown error, so no caller changes are needed. Add regression coverage for both the Node and browser platform builds: self-cycle, two- and three-folder rings with exact log-message assertions, a 500-folder ring proving the fix is genuinely bounded rather than merely fast, a real two-level non-cyclic nested folder chain to confirm unchanged behavior, and the pre-existing "parent not found" case to confirm its message stays distinct from the new cycle message. * test(javascript): fix KSM-1297 cycle tests after KSM-1267's error classification landed KSM-1267 merged into this branch's base after KSM-1297 was opened. It added an upfront missing-key check in fetchAndDecryptFolders that now runs before getSharedFolderUid, plus a summary console.error line whenever any folder is skipped. The cycle test fixtures used an empty folderKey placeholder, which the new check now intercepts before the folder ever reaches the cycle-detection code, and the new summary line shifted every exact call-count assertion by one. Give the cyclic fixtures a non-empty placeholder folderKey so they still reach and exercise the actual cycle-detection path instead of short-circuiting on the new check, update call counts to account for the summary line, and update the exact log-message assertions to match the new classification-tagged format. The getSharedFolderUid fix itself is unchanged. --- sdk/javascript/packages/core/src/keeper.ts | 5 +- .../core/test/keeper.browserPlatform.test.ts | 206 ++++++++++++++ .../packages/core/test/keeper.test.ts | 256 ++++++++++++++++++ 3 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 sdk/javascript/packages/core/test/keeper.browserPlatform.test.ts diff --git a/sdk/javascript/packages/core/src/keeper.ts b/sdk/javascript/packages/core/src/keeper.ts index baf857019..b9eaad229 100644 --- a/sdk/javascript/packages/core/src/keeper.ts +++ b/sdk/javascript/packages/core/src/keeper.ts @@ -992,7 +992,9 @@ const fetchAndDecryptSecrets = async (options: SecretManagerOptions, queryOption } const getSharedFolderUid = (folders: SecretsManagerResponseFolder[], parent: string): string | undefined => { - while (true) { + const visited = new Set() + while (!visited.has(parent)) { + visited.add(parent) const parentFolder = folders.find(x => x.folderUid === parent) if (!parentFolder) { return undefined @@ -1003,6 +1005,7 @@ const getSharedFolderUid = (folders: SecretsManagerResponseFolder[], parent: str return parent } } + throw new Error(`Folder data inconsistent - parent cycle detected at folder UID ${parent}`) }; // Converts a raw crypto/parse failure into a KeeperCryptoError classified by which mode this diff --git a/sdk/javascript/packages/core/test/keeper.browserPlatform.test.ts b/sdk/javascript/packages/core/test/keeper.browserPlatform.test.ts new file mode 100644 index 000000000..fddf832b7 --- /dev/null +++ b/sdk/javascript/packages/core/test/keeper.browserPlatform.test.ts @@ -0,0 +1,206 @@ +// Browser-platform counterpart to the parent-cycle tests in keeper.test.ts. +// +// Importing '../src/browser' (for its side effect only) connects the browser platform +// implementation instead of the node one: browserPlatform.ts is built entirely on the standard +// WebCrypto API (crypto.subtle) and global fetch, with no window or document dependency, so it +// runs correctly here under plain Node-based Jest without any DOM emulation. getSharedFolderUid +// itself has no platform-specific code (plain arrays and a Set), so what these tests actually +// prove is that the surrounding wrap/unwrap and encrypt/decrypt machinery still cooperates +// correctly with the fix once real WebCrypto, not Node's crypto module, is doing the work. +// +// platform.ts holds a single shared mutable module-level "platform" variable, set once by +// connectPlatform. src/browser/index.ts calls connectPlatform with the browser platform and only +// re-exports loadJsonConfig/inMemoryStorage from platform.ts by name (not the platform object +// itself), so platform and the rest of the needed exports are imported directly from their +// source modules below rather than through '../src/browser'. +import '../src/browser' +import {platform, inMemoryStorage, KeeperHttpResponse} from '../src/platform' +import {getFolders, initializeStorage, SecretManagerOptions} from '../src/keeper' + +// initializeStorage imports this as an AES key via the browser platform's importKey, which goes +// through crypto.subtle and therefore (unlike the node platform) requires a real 16/24/32 byte +// key once base64 decoded; a short placeholder string is not valid key material under WebCrypto. +const FAKE_CLIENT_KEY = 'YyIhK5wXFHj36wGBAOmBsxI3v5rIruINrC8KXjyM58c' + +afterEach(() => { + jest.restoreAllMocks() +}) + +test('getFolders skips a folder that names itself as its own parent instead of hanging (browser platform)', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(1) + const appKey = new Uint8Array(32).fill(2) + const rootFolderKey = new Uint8Array(32).fill(3) + const selfCycleUid = 'self-cycle-uid' + const rootUid = 'root-uid' + + const rootFolderKeyWrapped = await platform.encryptWithKey(rootFolderKey, appKey) + const rootFolderData = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify({name: 'Valid Root Folder'})), rootFolderKey, true) + + const serverResponse = { + folders: [ + // Never decrypted: getSharedFolderUid throws before folderKey/data are read. + {folderUid: selfCycleUid, folderKey: 'unused-folder-key', data: '', parent: selfCycleUid}, + {folderUid: rootUid, folderKey: platform.bytesToBase64(rootFolderKeyWrapped), data: platform.bytesToBase64(rootFolderData)} + ], + 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 => Promise.resolve({data: encryptedResponse, statusCode: 200, headers: []}) + + const kvs = inMemoryStorage({}) + await initializeStorage(kvs, `US:${FAKE_CLIENT_KEY}`) + await kvs.saveBytes('appKey', appKey) + + const options: SecretManagerOptions = {storage: kvs, queryFunction: queryFn} + const folders = await getFolders(options) + + expect(folders.length).toBe(1) + expect(folders[0].folderUid).toBe(rootUid) + expect(folders[0].name).toBe('Valid Root Folder') + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('parent cycle detected at folder UID')) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(selfCycleUid)) + + errorSpy.mockRestore() +}) + +test('getFolders detects a two-folder parent cycle and logs each folder naming the other as the cycle point (browser platform)', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(1) + const appKey = new Uint8Array(32).fill(2) + + const serverResponse = { + folders: [ + // Neither folder ever decrypts: getSharedFolderUid throws for both before + // folderKey/data are read, so they can stay empty. + {folderUid: 'folder-a', folderKey: 'unused-folder-key', data: '', parent: 'folder-b'}, + {folderUid: 'folder-b', folderKey: 'unused-folder-key', data: '', parent: 'folder-a'} + ], + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey) + + platform.getRandomBytes = () => transmissionKey + const queryFn = (): Promise => Promise.resolve({data: encryptedResponse, statusCode: 200, headers: []}) + + const kvs = inMemoryStorage({}) + await initializeStorage(kvs, `US:${FAKE_CLIENT_KEY}`) + await kvs.saveBytes('appKey', appKey) + + const options: SecretManagerOptions = {storage: kvs, queryFunction: queryFn} + const folders = await getFolders(options) + + expect(folders.length).toBe(0) + // 2 per-folder skip lines plus the KSM-1267 summary line naming both skipped UIDs. + expect(errorSpy).toHaveBeenCalledTimes(3) + // folder-a resolves its shared-folder lookup starting at folder-b: the walk visits folder-b + // then folder-a again, so the cycle closes back at folder-b. The cycle error is a plain + // Error (not a KeeperCryptoError), so KSM-1267's classifier labels it "unknown". + expect(errorSpy.mock.calls[0][0]).toBe( + 'Folder folder-a skipped due to error (unknown): Error, Folder data inconsistent - parent cycle detected at folder UID folder-b' + ) + // folder-b's lookup starts at folder-a and symmetrically closes back at folder-a. + expect(errorSpy.mock.calls[1][0]).toBe( + 'Folder folder-b skipped due to error (unknown): Error, Folder data inconsistent - parent cycle detected at folder UID folder-a' + ) + + errorSpy.mockRestore() +}) + +// The wall-clock bound below is the real regression guard: a synchronous infinite loop cannot be +// preempted by Jest's timer-based timeout, so a future revert of the fix would hang this test +// indefinitely rather than fail fast; the bounded implementation is what keeps this test reliable. +test('getFolders resolves a large folder-parent cycle quickly instead of hanging the event loop (browser platform)', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(1) + const ringSize = 500 + const ringFolders: { folderUid: string, folderKey: string, data: string, parent: string }[] = [] + for (let i = 0; i < ringSize; i++) { + ringFolders.push({folderUid: `folder-${i}`, folderKey: 'unused-folder-key', data: '', parent: `folder-${(i + 1) % ringSize}`}) + } + + const serverResponse = {folders: ringFolders, records: [], expiresOn: 0, warnings: []} + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey) + + platform.getRandomBytes = () => transmissionKey + const queryFn = (): Promise => Promise.resolve({data: encryptedResponse, statusCode: 200, headers: []}) + + const kvs = inMemoryStorage({}) + await initializeStorage(kvs, `US:${FAKE_CLIENT_KEY}`) + + const start = Date.now() + const folders = await getFolders({storage: kvs, queryFunction: queryFn}) + const elapsed = Date.now() - start + + expect(folders).toEqual([]) + expect(elapsed).toBeLessThan(2000) + + errorSpy.mockRestore() +}, 5000) + +test('getFolders decrypts a real two-level, non-cyclic parent chain (root then child) (browser platform)', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(1) + const appKey = new Uint8Array(32).fill(2) + const rootFolderKey = new Uint8Array(32).fill(3) + const childFolderKey = new Uint8Array(32).fill(4) + const rootUid = 'root-folder-uid' + const childUid = 'child-folder-uid' + + const rootFolderKeyWrapped = await platform.encryptWithKey(rootFolderKey, appKey) + const rootFolderData = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify({name: 'Root Folder'})), rootFolderKey, true) + + // The child's folder key is wrapped with the root's raw key bytes, matching how + // fetchAndDecryptFolders unwraps a nested folder's key against its resolved shared folder. + const childFolderKeyWrapped = await platform.encryptWithKey(childFolderKey, rootFolderKey, true) + const childFolderData = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify({name: 'Child Folder'})), childFolderKey, true) + + const serverResponse = { + folders: [ + // Root listed before child: the root's key must be cached before the child's own + // unwrap (which unwraps against the root) runs later in the same loop. + {folderUid: rootUid, folderKey: platform.bytesToBase64(rootFolderKeyWrapped), data: platform.bytesToBase64(rootFolderData)}, + {folderUid: childUid, folderKey: platform.bytesToBase64(childFolderKeyWrapped), data: platform.bytesToBase64(childFolderData), parent: rootUid} + ], + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey) + + platform.getRandomBytes = () => transmissionKey + const queryFn = (): Promise => Promise.resolve({data: encryptedResponse, statusCode: 200, headers: []}) + + const kvs = inMemoryStorage({}) + await initializeStorage(kvs, `US:${FAKE_CLIENT_KEY}`) + await kvs.saveBytes('appKey', appKey) + + const options: SecretManagerOptions = {storage: kvs, queryFunction: queryFn} + const folders = await getFolders(options) + + expect(folders.length).toBe(2) + expect(folders.find(f => f.folderUid === rootUid)?.name).toBe('Root Folder') + expect(folders.find(f => f.folderUid === childUid)?.name).toBe('Child Folder') + expect(errorSpy).not.toHaveBeenCalled() + + errorSpy.mockRestore() +}) diff --git a/sdk/javascript/packages/core/test/keeper.test.ts b/sdk/javascript/packages/core/test/keeper.test.ts index c37f8580a..34b5a5d98 100644 --- a/sdk/javascript/packages/core/test/keeper.test.ts +++ b/sdk/javascript/packages/core/test/keeper.test.ts @@ -14,6 +14,10 @@ const FAKE_ONE_TIME_TOKEN = 'YyIhK5wXFHj36wGBAOmBsxI3v5rIruINrC8KXjyM58c' const keyErrorResponse = (keyId: number) => JSON.stringify({ error: 'key', key_id: keyId }) +afterEach(() => { + jest.restoreAllMocks() +}) + test('Get secrets e2e', async () => { const responses: { transmissionKey: string, data: string, statusCode: number } [] = JSON.parse(fs.readFileSync('../../../fake_data.json').toString()) @@ -577,3 +581,255 @@ test('flat record with innerFolderUid falls back to the app key when no matching expect(secrets.records.length).toBe(1) expect(secrets.records[0].data.title).toBe('Orphaned Record') }) + +test('getFolders skips a folder that names itself as its own parent instead of hanging', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(11) + const appKey = new Uint8Array(32).fill(12) + const folderKey = new Uint8Array(32).fill(13) + + const goodFolderKeyWrapped = await platform.encryptWithKey(folderKey, appKey) + const goodFolderData = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify({ name: 'Good Root Folder' })), folderKey, true) + + const serverResponse = { + folders: [ + { folderUid: 'self-parent-uid', folderKey: 'unused-folder-key', data: '', parent: 'self-parent-uid' }, + { folderUid: 'good-root-uid', folderKey: platform.bytesToBase64(goodFolderKeyWrapped), data: platform.bytesToBase64(goodFolderData) } + ], + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey) + + platform.getRandomBytes = () => transmissionKey + const queryFn = (): Promise => 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-root-uid') + expect(folders[0].name).toBe('Good Root Folder') + + const cycleLog = consoleErrorSpy.mock.calls.map(call => call[0]).find(msg => msg.includes('self-parent-uid')) + expect(cycleLog).toBeDefined() + expect(cycleLog).toContain('parent cycle detected at folder UID self-parent-uid') + + consoleErrorSpy.mockRestore() +}) + +test('getFolders detects a two-folder parent cycle and logs each folder naming the other as the cycle point', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(21) + + const serverResponse = { + folders: [ + { folderUid: 'folder-a', folderKey: 'unused-folder-key', data: '', parent: 'folder-b' }, + { folderUid: 'folder-b', folderKey: 'unused-folder-key', data: '', parent: 'folder-a' } + ], + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey) + + platform.getRandomBytes = () => transmissionKey + const queryFn = (): Promise => Promise.resolve({ data: encryptedResponse, statusCode: 200, headers: [] }) + + const kvs = inMemoryStorage({}) + await initializeStorage(kvs, 'US:FAKE_CLIENT_KEY') + + const folders = await getFolders({ storage: kvs, queryFunction: queryFn }) + + expect(folders).toEqual([]) + // 2 per-folder skip lines plus the KSM-1267 summary line naming both skipped UIDs. + expect(consoleErrorSpy).toHaveBeenCalledTimes(3) + expect(consoleErrorSpy.mock.calls[0][0]).toContain('Folder folder-a skipped due to error') + expect(consoleErrorSpy.mock.calls[0][0]).toContain('Folder data inconsistent - parent cycle detected at folder UID folder-b') + expect(consoleErrorSpy.mock.calls[1][0]).toContain('Folder folder-b skipped due to error') + expect(consoleErrorSpy.mock.calls[1][0]).toContain('Folder data inconsistent - parent cycle detected at folder UID folder-a') + + consoleErrorSpy.mockRestore() +}) + +test('getFolders detects a longer three-folder parent cycle and skips every folder in the ring', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(31) + + const serverResponse = { + folders: [ + { folderUid: 'ring-a', folderKey: 'unused-folder-key', data: '', parent: 'ring-b' }, + { folderUid: 'ring-b', folderKey: 'unused-folder-key', data: '', parent: 'ring-c' }, + { folderUid: 'ring-c', folderKey: 'unused-folder-key', data: '', parent: 'ring-a' } + ], + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey) + + platform.getRandomBytes = () => transmissionKey + const queryFn = (): Promise => Promise.resolve({ data: encryptedResponse, statusCode: 200, headers: [] }) + + const kvs = inMemoryStorage({}) + await initializeStorage(kvs, 'US:FAKE_CLIENT_KEY') + + const folders = await getFolders({ storage: kvs, queryFunction: queryFn }) + + expect(folders).toEqual([]) + // 3 per-folder skip lines plus the KSM-1267 summary line naming all three skipped UIDs. + expect(consoleErrorSpy).toHaveBeenCalledTimes(4) + for (const call of consoleErrorSpy.mock.calls.slice(0, 3)) { + expect(call[0]).toContain('parent cycle detected at folder UID') + } + + consoleErrorSpy.mockRestore() +}) + +// The wall-clock bound below is the real regression guard: a synchronous infinite loop cannot be +// preempted by Jest's timer-based timeout, so a future revert of the fix would hang this test +// indefinitely rather than fail fast; the bounded implementation is what keeps this test reliable. +test('getFolders resolves a large folder-parent cycle quickly instead of hanging the event loop', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(41) + const ringSize = 500 + const ringFolders: { folderUid: string, folderKey: string, data: string, parent: string }[] = [] + for (let i = 0; i < ringSize; i++) { + ringFolders.push({ folderUid: `folder-${i}`, folderKey: 'unused-folder-key', data: '', parent: `folder-${(i + 1) % ringSize}` }) + } + + const serverResponse = { + folders: ringFolders, + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey) + + platform.getRandomBytes = () => transmissionKey + const queryFn = (): Promise => Promise.resolve({ data: encryptedResponse, statusCode: 200, headers: [] }) + + const kvs = inMemoryStorage({}) + await initializeStorage(kvs, 'US:FAKE_CLIENT_KEY') + + const start = Date.now() + const folders = await getFolders({ storage: kvs, queryFunction: queryFn }) + const elapsed = Date.now() - start + + expect(folders).toEqual([]) + expect(elapsed).toBeLessThan(2000) + // ringSize per-folder skip lines plus the KSM-1267 summary line, which is not itself a + // cycle message, so it is checked separately from the loop below. + expect(consoleErrorSpy).toHaveBeenCalledTimes(ringSize + 1) + for (const call of consoleErrorSpy.mock.calls.slice(0, ringSize)) { + expect(call[0]).toContain('parent cycle detected at folder UID') + } + expect(consoleErrorSpy.mock.calls[ringSize][0]).toContain(`getFolders: ${ringSize} of ${ringSize} folder(s) could not be decrypted`) + + consoleErrorSpy.mockRestore() +}, 5000) + +test('getFolders decrypts a real two-level, non-cyclic parent chain (root then child)', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(51) + const appKey = new Uint8Array(32).fill(52) + const rootFolderKey = new Uint8Array(32).fill(53) + const childFolderKey = new Uint8Array(32).fill(54) + const rootUid = 'root-folder-uid' + const childUid = 'child-folder-uid' + + const rootFolderKeyWrapped = await platform.encryptWithKey(rootFolderKey, appKey) + const rootFolderData = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify({ name: 'Root Folder' })), rootFolderKey, true) + + const childFolderKeyWrapped = await platform.encryptWithKey(childFolderKey, rootFolderKey, true) + const childFolderData = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify({ name: 'Child Folder' })), childFolderKey, true) + + const serverResponse = { + folders: [ + { folderUid: rootUid, folderKey: platform.bytesToBase64(rootFolderKeyWrapped), data: platform.bytesToBase64(rootFolderData) }, + { folderUid: childUid, folderKey: platform.bytesToBase64(childFolderKeyWrapped), data: platform.bytesToBase64(childFolderData), parent: rootUid } + ], + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey) + + platform.getRandomBytes = () => transmissionKey + const queryFn = (): Promise => 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(2) + const root = folders.find(f => f.folderUid === rootUid) + const child = folders.find(f => f.folderUid === childUid) + expect(root?.name).toBe('Root Folder') + expect(child?.name).toBe('Child Folder') + expect(consoleErrorSpy).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() +}) + +test('getFolders keeps the "unable to locate shared folder" message distinct from the cycle message', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const transmissionKey = new Uint8Array(32).fill(61) + const appKey = new Uint8Array(32).fill(62) + const folderKey = new Uint8Array(32).fill(63) + + const goodFolderKeyWrapped = await platform.encryptWithKey(folderKey, appKey) + const goodFolderData = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify({ name: 'Good Folder' })), folderKey, true) + + const serverResponse = { + folders: [ + { folderUid: 'orphan-uid', folderKey: 'unused-folder-key', data: '', parent: 'missing-parent-uid' }, + { folderUid: 'good-uid', folderKey: platform.bytesToBase64(goodFolderKeyWrapped), data: platform.bytesToBase64(goodFolderData) } + ], + records: [], + expiresOn: 0, + warnings: [] + } + const encryptedResponse = await platform.encryptWithKey( + platform.stringToBytes(JSON.stringify(serverResponse)), transmissionKey) + + platform.getRandomBytes = () => transmissionKey + const queryFn = (): Promise => 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') + + // 1 per-folder skip line plus the KSM-1267 summary line. + expect(consoleErrorSpy).toHaveBeenCalledTimes(2) + expect(consoleErrorSpy.mock.calls[0][0]).toBe('Folder orphan-uid skipped due to error (missing-key): KeeperCryptoError, Folder data inconsistent - unable to locate shared folder for orphan-uid') + expect(consoleErrorSpy.mock.calls[0][0]).not.toContain('parent cycle detected') + + consoleErrorSpy.mockRestore() +}) From f7c33bc110e8e4f4341d83cc0741c3476c8597ce Mon Sep 17 00:00:00 2001 From: stas-schaller Date: Thu, 3 Sep 2026 11:23:09 -0400 Subject: [PATCH 20/20] chore(javascript): bump example core version pins, migrate share-client to Vite (#1156) hello-secret (16.0.12), proxy-support and custom-caching-function-support (both 17.3.0) were stale relative to the SDK's current 17.6.0. hello-secret's yarn.lock is dropped rather than regenerated against 17.6.0, which hasn't published to npm yet, matching the other three example directories, none of which commit a lockfile. share-client also moves off Create React App (react-scripts 4.0.3, archived upstream) onto Vite, bumping react-scripts/react 17/typescript 4.1 to vite 8/react 19/typescript 5.9 and dropping the stale Node-12-era @types/node pin. README.md's dead IndexedDB blog link is removed as part of the same cleanup. Supersedes #1150, which merged into the wrong base branch and never reached this release branch. KSM-1320 --- .gitignore | 1 + .../package.json | 2 +- examples/javascript/hello-secret/package.json | 2 +- examples/javascript/hello-secret/yarn.lock | 8 --- .../javascript/proxy-support/package.json | 2 +- examples/javascript/share-client/.gitignore | 2 +- examples/javascript/share-client/README.md | 9 +++- examples/javascript/share-client/index.html | 21 ++++++++ examples/javascript/share-client/package.json | 51 ++++++------------- .../javascript/share-client/public/index.html | 43 ---------------- .../javascript/share-client/src/App.test.tsx | 9 ---- examples/javascript/share-client/src/App.tsx | 2 +- .../javascript/share-client/src/index.tsx | 13 ++--- .../share-client/src/react-app-env.d.ts | 1 - .../share-client/src/reportWebVitals.ts | 15 ------ .../javascript/share-client/src/setupTests.ts | 5 -- .../javascript/share-client/src/vite-env.d.ts | 1 + .../javascript/share-client/tsconfig.json | 32 +++++------- .../share-client/tsconfig.node.json | 11 ++++ .../javascript/share-client/vite.config.ts | 6 +++ 20 files changed, 84 insertions(+), 152 deletions(-) delete mode 100644 examples/javascript/hello-secret/yarn.lock create mode 100644 examples/javascript/share-client/index.html delete mode 100644 examples/javascript/share-client/public/index.html delete mode 100644 examples/javascript/share-client/src/App.test.tsx delete mode 100644 examples/javascript/share-client/src/react-app-env.d.ts delete mode 100644 examples/javascript/share-client/src/reportWebVitals.ts delete mode 100644 examples/javascript/share-client/src/setupTests.ts create mode 100644 examples/javascript/share-client/src/vite-env.d.ts create mode 100644 examples/javascript/share-client/tsconfig.node.json create mode 100644 examples/javascript/share-client/vite.config.ts diff --git a/.gitignore b/.gitignore index 18c90c487..037c45075 100644 --- a/.gitignore +++ b/.gitignore @@ -69,5 +69,6 @@ ansible.cfg # Except typescript configuration !tsconfig.json !tsconfig.test.json +!tsconfig.node.json .gradle/ diff --git a/examples/javascript/custom-caching-function-support/package.json b/examples/javascript/custom-caching-function-support/package.json index 428ae2599..1b8770470 100644 --- a/examples/javascript/custom-caching-function-support/package.json +++ b/examples/javascript/custom-caching-function-support/package.json @@ -8,6 +8,6 @@ "run": "node hello.js" }, "dependencies": { - "@keeper-security/secrets-manager-core": "17.3.0" + "@keeper-security/secrets-manager-core": "17.6.0" } } diff --git a/examples/javascript/hello-secret/package.json b/examples/javascript/hello-secret/package.json index 188bf20f8..c051fee64 100644 --- a/examples/javascript/hello-secret/package.json +++ b/examples/javascript/hello-secret/package.json @@ -7,6 +7,6 @@ "run" : "node hello.js" }, "dependencies": { - "@keeper-security/secrets-manager-core": "16.0.12" + "@keeper-security/secrets-manager-core": "17.6.0" } } diff --git a/examples/javascript/hello-secret/yarn.lock b/examples/javascript/hello-secret/yarn.lock deleted file mode 100644 index 5278f6cd0..000000000 --- a/examples/javascript/hello-secret/yarn.lock +++ /dev/null @@ -1,8 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@keeper-security/secrets-manager-core@16.0.12": - version "16.0.12" - resolved "https://registry.yarnpkg.com/@keeper-security/secrets-manager-core/-/secrets-manager-core-16.0.12.tgz#882299d4c18ddf828f20566c8f693e446e4a5ab9" - integrity sha512-i4ncaRAgPCHqhi+UkrhHvBfNyBT/yEHHXKukXgI/dCKDQWRVJgnkqdd9rSFM35viCD8F2mnPOz9I1VsE6H09MQ== diff --git a/examples/javascript/proxy-support/package.json b/examples/javascript/proxy-support/package.json index a52ef8dce..9b156adac 100644 --- a/examples/javascript/proxy-support/package.json +++ b/examples/javascript/proxy-support/package.json @@ -7,7 +7,7 @@ "run" : "node hello.js" }, "dependencies": { - "@keeper-security/secrets-manager-core": "17.3.0", + "@keeper-security/secrets-manager-core": "17.6.0", "https-proxy-agent": "^7.0.6" } } diff --git a/examples/javascript/share-client/.gitignore b/examples/javascript/share-client/.gitignore index f3b921246..ec47064d2 100644 --- a/examples/javascript/share-client/.gitignore +++ b/examples/javascript/share-client/.gitignore @@ -9,7 +9,7 @@ /coverage # production -/build +/dist # misc .DS_Store diff --git a/examples/javascript/share-client/README.md b/examples/javascript/share-client/README.md index fb349f0cd..d565c3e39 100644 --- a/examples/javascript/share-client/README.md +++ b/examples/javascript/share-client/README.md @@ -4,6 +4,11 @@ This application lets any user with a link receive a shared secret from Keeper. Only the first user who clicks the link can access the secret. -The SDK stores keys in IndexedDB: -https://blog.engelke.com/2014/09/19/saving-cryptographic-keys-in-the-browser/ +The SDK stores keys in IndexedDB. + +## Running + +- `npm run dev`: start a Vite dev server +- `npm run build`: produce a production build in `dist/` +- `npm run preview`: serve the production build locally diff --git a/examples/javascript/share-client/index.html b/examples/javascript/share-client/index.html new file mode 100644 index 000000000..6084d3990 --- /dev/null +++ b/examples/javascript/share-client/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + Keeper Share + + + +
+ + + diff --git a/examples/javascript/share-client/package.json b/examples/javascript/share-client/package.json index 2849b6b94..3c0b76b74 100644 --- a/examples/javascript/share-client/package.json +++ b/examples/javascript/share-client/package.json @@ -2,44 +2,23 @@ "name": "share-client", "version": "0.1.0", "private": true, + "type": "module", "dependencies": { - "@testing-library/jest-dom": "^5.11.4", - "@testing-library/react": "^11.1.0", - "@testing-library/user-event": "^12.1.10", - "@types/jest": "^26.0.15", - "@types/node": "^12.0.0", - "@types/react": "^17.0.0", - "@types/react-dom": "^17.0.0", - "react": "^17.0.2", - "react-dom": "^17.0.2", - "react-scripts": "4.0.3", - "typescript": "^4.1.2", - "web-vitals": "^1.0.1", - "@keeper-security/secrets-manager-core": "16.0.12" + "react": "^19.2.8", + "react-dom": "^19.2.8", + "@keeper-security/secrets-manager-core": "17.6.0" }, - "scripts": { - "start": "react-scripts start", - "start-https": "export HTTPS=true&&react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" - }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.5", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^5.9.3", + "vite": "^8.2.2" }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" } } diff --git a/examples/javascript/share-client/public/index.html b/examples/javascript/share-client/public/index.html deleted file mode 100644 index aa069f27c..000000000 --- a/examples/javascript/share-client/public/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - React App - - - -
- - - diff --git a/examples/javascript/share-client/src/App.test.tsx b/examples/javascript/share-client/src/App.test.tsx deleted file mode 100644 index 2a68616d9..000000000 --- a/examples/javascript/share-client/src/App.test.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/examples/javascript/share-client/src/App.tsx b/examples/javascript/share-client/src/App.tsx index 6fa6f91fd..4473d2e47 100644 --- a/examples/javascript/share-client/src/App.tsx +++ b/examples/javascript/share-client/src/App.tsx @@ -23,7 +23,7 @@ const Secrets = (props: any) => { }) setSecrets(secrets); } - catch (e) { + catch (e: any) { setSecrets(JSON.parse(e.message)); } }; diff --git a/examples/javascript/share-client/src/index.tsx b/examples/javascript/share-client/src/index.tsx index ef2edf8ea..dc9bf368c 100644 --- a/examples/javascript/share-client/src/index.tsx +++ b/examples/javascript/share-client/src/index.tsx @@ -1,17 +1,10 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App'; -import reportWebVitals from './reportWebVitals'; -ReactDOM.render( +createRoot(document.getElementById('root')!).render( - , - document.getElementById('root') + ); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); diff --git a/examples/javascript/share-client/src/react-app-env.d.ts b/examples/javascript/share-client/src/react-app-env.d.ts deleted file mode 100644 index 6431bc5fc..000000000 --- a/examples/javascript/share-client/src/react-app-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/javascript/share-client/src/reportWebVitals.ts b/examples/javascript/share-client/src/reportWebVitals.ts deleted file mode 100644 index 49a2a16e0..000000000 --- a/examples/javascript/share-client/src/reportWebVitals.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ReportHandler } from 'web-vitals'; - -const reportWebVitals = (onPerfEntry?: ReportHandler) => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); - } -}; - -export default reportWebVitals; diff --git a/examples/javascript/share-client/src/setupTests.ts b/examples/javascript/share-client/src/setupTests.ts deleted file mode 100644 index 8f2609b7b..000000000 --- a/examples/javascript/share-client/src/setupTests.ts +++ /dev/null @@ -1,5 +0,0 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; diff --git a/examples/javascript/share-client/src/vite-env.d.ts b/examples/javascript/share-client/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/examples/javascript/share-client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/javascript/share-client/tsconfig.json b/examples/javascript/share-client/tsconfig.json index a273b0cfc..76c4bfbe4 100644 --- a/examples/javascript/share-client/tsconfig.json +++ b/examples/javascript/share-client/tsconfig.json @@ -1,26 +1,22 @@ { "compilerOptions": { - "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "allowJs": true, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "module": "esnext", - "moduleResolution": "node", + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + + "strict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true }, - "include": [ - "src" - ] + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/examples/javascript/share-client/tsconfig.node.json b/examples/javascript/share-client/tsconfig.node.json new file mode 100644 index 000000000..97ede7ee6 --- /dev/null +++ b/examples/javascript/share-client/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/javascript/share-client/vite.config.ts b/examples/javascript/share-client/vite.config.ts new file mode 100644 index 000000000..eca6e2f35 --- /dev/null +++ b/examples/javascript/share-client/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()] +})