From 52b87c2ee6d543d0882c1c667014256329c3ca9d Mon Sep 17 00:00:00 2001 From: Tyler Carson Date: Mon, 12 Jan 2026 13:38:15 -0800 Subject: [PATCH 1/2] Handle switch from HPKE to EC when server doesn't support it - add dev govcloud ec public key - add tests for key rotation scenarios - refactor some test utils / setup code --- keeperapi/jest.config.js | 3 + keeperapi/package-lock.json | 4 +- keeperapi/package.json | 2 +- keeperapi/setup-jest.ts | 29 + .../src/__tests__/createUserRequest.test.ts | 10 - keeperapi/src/__tests__/crypto.test.ts | 10 - keeperapi/src/__tests__/endpoint.test.ts | 524 ++++++++++++++++++ .../src/__tests__/getOnsitePublicKey.test.ts | 7 - keeperapi/src/__tests__/qrc.test.ts | 80 +-- keeperapi/src/configuration.ts | 8 + keeperapi/src/endpoint.ts | 41 +- keeperapi/src/qrc/index.ts | 5 + keeperapi/src/qrc/pem.ts | 68 +++ keeperapi/src/transmissionKeys.ts | 6 +- 14 files changed, 674 insertions(+), 123 deletions(-) create mode 100644 keeperapi/setup-jest.ts create mode 100644 keeperapi/src/__tests__/endpoint.test.ts create mode 100644 keeperapi/src/qrc/pem.ts diff --git a/keeperapi/jest.config.js b/keeperapi/jest.config.js index 85d6ad7..aec65aa 100644 --- a/keeperapi/jest.config.js +++ b/keeperapi/jest.config.js @@ -13,4 +13,7 @@ module.exports = { // Module file extensions moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], + + // Setup file to run before each test file + setupFilesAfterEnv: ["/setup-jest.ts"], }; diff --git a/keeperapi/package-lock.json b/keeperapi/package-lock.json index 7c39d17..780852c 100644 --- a/keeperapi/package-lock.json +++ b/keeperapi/package-lock.json @@ -1,12 +1,12 @@ { "name": "@keeper-security/keeperapi", - "version": "17.0.3", + "version": "17.0.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@keeper-security/keeperapi", - "version": "17.0.3", + "version": "17.0.4", "license": "ISC", "dependencies": { "@noble/post-quantum": "^0.5.2", diff --git a/keeperapi/package.json b/keeperapi/package.json index 0af8f52..61324f0 100644 --- a/keeperapi/package.json +++ b/keeperapi/package.json @@ -1,7 +1,7 @@ { "name": "@keeper-security/keeperapi", "description": "Keeper API Javascript SDK", - "version": "17.0.3", + "version": "17.0.4", "browser": "dist/index.es.js", "main": "dist/index.cjs.js", "types": "dist/node/index.d.ts", diff --git a/keeperapi/setup-jest.ts b/keeperapi/setup-jest.ts new file mode 100644 index 0000000..1c36da4 --- /dev/null +++ b/keeperapi/setup-jest.ts @@ -0,0 +1,29 @@ +/** + * Jest setup file for jsdom environment + * This file is automatically loaded before each test file runs + */ + +// @ts-ignore +import crypto from 'crypto' +import { TextEncoder, TextDecoder } from 'util' + +// Set up global TextEncoder and TextDecoder for jsdom environment +Object.assign(global, { TextDecoder, TextEncoder }) + +// Set up crypto object with Web Crypto API for both browser and node environments +const cryptoObj = { + subtle: crypto.webcrypto.subtle, + getRandomValues: (array: Uint8Array) => { + const randomData = crypto.randomBytes(array.length) + array.set(randomData) + return array + } +} + +// Set up crypto for global.self (browser APIs) - only if self exists (jsdom environment) +if (typeof global.self !== 'undefined') { + Object.defineProperty(global.self, 'crypto', { value: cryptoObj }) +} + +// Set up crypto for globalThis (noble libraries) +Object.defineProperty(globalThis, 'crypto', { value: cryptoObj }) diff --git a/keeperapi/src/__tests__/createUserRequest.test.ts b/keeperapi/src/__tests__/createUserRequest.test.ts index ab492bf..07bec19 100644 --- a/keeperapi/src/__tests__/createUserRequest.test.ts +++ b/keeperapi/src/__tests__/createUserRequest.test.ts @@ -6,20 +6,10 @@ import crypto from 'crypto' import {nodePlatform} from "../node/platform"; import {browserPlatform} from "../browser/platform" -import {TextEncoder, TextDecoder} from 'util'; import {KeyWrapper, connectPlatform, platform} from "../platform"; import { Auth } from '../auth'; import { KeeperEnvironment } from '../endpoint'; -Object.assign(global, {TextDecoder, TextEncoder}) - -Object.defineProperty(global.self, 'crypto', { - value: { - subtle: crypto.webcrypto.subtle, - getRandomValues: (array: any) => crypto.randomBytes(array.length) - } -}) - describe('create user request', () => { const username = 'username' diff --git a/keeperapi/src/__tests__/crypto.test.ts b/keeperapi/src/__tests__/crypto.test.ts index 806e3b4..cb954a9 100644 --- a/keeperapi/src/__tests__/crypto.test.ts +++ b/keeperapi/src/__tests__/crypto.test.ts @@ -7,18 +7,8 @@ import crypto from 'crypto' import {nodePlatform} from "../node/platform"; import {browserPlatform} from "../browser/platform" import {publicKey, privateKey} from "./ecies-test-vectors"; -import {TextEncoder, TextDecoder} from 'util'; import {connectPlatform, platform} from "../platform"; -Object.assign(global, {TextDecoder, TextEncoder}) - -Object.defineProperty(global.self, 'crypto', { - value: { - subtle: crypto.webcrypto.subtle, - getRandomValues: (array: any) => crypto.randomBytes(array.length) - } -}) - describe('crypto test', () => { it('node API encrypts a message under EC and then decrypts it (test key pair)', async () => { connectPlatform(nodePlatform) diff --git a/keeperapi/src/__tests__/endpoint.test.ts b/keeperapi/src/__tests__/endpoint.test.ts new file mode 100644 index 0000000..5c60331 --- /dev/null +++ b/keeperapi/src/__tests__/endpoint.test.ts @@ -0,0 +1,524 @@ +/** + * @jest-environment jsdom + */ + +import { KeeperEndpoint } from '../endpoint' +import { platform, connectPlatform } from '../platform' +import { browserPlatform } from '../browser/platform' +import { ClientConfigurationInternal } from '../configuration' +import { AllowedMlKemKeyIds, isAllowedEcKeyId, isAllowedMlKemKeyId } from '../transmissionKeys' +import { KeeperError } from '../configuration' +import { Authentication } from '../proto' +import { startLoginMessage } from '../restMessages' +import { HPKE_ECDH_KYBER, Ciphersuite, MlKemVariant, mlKemKeygen, encodeMlKemPublicKeyToPem } from '../qrc' +import { getKeeperMlKemKeyVariant } from '../transmissionKeys' +import { KeeperHttpResponse } from '../commands' + +// Mock server key configuration +interface MockServerKeys { + ecKeyId: number + mlKemKeyId?: number // Optional: when undefined, server doesn't support HPKE +} + +// Store test server keys (generated in beforeAll) +const testServerKeys: { + [keyId: number]: { + publicKey: Uint8Array + privateKey: Uint8Array // EC private key as raw bytes + } +} = {} + +const testServerMlKemKeys: { + [keyId: number]: { + publicKey: Uint8Array + privateKey: Uint8Array // ML-KEM private key + } +} = {} + +let mockServerKeys: MockServerKeys + +describe('KeeperEndpoint - Transmission Key ID Rotation', () => { + let endpoint: KeeperEndpoint + let mockConfig: ClientConfigurationInternal + let onDeviceConfigMock = jest.fn() + let postSpy: jest.SpyInstance + const defaultEcKeyId = 10 + const defaultMlKemKeyId = 136 + + const startLoginRequest = startLoginMessage({ + clientVersion: '17.0.0', + username: 'test@example.com', + encryptedDeviceToken: new Uint8Array([1, 2, 3, 4, 5]), + loginType: Authentication.LoginType.NORMAL, + loginMethod: Authentication.LoginMethod.EXISTING_ACCOUNT, + }) + + beforeAll(async () => { + connectPlatform(browserPlatform) + + // Generate our own EC key pairs for testing, since we need private keys + const ecKeyIds = Object.keys(platform.keys) + .map(Number) + .filter(id => !isNaN(id) && isAllowedEcKeyId(id)) + for (const keyId of ecKeyIds) { + const ecdh = await platform.generateECKeyPair() + testServerKeys[keyId] = { + publicKey: ecdh.publicKey, + privateKey: ecdh.privateKey + } + platform.keys[keyId] = ecdh.publicKey + } + + // Generate our own ML-KEM key pairs for testing + const mlKemKeyIds = Object.keys(platform.mlKemKeys) + .map(Number) + .filter(id => !isNaN(id) && isAllowedMlKemKeyId(id)) + for (const keyId of mlKemKeyIds) { + const variant = getKeeperMlKemKeyVariant(keyId as AllowedMlKemKeyIds) + const mlKemKeyPair = mlKemKeygen(variant) + testServerMlKemKeys[keyId] = { + publicKey: mlKemKeyPair.publicKey, + privateKey: mlKemKeyPair.privateKey + } + const pemPublicKey = encodeMlKemPublicKeyToPem(mlKemKeyPair.publicKey, variant) + platform.mlKemKeys[keyId] = pemPublicKey + } + }) + + beforeEach(async () => { + jest.clearAllMocks() + + // Generate real keys for testing + const ecdh = await platform.generateECKeyPair() + + mockConfig = { + host: 'test.keepersecurity.com', + deviceConfig: { + deviceToken: new Uint8Array([1, 2, 3, 4, 5]), + privateKey: ecdh.privateKey, + publicKey: ecdh.publicKey, + deviceName: 'Test Device', + transmissionKeyId: defaultEcKeyId, + mlKemPublicKeyId: defaultMlKemKeyId, + }, + clientVersion: 'ec17.6.0', + onDeviceConfig: onDeviceConfigMock, + } + + endpoint = new KeeperEndpoint(mockConfig) + + // Default mock server keys match client config + mockServerKeys = { + ecKeyId: mockConfig.deviceConfig.transmissionKeyId!, + mlKemKeyId: mockConfig.deviceConfig.mlKemPublicKeyId!, + } + + // Single mock implementation for platform.post + postSpy = jest.spyOn(platform, 'post').mockImplementation(mockPlatformPost) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should rotate both EC and ML-KEM keys when using HPKE', async () => { + const newEcKeyId = 11 + const newMlKemKeyId = 124 + + // Enable HPKE for this test + mockConfig.useHpkeForTransmissionKey = true + mockConfig.deviceConfig.useHpkeTransmission = true + endpoint = new KeeperEndpoint(mockConfig) + + // Configure mock server to expect new key IDs + mockServerKeys.ecKeyId = newEcKeyId + mockServerKeys.mlKemKeyId = newMlKemKeyId + + // Ensure keys are different before rotation + expect(mockConfig.deviceConfig.transmissionKeyId).not.toBe(newEcKeyId) + expect(mockConfig.deviceConfig.mlKemPublicKeyId).not.toBe(newMlKemKeyId) + + // Execute startLogin which should trigger key rotation + await endpoint.executeRest(startLoginRequest) + + // Verify that updateTransmissionKey was called with correct parameters + expect(mockConfig.deviceConfig.transmissionKeyId).toBe(newEcKeyId) + expect(mockConfig.deviceConfig.mlKemPublicKeyId).toBe(newMlKemKeyId) + expect(onDeviceConfigMock).toHaveBeenCalledTimes(1) + expect(postSpy).toHaveBeenCalledTimes(2) // First fails, second succeeds + + // Check that both calls supplied a qrcMessageKey (HPKE), + // and updated keys were used in the second call + const firstCallArgs = postSpy.mock.calls[0] + const firstRequest = Authentication.ApiRequest.decode(firstCallArgs[1]) + expect(firstRequest.qrcMessageKey).toBeDefined() + expect(firstRequest.qrcMessageKey!.ecKeyId).toBe(defaultEcKeyId) + expect(firstRequest.publicKeyId).toBe(defaultMlKemKeyId) + const secondCallArgs = postSpy.mock.calls[1] + const secondRequest = Authentication.ApiRequest.decode(secondCallArgs[1]) + expect(secondRequest.qrcMessageKey).toBeDefined() + expect(secondRequest.qrcMessageKey!.ecKeyId).toBe(newEcKeyId) + expect(secondRequest.publicKeyId).toBe(newMlKemKeyId) + }) + + it('should rotate just EC key when using EC (not HPKE)', async () => { + const newEcKeyId = 11 + const newMlKemKeyId = 124 + + // Configure mock server to expect new key IDs + mockServerKeys.ecKeyId = newEcKeyId + mockServerKeys.mlKemKeyId = newMlKemKeyId + + // Ensure keys are different before rotation + expect(mockConfig.deviceConfig.transmissionKeyId).not.toBe(newEcKeyId) + expect(mockConfig.deviceConfig.mlKemPublicKeyId).not.toBe(newMlKemKeyId) + + // Execute startLogin which should trigger key rotation + await endpoint.executeRest(startLoginRequest) + + // Verify that EC key was updated and ML-KEM wasn't (since we only used EC) + expect(mockConfig.deviceConfig.transmissionKeyId).toBe(newEcKeyId) + expect(mockConfig.deviceConfig.mlKemPublicKeyId).toBe(defaultMlKemKeyId) + expect(onDeviceConfigMock).toHaveBeenCalledTimes(1) + expect(postSpy).toHaveBeenCalledTimes(2) // First fails, second succeeds + + // Check that first call used qrcMessageKey (HPKE), + // and second call did not (non-HPKE) + const firstCallArgs = postSpy.mock.calls[0] + const firstRequest = Authentication.ApiRequest.decode(firstCallArgs[1]) + expect(firstRequest.qrcMessageKey).toBeNull() + expect(firstRequest.publicKeyId).toBe(defaultEcKeyId) + const secondCallArgs = postSpy.mock.calls[1] + const secondRequest = Authentication.ApiRequest.decode(secondCallArgs[1]) + expect(secondRequest.qrcMessageKey).toBeNull() + expect(secondRequest.publicKeyId).toBe(newEcKeyId) + }) + + it('should use default keys when device config has no keys set (EC)', async () => { + const newEcKeyId = 11 + const newMlKemKeyId = 124 + + // Clear device config keys to simulate newly registered device + mockConfig.deviceConfig.transmissionKeyId = undefined + mockConfig.deviceConfig.mlKemPublicKeyId = undefined + endpoint = new KeeperEndpoint(mockConfig) + + // Configure mock server to expect new key IDs + mockServerKeys.ecKeyId = newEcKeyId + mockServerKeys.mlKemKeyId = newMlKemKeyId + + // Execute startLogin which should trigger key rotation + await endpoint.executeRest(startLoginRequest) + + // Verify that EC key was updated and ML-KEM wasn't (since we only used EC) + expect(mockConfig.deviceConfig.transmissionKeyId).toBe(newEcKeyId) + expect(mockConfig.deviceConfig.mlKemPublicKeyId).toBe(defaultMlKemKeyId) + expect(onDeviceConfigMock).toHaveBeenCalledTimes(1) + expect(postSpy).toHaveBeenCalledTimes(2) // First fails, second succeeds + }) + + it('should use default keys when device config has no keys set (HPKE)', async () => { + const newEcKeyId = 11 + const newMlKemKeyId = 124 + + // Clear device config keys to simulate newly registered device + mockConfig.deviceConfig.transmissionKeyId = undefined + mockConfig.deviceConfig.mlKemPublicKeyId = undefined + mockConfig.useHpkeForTransmissionKey = true + endpoint = new KeeperEndpoint(mockConfig) + + // Configure mock server to expect new key IDs + mockServerKeys.ecKeyId = newEcKeyId + mockServerKeys.mlKemKeyId = newMlKemKeyId + + // Execute startLogin which should trigger key rotation + await endpoint.executeRest(startLoginRequest) + + // Verify that both keys were updated + expect(mockConfig.deviceConfig.transmissionKeyId).toBe(newEcKeyId) + expect(mockConfig.deviceConfig.mlKemPublicKeyId).toBe(newMlKemKeyId) + expect(onDeviceConfigMock).toHaveBeenCalledTimes(1) + expect(postSpy).toHaveBeenCalledTimes(2) // First fails, second succeeds + }) + + it("should switch from HPKE to non-HPKE when server doesn't support HPKE", async () => { + const newEcKeyId = 11 + + // Enable HPKE for client + mockConfig.useHpkeForTransmissionKey = true + mockConfig.deviceConfig.useHpkeTransmission = true + endpoint = new KeeperEndpoint(mockConfig) + + // Configure mock server to not support HPKE (no ML-KEM key) + mockServerKeys = { + ecKeyId: newEcKeyId, + mlKemKeyId: undefined, // Server doesn't support HPKE + } + + // Execute startLogin + await endpoint.executeRest(startLoginRequest) + + // Verify client switched to non-HPKE and updated EC key only + expect(mockConfig.deviceConfig.transmissionKeyId).toBe(newEcKeyId) + expect(mockConfig.deviceConfig.useHpkeTransmission).toBe(false) + expect(mockConfig.deviceConfig.mlKemPublicKeyId).toBe(defaultMlKemKeyId) // ML-KEM key unchanged + // onDeviceConfig called twice: once to disable HPKE, once to update EC key + expect(onDeviceConfigMock).toHaveBeenCalledTimes(2) + expect(postSpy).toHaveBeenCalledTimes(2) // First HPKE fails, second non-HPKE succeeds + + // Verify first call used HPKE and second used non-HPKE + const firstCallArgs = postSpy.mock.calls[0] + const firstRequest = Authentication.ApiRequest.decode(firstCallArgs[1]) + expect(firstRequest.qrcMessageKey).toBeDefined() + + const secondCallArgs = postSpy.mock.calls[1] + const secondRequest = Authentication.ApiRequest.decode(secondCallArgs[1]) + expect(secondRequest.qrcMessageKey).toBeNull() + expect(secondRequest.publicKeyId).toBe(newEcKeyId) + }) + + it('should switch from HPKE to non-HPKE when returned qrc_ec_key_id is unknown to client', async () => { + const validEcKeyId = 11 + const unknownMlKemKeyId = 999 // ML-KEM key ID not in testServerMlKemKeys + + // Enable HPKE for client + mockConfig.useHpkeForTransmissionKey = true + mockConfig.deviceConfig.useHpkeTransmission = true + endpoint = new KeeperEndpoint(mockConfig) + + // Configure server with unknown ML-KEM key ID but valid EC key ID + mockServerKeys = { + ecKeyId: validEcKeyId, + mlKemKeyId: unknownMlKemKeyId, // Unknown to client - will trigger fallback + } + + // Execute startLogin - client will try HPKE first, get unknown ML-KEM key, fall back to non-HPKE + await endpoint.executeRest(startLoginRequest) + + // Verify client switched to non-HPKE and uses valid EC key + expect(mockConfig.deviceConfig.transmissionKeyId).toBe(validEcKeyId) + expect(mockConfig.deviceConfig.useHpkeTransmission).toBe(false) + // onDeviceConfig called twice: once to disable HPKE, once to update EC key + expect(onDeviceConfigMock).toHaveBeenCalledTimes(2) + expect(postSpy).toHaveBeenCalledTimes(2) + + // Verify first call used HPKE and second used non-HPKE + const firstCallArgs = postSpy.mock.calls[0] + const firstRequest = Authentication.ApiRequest.decode(firstCallArgs[1]) + expect(firstRequest.qrcMessageKey).toBeDefined() + + const secondCallArgs = postSpy.mock.calls[1] + const secondRequest = Authentication.ApiRequest.decode(secondCallArgs[1]) + expect(secondRequest.qrcMessageKey).toBeNull() + expect(secondRequest.publicKeyId).toBe(validEcKeyId) + }) + + it('should give device config precedence over global config for HPKE usage', async () => { + const newEcKeyId = 11 + const newMlKemKeyId = 124 + + // Global config says use HPKE, but device config says don't + // (e.g., server previously told us it doesn't support HPKE, + // or the server told us to use an unknown ML-KEM key ID) + mockConfig.useHpkeForTransmissionKey = true // Client: use/allow HPKE + mockConfig.deviceConfig.useHpkeTransmission = false // Device: don't use HPKE + endpoint = new KeeperEndpoint(mockConfig) + + // Configure mock server for non-HPKE (device config should take precedence) + mockServerKeys = { + ecKeyId: newEcKeyId, + mlKemKeyId: newMlKemKeyId, + } + + // Execute startLogin + await endpoint.executeRest(startLoginRequest) + + // Verify device config took precedence - client used non-HPKE + expect(mockConfig.deviceConfig.transmissionKeyId).toBe(newEcKeyId) + expect(mockConfig.deviceConfig.useHpkeTransmission).toBe(false) + expect(mockConfig.deviceConfig.mlKemPublicKeyId).toBe(defaultMlKemKeyId) // ML-KEM key unchanged + expect(postSpy).toHaveBeenCalledTimes(2) + + // Verify non-HPKE was used (no qrcMessageKey) + const firstCallArgs = postSpy.mock.calls[0] + const firstRequest = Authentication.ApiRequest.decode(firstCallArgs[1]) + expect(firstRequest.qrcMessageKey).toBeNull() + expect(firstRequest.publicKeyId).toBe(defaultEcKeyId) + + const secondCallArgs = postSpy.mock.calls[1] + const secondRequest = Authentication.ApiRequest.decode(secondCallArgs[1]) + expect(secondRequest.qrcMessageKey).toBeNull() + expect(secondRequest.publicKeyId).toBe(newEcKeyId) + }) + + it('should throw an error if server returns unknown EC key ID (EC mode)', async () => { + const unknownEcKeyId = 999 // EC key ID not in testServerKeys + + // Configure mock server to return unknown EC key ID + mockServerKeys.ecKeyId = unknownEcKeyId + mockServerKeys.mlKemKeyId = defaultMlKemKeyId + + // Execute startLogin and expect error + await expect(endpoint.executeRest(startLoginRequest)) + .rejects + .toThrow() + }) + + it('should throw an error if server returns unknown EC key ID (HPKE mode)', async () => { + const unknownEcKeyId = 999 // EC key ID not in testServerKeys + const newMlKemKeyId = 124 + + // Clear device config keys to simulate newly registered device + mockConfig.deviceConfig.transmissionKeyId = undefined + mockConfig.deviceConfig.mlKemPublicKeyId = undefined + mockConfig.useHpkeForTransmissionKey = true + endpoint = new KeeperEndpoint(mockConfig) + + // Configure mock server to expect new key IDs + mockServerKeys.ecKeyId = unknownEcKeyId + mockServerKeys.mlKemKeyId = newMlKemKeyId + + // Execute startLogin and expect error + await expect(endpoint.executeRest(startLoginRequest)) + .rejects + .toThrow() + }) +}) + +async function mockPlatformPost(_, requestBody): Promise { + const apiRequest = Authentication.ApiRequest.decode(requestBody) + if (apiRequest.qrcMessageKey) { + // Client is using HPKE mode + const ecKeyId = apiRequest.qrcMessageKey.ecKeyId! + const mlKemKeyId = apiRequest.publicKeyId! + + // Check if server doesn't support HPKE (no mlKemKeyId configured) + if (mockServerKeys.mlKemKeyId === undefined) { + // Server doesn't support HPKE - return error without qrc_ec_key_id + const errorObj: KeeperError = { + key_id: mockServerKeys.ecKeyId, + // No qrc_ec_key_id or mlKemKeyId - server doesn't support HPKE + location: 'encrypted_rest_filter', + error: 'key', + message: 'key', + } + return { + data: platform.stringToBytes(JSON.stringify(errorObj)), + statusCode: 401, + headers: new Headers(), + } + } + + // Server supports HPKE - check both EC and ML-KEM key IDs + if ( + mlKemKeyId !== mockServerKeys.mlKemKeyId || + ecKeyId !== mockServerKeys.ecKeyId + ) { + const errorObj: KeeperError = { + key_id: mockServerKeys.mlKemKeyId, + qrc_ec_key_id: mockServerKeys.ecKeyId, + location: 'encrypted_rest_filter', + error: 'key', + message: 'key', + } + return { + data: platform.stringToBytes(JSON.stringify(errorObj)), + statusCode: 401, + headers: new Headers(), + } + } + + // Keys match - check if they exist in our test keys + if (!isAllowedEcKeyId(ecKeyId) || !testServerKeys[ecKeyId]) { + throw new Error(`Unknown EC Key ID: ${ecKeyId}`) + } + if (!isAllowedMlKemKeyId(mlKemKeyId) || !testServerMlKemKeys[mlKemKeyId]) { + throw new Error(`Unknown ML-KEM Key ID: ${mlKemKeyId}`) + } + } else { + // Non-HPKE mode: check only EC key ID + const ecKeyId = apiRequest.publicKeyId! + + if (ecKeyId !== mockServerKeys.ecKeyId) { + const errorObj: KeeperError = { + key_id: mockServerKeys.ecKeyId, + location: 'encrypted_rest_filter', + error: 'key', + message: 'key', + } + return { + data: platform.stringToBytes(JSON.stringify(errorObj)), + statusCode: 401, + headers: new Headers(), + } + } + + // Key matches - check if it exists in our test keys + if (!isAllowedEcKeyId(ecKeyId) || !testServerKeys[ecKeyId]) { + throw new Error(`Unknown EC Key ID: ${ecKeyId}`) + } + } + + // Keys match and exist - decrypt the transmission key from the request + const transmissionKey = await decryptApiRequestPayload(apiRequest) + + // Return success response (assume start_login for sake of test) + const loginResponse = Authentication.LoginResponse.encode({ + loginState: Authentication.LoginState.LOGGED_IN, + encryptedSessionToken: platform.getRandomBytes(64), + encryptedDataKey: platform.getRandomBytes(64), + }).finish() + const encryptedResponse = await platform.aesGcmEncrypt(loginResponse, transmissionKey) + return { + data: encryptedResponse, + statusCode: 200, + headers: new Headers(), + } +} + +// Helper function to decrypt API request payload +async function decryptApiRequestPayload(request: Authentication.ApiRequest) { + if (request.qrcMessageKey) { + const qrcMessageKey = request.qrcMessageKey + const ecKeyId = qrcMessageKey.ecKeyId! + const mlKemKeyId = request.publicKeyId! + + // Get the correct variant for this ML-KEM key ID + const variant = getKeeperMlKemKeyVariant(mlKemKeyId as AllowedMlKemKeyIds) + const ciphersuite = variant === MlKemVariant.ML_KEM_768 + ? Ciphersuite.HPKE_MLKEM768_ECDHP256_HKDFSHA256_AESGCM256 + : Ciphersuite.HPKE_MLKEM1024_ECDHP256_HKDFSHA256_AESGCM256 + const hpke = new HPKE_ECDH_KYBER(ciphersuite) + + // Ensure all buffers are proper Uint8Arrays (protobuf may return Buffer objects) + const clientEcPublicKey = new Uint8Array(qrcMessageKey.clientEcPublicKey!) + const mlKemEncapsulatedKey = new Uint8Array(qrcMessageKey.mlKemEncapsulatedKey!) + const data = new Uint8Array(qrcMessageKey.data!) + const optionalData = request.encryptedTransmissionKey ? new Uint8Array(request.encryptedTransmissionKey) : undefined + + // Decrypt the transmission key using HPKE + const transmissionKey = await hpke.decrypt( + clientEcPublicKey, + mlKemEncapsulatedKey, + data, + qrcMessageKey.msgVersion as number, + testServerKeys[ecKeyId].privateKey, // EC private key (raw bytes) + testServerKeys[ecKeyId].publicKey, // EC public key + testServerMlKemKeys[mlKemKeyId].privateKey, // ML-KEM private key + optionalData // optionalData + ) + + return transmissionKey + } else { + // Non-HPKE mode: decrypt using EC key + const ecKeyId = request.publicKeyId! + const transmissionKey = await platform.privateDecryptEC( + request.encryptedTransmissionKey!, + testServerKeys[ecKeyId].privateKey, + testServerKeys[ecKeyId].publicKey // Public key is required for EC decryption + ) + + return transmissionKey + } +} + diff --git a/keeperapi/src/__tests__/getOnsitePublicKey.test.ts b/keeperapi/src/__tests__/getOnsitePublicKey.test.ts index 9dbe9d6..0d188c0 100644 --- a/keeperapi/src/__tests__/getOnsitePublicKey.test.ts +++ b/keeperapi/src/__tests__/getOnsitePublicKey.test.ts @@ -10,13 +10,6 @@ import { nodePlatform } from '../node/platform' import { connectPlatform } from '../platform' // import NodeRSA from 'node-rsa'; -Object.defineProperty(global.self, 'crypto', { - value: { - subtle: crypto.webcrypto.subtle, - getRandomValues: (array: any) => crypto.randomBytes(array.length) - } -}) - describe('getOnsitePublicKey', () => { let endpoint = new KeeperEndpoint({ diff --git a/keeperapi/src/__tests__/qrc.test.ts b/keeperapi/src/__tests__/qrc.test.ts index 1c96ba8..1579a41 100644 --- a/keeperapi/src/__tests__/qrc.test.ts +++ b/keeperapi/src/__tests__/qrc.test.ts @@ -5,7 +5,6 @@ // @ts-ignore import crypto from 'crypto'; import { browserPlatform } from '../browser/platform'; -import { TextEncoder, TextDecoder } from 'util'; import { connectPlatform, platform } from '../platform'; import { HPKE_ECDH_KYBER, @@ -16,25 +15,11 @@ import { mlKemEncapsulate, mlKemDecapsulate, ML_KEM_768_CIPHERTEXT_LENGTH, + encodeMlKemPublicKeyToPem, } from '../qrc'; import { EC_PUBLIC_KEY_LENGTH, EC_SHARED_SECRET_LENGTH, ML_KEM_1024_CIPHERTEXT_LENGTH } from '../qrc/constants'; import { getKeeperMlKemKeys, getKeeperMlKemKeyVariant, isAllowedMlKemKeyId } from '../transmissionKeys'; -Object.assign(global, { TextDecoder, TextEncoder }); - -// Set up crypto for both global.self (browser APIs) and globalThis (noble libraries) -const cryptoObj = { - subtle: crypto.webcrypto.subtle, - getRandomValues: (array: Uint8Array) => { - const randomData = crypto.randomBytes(array.length); - array.set(randomData); - return array; - }, -}; - -Object.defineProperty(global.self, 'crypto', { value: cryptoObj }); -Object.defineProperty(globalThis, 'crypto', { value: cryptoObj }); - describe('ML-KEM Operations', () => { describe('mlKemEncapsulate and mlKemDecapsulate', () => { it('encapsulates and decapsulates with ML-KEM-768', () => { @@ -70,69 +55,6 @@ describe('ML-KEM Operations', () => { }); describe('PEM-encoded ML-KEM keys', () => { - // ML-KEM OIDs from NIST - // ML-KEM-768: 2.16.840.1.101.3.4.4.2 -> 06 0B 60 86 48 01 65 03 04 04 02 - // ML-KEM-1024: 2.16.840.1.101.3.4.4.3 -> 06 0B 60 86 48 01 65 03 04 04 03 - const ML_KEM_768_OID = new Uint8Array([0x06, 0x0B, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x04, 0x02]); - const ML_KEM_1024_OID = new Uint8Array([0x06, 0x0B, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x04, 0x03]); - - /** - * Encodes a length in DER format - */ - function encodeLength(length: number): Uint8Array { - if (length < 128) { - return new Uint8Array([length]); - } else if (length < 256) { - return new Uint8Array([0x81, length]); - } else if (length < 65536) { - return new Uint8Array([0x82, (length >> 8) & 0xFF, length & 0xFF]); - } else { - throw new Error('Length too large for DER encoding'); - } - } - - /** - * Wraps data in a DER SEQUENCE - */ - function derSequence(...items: Uint8Array[]): Uint8Array { - const content = concatUint8Arrays(...items); - const lengthBytes = encodeLength(content.length); - return concatUint8Arrays(new Uint8Array([0x30]), lengthBytes, content); - } - - /** - * Wraps data in a DER BIT STRING (with 0 unused bits) - */ - function derBitString(data: Uint8Array): Uint8Array { - const lengthBytes = encodeLength(data.length + 1); // +1 for unused bits byte - return concatUint8Arrays(new Uint8Array([0x03]), lengthBytes, new Uint8Array([0x00]), data); - } - - /** - * Encodes a raw ML-KEM public key to PEM format (SubjectPublicKeyInfo) - */ - function encodeMlKemPublicKeyToPem(rawPublicKey: Uint8Array, variant: MlKemVariant): Uint8Array { - const oid = variant === MlKemVariant.ML_KEM_768 ? ML_KEM_768_OID : ML_KEM_1024_OID; - - // AlgorithmIdentifier: SEQUENCE { OID } - const algorithmIdentifier = derSequence(oid); - - // SubjectPublicKeyInfo: SEQUENCE { AlgorithmIdentifier, BIT STRING } - const spki = derSequence(algorithmIdentifier, derBitString(rawPublicKey)); - - // Encode to base64 and wrap in PEM - const base64 = platform.bytesToBase64(spki); - - // Format with line breaks every 64 characters - const lines: string[] = []; - for (let i = 0; i < base64.length; i += 64) { - lines.push(base64.slice(i, i + 64)); - } - - const pemString = `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`; - return new TextEncoder().encode(pemString); - } - beforeAll(() => { connectPlatform(browserPlatform); }); diff --git a/keeperapi/src/configuration.ts b/keeperapi/src/configuration.ts index 7ee24a0..bc99ed5 100644 --- a/keeperapi/src/configuration.ts +++ b/keeperapi/src/configuration.ts @@ -51,6 +51,14 @@ export interface DeviceConfig { publicKey?: Uint8Array transmissionKeyId?: number mlKemPublicKeyId?: number + /** + * Overrides `ClientConfiguration.useHpkeForTransmissionKey` if set. + * Relevant for GovCloud, which at time of writing has no HPKE support. + * It's also possible that a region (again, most likely GovCloud) + * may tell the client NOT to use HPKE any more even after it once did, + * so this caches that state. + */ + useHpkeTransmission?: boolean } export interface SessionStorage { diff --git a/keeperapi/src/endpoint.ts b/keeperapi/src/endpoint.ts index a4970c5..95b58ef 100644 --- a/keeperapi/src/endpoint.ts +++ b/keeperapi/src/endpoint.ts @@ -47,7 +47,7 @@ export class KeeperEndpoint { if (options.locale) { this.locale = options.locale } - if (options.useHpkeForTransmissionKey) { + if (options.useHpkeForTransmissionKey && options.deviceConfig.useHpkeTransmission !== false) { this.useHpkeForTransmissionKey = true } } @@ -200,20 +200,37 @@ export class KeeperEndpoint { const errorObj: KeeperError = JSON.parse(errorMessage) switch (errorObj.error) { case 'key': - if (errorObj.qrc_ec_key_id && errorObj.key_id){ - if (isAllowedEcKeyId(errorObj.qrc_ec_key_id) && isAllowedMlKemKeyId(errorObj.key_id)) { - // Rotate EC key and ML-KEM key - await this.updateTransmissionKey(errorObj.qrc_ec_key_id, errorObj.key_id) - } else { - throw new Error('Incorrect Transmission Key IDs being used.') - } + let newEcKeyId: AllowedEcKeyIds + let newMlKemKeyId: AllowedMlKemKeyIds + let disableHpke = false + + if (errorObj.qrc_ec_key_id && errorObj.key_id && isAllowedEcKeyId(errorObj.qrc_ec_key_id)) { + // Server provided both EC and ML-KEM key IDs (HPKE mode) + newEcKeyId = errorObj.qrc_ec_key_id + newMlKemKeyId = errorObj.key_id as AllowedMlKemKeyIds + disableHpke = !isAllowedMlKemKeyId(errorObj.key_id) // disable if unknown ML-KEM key } else if (errorObj.key_id && isAllowedEcKeyId(errorObj.key_id) && this._transmissionKey) { - // Rotate EC key - await this.updateTransmissionKey(errorObj.key_id, this._transmissionKey.mlKemKeyId as AllowedMlKemKeyIds) + // Server provided only EC key ID (non-HPKE mode or fallback) + newEcKeyId = errorObj.key_id + newMlKemKeyId = this._transmissionKey.mlKemKeyId as AllowedMlKemKeyIds // keep current ML-KEM key id + if (this.useHpkeForTransmissionKey) { + // If we tried HPKE but server only provided EC key, disable HPKE + disableHpke = true + } + } else { + throw new Error(`Invalid key rotation request: ${errorMessage}`) } - else { - throw new Error('Incorrect Transmission Key ID being used.') + + // Disable HPKE if server doesn't support it or provided unknown ML-KEM key + if (disableHpke) { + this.useHpkeForTransmissionKey = false + this.options.deviceConfig.useHpkeTransmission = false + if (this.options.onDeviceConfig) { + await this.options.onDeviceConfig(this.options.deviceConfig, this.options.host) + } } + + await this.updateTransmissionKey(newEcKeyId, newMlKemKeyId) continue case 'region_redirect': this.options.host = errorObj.region_host! diff --git a/keeperapi/src/qrc/index.ts b/keeperapi/src/qrc/index.ts index cf3728c..3d95165 100644 --- a/keeperapi/src/qrc/index.ts +++ b/keeperapi/src/qrc/index.ts @@ -48,3 +48,8 @@ export { buildContextInfo, validateContextInfoParams } from './context'; + +// PEM encoding +export { + encodeMlKemPublicKeyToPem +} from './pem'; diff --git a/keeperapi/src/qrc/pem.ts b/keeperapi/src/qrc/pem.ts new file mode 100644 index 0000000..79ab24c --- /dev/null +++ b/keeperapi/src/qrc/pem.ts @@ -0,0 +1,68 @@ +/** + * PEM encoding utilities for ML-KEM keys + */ + +import { platform } from '../platform'; +import { concatUint8Arrays } from './utils'; +import { MlKemVariant } from './constants'; + +// ML-KEM OIDs from NIST +const ML_KEM_768_OID = new Uint8Array([0x06, 0x0B, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x04, 0x02]); +const ML_KEM_1024_OID = new Uint8Array([0x06, 0x0B, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x04, 0x03]); + +/** + * Encodes a length in DER format + */ +function encodeLength(length: number): Uint8Array { + if (length < 128) { + return new Uint8Array([length]); + } else if (length < 256) { + return new Uint8Array([0x81, length]); + } else if (length < 65536) { + return new Uint8Array([0x82, (length >> 8) & 0xFF, length & 0xFF]); + } else { + throw new Error('Length too large for DER encoding'); + } +} + +/** + * Wraps data in a DER SEQUENCE + */ +function derSequence(...items: Uint8Array[]): Uint8Array { + const content = concatUint8Arrays(...items); + const lengthBytes = encodeLength(content.length); + return concatUint8Arrays(new Uint8Array([0x30]), lengthBytes, content); +} + +/** + * Wraps data in a DER BIT STRING (with 0 unused bits) + */ +function derBitString(data: Uint8Array): Uint8Array { + const lengthBytes = encodeLength(data.length + 1); // +1 for unused bits byte + return concatUint8Arrays(new Uint8Array([0x03]), lengthBytes, new Uint8Array([0x00]), data); +} + +/** + * Encodes a raw ML-KEM public key to PEM format (SubjectPublicKeyInfo) + */ +export function encodeMlKemPublicKeyToPem(rawPublicKey: Uint8Array, variant: MlKemVariant): Uint8Array { + const oid = variant === MlKemVariant.ML_KEM_768 ? ML_KEM_768_OID : ML_KEM_1024_OID; + + // AlgorithmIdentifier: SEQUENCE { OID } + const algorithmIdentifier = derSequence(oid); + + // SubjectPublicKeyInfo: SEQUENCE { AlgorithmIdentifier, BIT STRING } + const spki = derSequence(algorithmIdentifier, derBitString(rawPublicKey)); + + // Encode to base64 and wrap in PEM + const base64 = platform.bytesToBase64(spki); + + // Format with line breaks every 64 characters + const lines: string[] = []; + for (let i = 0; i < base64.length; i += 64) { + lines.push(base64.slice(i, i + 64)); + } + + const pemString = `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`; + return new TextEncoder().encode(pemString); +} diff --git a/keeperapi/src/transmissionKeys.ts b/keeperapi/src/transmissionKeys.ts index 86fa37e..817e7d9 100644 --- a/keeperapi/src/transmissionKeys.ts +++ b/keeperapi/src/transmissionKeys.ts @@ -12,9 +12,10 @@ export type AllowedEcKeyIds = | 15 | 16 | 17 +| 18 export function isAllowedEcKeyId(num:number):num is AllowedEcKeyIds { - return num >= 7 && num <= 17 + return num >= 7 && num <= 18 } export function getKeeperKeys(fct:(source: string) => Uint8Array){ @@ -30,7 +31,8 @@ export function getKeeperKeys(fct:(source: string) => Uint8Array){ 'BJFF8j-dH7pDEw_U347w2CBM6xYM8Dk5fPPAktjib-opOqzvvbsER-WDHM4ONCSBf9O_obAHzCyygxmtpktDuiE', 'BDKyWBvLbyZ-jMueORl3JwJnnEpCiZdN7yUvT0vOyjwpPBCDf6zfL4RWzvSkhAAFnwOni_1tQSl8dfXHbXqXsQ8', 'BDXyZZnrl0tc2jdC5I61JjwkjK2kr7uet9tZjt8StTiJTAQQmnVOYBgbtP08PWDbecxnHghx3kJ8QXq1XE68y8c', - 'BFX68cb97m9_sweGdOVavFM3j5ot6gveg6xT4BtGahfGhKib-zdZyO9pwvv1cBda9ahkSzo1BQ4NVXp9qRyqVGU' + 'BFX68cb97m9_sweGdOVavFM3j5ot6gveg6xT4BtGahfGhKib-zdZyO9pwvv1cBda9ahkSzo1BQ4NVXp9qRyqVGU', + 'BNhngQqTT1bPKxGuB6FhbPTAeNVFl8PKGGSGo5W06xWIReutm6ix6JPivqnbvkydY-1uDQTr-5e6t70G01Bb5JA' ].reduce((keys:Uint8Array[], key) => { keys[keyNumber++] = fct(key) return keys From b20fca8deb125cb25ad113e3d45aa35ee448d11b Mon Sep 17 00:00:00 2001 From: Tyler Carson Date: Thu, 15 Jan 2026 14:08:17 -0800 Subject: [PATCH 2/2] add assertions that prove default key used when no storage values --- keeperapi/src/__tests__/endpoint.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/keeperapi/src/__tests__/endpoint.test.ts b/keeperapi/src/__tests__/endpoint.test.ts index 5c60331..71d5c0f 100644 --- a/keeperapi/src/__tests__/endpoint.test.ts +++ b/keeperapi/src/__tests__/endpoint.test.ts @@ -215,6 +215,16 @@ describe('KeeperEndpoint - Transmission Key ID Rotation', () => { expect(mockConfig.deviceConfig.mlKemPublicKeyId).toBe(defaultMlKemKeyId) expect(onDeviceConfigMock).toHaveBeenCalledTimes(1) expect(postSpy).toHaveBeenCalledTimes(2) // First fails, second succeeds + + // Check that the first call used the default EC key, and second used the new EC key + const firstCallArgs = postSpy.mock.calls[0] + const firstRequest = Authentication.ApiRequest.decode(firstCallArgs[1]) + expect(firstRequest.qrcMessageKey).toBeNull() + expect(firstRequest.publicKeyId).toBe(defaultEcKeyId) + const secondCallArgs = postSpy.mock.calls[1] + const secondRequest = Authentication.ApiRequest.decode(secondCallArgs[1]) + expect(secondRequest.qrcMessageKey).toBeNull() + expect(secondRequest.publicKeyId).toBe(newEcKeyId) }) it('should use default keys when device config has no keys set (HPKE)', async () => { @@ -239,6 +249,18 @@ describe('KeeperEndpoint - Transmission Key ID Rotation', () => { expect(mockConfig.deviceConfig.mlKemPublicKeyId).toBe(newMlKemKeyId) expect(onDeviceConfigMock).toHaveBeenCalledTimes(1) expect(postSpy).toHaveBeenCalledTimes(2) // First fails, second succeeds + + // Check that the first call used the default keys, and second used the new keys + const firstCallArgs = postSpy.mock.calls[0] + const firstRequest = Authentication.ApiRequest.decode(firstCallArgs[1]) + expect(firstRequest.qrcMessageKey).toBeDefined() + expect(firstRequest.qrcMessageKey!.ecKeyId).toBe(defaultEcKeyId) + expect(firstRequest.publicKeyId).toBe(defaultMlKemKeyId) + const secondCallArgs = postSpy.mock.calls[1] + const secondRequest = Authentication.ApiRequest.decode(secondCallArgs[1]) + expect(secondRequest.qrcMessageKey).toBeDefined() + expect(secondRequest.qrcMessageKey!.ecKeyId).toBe(newEcKeyId) + expect(secondRequest.publicKeyId).toBe(newMlKemKeyId) }) it("should switch from HPKE to non-HPKE when server doesn't support HPKE", async () => {