diff --git a/.changeset/legal-pandas-yell.md b/.changeset/legal-pandas-yell.md new file mode 100644 index 0000000000..f524ece664 --- /dev/null +++ b/.changeset/legal-pandas-yell.md @@ -0,0 +1,5 @@ +--- +'@sap-cloud-sdk/connectivity': minor +--- + +[New Functionality] Support certificates in JKS format for `ClientCertificateAuthentication`. diff --git a/packages/connectivity/package.json b/packages/connectivity/package.json index 3bfd765a0f..b7230737a7 100644 --- a/packages/connectivity/package.json +++ b/packages/connectivity/package.json @@ -46,6 +46,7 @@ "@sap/xssec": "^4.12.1", "async-retry": "^1.3.3", "axios": "^1.13.2", + "jks-js": "^1.1.4", "jsonwebtoken": "^9.0.3" }, "devDependencies": { diff --git a/packages/connectivity/src/http-agent/http-agent.spec.ts b/packages/connectivity/src/http-agent/http-agent.spec.ts index fb3c08b1e1..07e1458175 100644 --- a/packages/connectivity/src/http-agent/http-agent.spec.ts +++ b/packages/connectivity/src/http-agent/http-agent.spec.ts @@ -1,6 +1,12 @@ import { X509Certificate } from 'node:crypto'; import mock from 'mock-fs'; import { createLogger } from '@sap-cloud-sdk/util'; + +// Mock jks-js module +jest.mock('jks-js', () => ({ + toPem: jest.fn() +})); +import * as jks from 'jks-js'; import { registerDestinationCache } from '../scp-cf/destination/register-destination-cache'; import { certAsString } from '../../../../test-resources/test/test-util/test-certificate'; import { getAgentConfig } from './http-agent'; @@ -172,7 +178,17 @@ describe('createAgent', () => { ).toMatchObject(expectedOptions); }); - it('throws an error if the format is not supported', async () => { + it('does not throw an error for supported JKS format', async () => { + const mockPemKeystore = { + alias1: { + cert: '-----BEGIN CERTIFICATE-----\nMII...\n-----END CERTIFICATE-----', + key: '-----BEGIN PRIVATE KEY-----\nMII...\n-----END PRIVATE KEY-----' + } + }; + (jks.toPem as jest.MockedFunction).mockReturnValue( + mockPemKeystore + ); + const destination: HttpDestination = { url: 'https://destination.example.com', authentication: 'ClientCertificateAuthentication', @@ -187,8 +203,34 @@ describe('createAgent', () => { ] }; + const expectedOptions = { + rejectUnauthorized: true, + cert: Buffer.from(mockPemKeystore['alias1'].cert, 'utf8'), + key: Buffer.from(mockPemKeystore['alias1'].key, 'utf8') + }; + + expect( + (await getAgentConfig(destination))['httpsAgent']['options'] + ).toMatchObject(expectedOptions); + }); + + it('throws an error if the format is not supported', async () => { + const destination: HttpDestination = { + url: 'https://destination.example.com', + authentication: 'ClientCertificateAuthentication', + keyStoreName: 'cert.unknown', + keyStorePassword: 'password', + certificates: [ + { + name: 'cert.unknown', + content: 'base64string', + type: 'CERTIFICATE' + } + ] + }; + expect(async () => getAgentConfig(destination)).rejects.toThrow( - "The format of the provided certificate 'cert.jks' is not supported. Supported formats are: p12, pfx, pem. You can convert Java Keystores (.jks, .keystore) into PKCS#12 keystores using the JVM's keytool CLI: keytool -importkeystore -srckeystore your-keystore.jks -destkeystore your-keystore.p12 -deststoretype pkcs12" + "The format of the provided certificate 'cert.unknown' is not supported. Supported formats are: p12, pfx, pem, jks, keystore." ); }); diff --git a/packages/connectivity/src/http-agent/http-agent.ts b/packages/connectivity/src/http-agent/http-agent.ts index 7e2a7e93c6..5588637a13 100644 --- a/packages/connectivity/src/http-agent/http-agent.ts +++ b/packages/connectivity/src/http-agent/http-agent.ts @@ -1,6 +1,7 @@ import { readFile } from 'fs/promises'; import http from 'http'; import https from 'https'; +import * as jks from 'jks-js'; import { createLogger, last } from '@sap-cloud-sdk/util'; /* Careful the proxy imports cause circular dependencies if imported from scp directly */ // eslint-disable-next-line import/no-internal-modules @@ -132,6 +133,37 @@ function getKeyStoreOptions(destination: Destination): const certBuffer = Buffer.from(certificate.content, 'base64'); + if ( + getFormat(certificate) === 'jks' || + getFormat(certificate) === 'keystore' + ) { + const pemKeystore = jks.toPem( + certBuffer, + destination.keyStorePassword || '' + ); + const aliases = Object.keys(pemKeystore); + if (aliases.length === 0) { + throw Error('No entries found in JKS keystore'); + } + const alias = aliases[0]; + + if (aliases.length > 1) { + logger.debug( + `JKS keystore contains ${aliases.length} aliases. ` + + 'Using the first one. ' + + 'If this is not the correct certificate, please use a JKS file with only one entry.' + ); + } + + const entry = pemKeystore[alias]; + if (!entry.cert || !entry.key) { + throw Error('Invalid JKS entry: missing cert or key'); + } + return { + cert: Buffer.from(entry.cert, 'utf8'), + key: Buffer.from(entry.key, 'utf8') + }; + } // if the format is pem, the key and certificate needs to be passed separately // it could be required to separate the string into two parts, but this seems to work as well if (getFormat(certificate) === 'pem') { @@ -207,7 +239,7 @@ function mtlsIsEnabled(destination: Destination) { /* The node client supports only these store formats https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions. */ -const supportedCertificateFormats = ['p12', 'pfx', 'pem']; +const supportedCertificateFormats = ['p12', 'pfx', 'pem', 'jks', 'keystore']; function isSupportedFormat(format: string | undefined): boolean { return !!format && supportedCertificateFormats.includes(format); @@ -235,15 +267,7 @@ function validateFormat(certificate: DestinationCertificate) { const format = getFormat(certificate); if (!isSupportedFormat(format)) { throw Error( - `The format of the provided certificate '${ - certificate.name - }' is not supported. Supported formats are: ${supportedCertificateFormats.join( - ', ' - )}. ${ - format && ['jks', 'keystore'].includes(format) - ? "You can convert Java Keystores (.jks, .keystore) into PKCS#12 keystores using the JVM's keytool CLI: keytool -importkeystore -srckeystore your-keystore.jks -destkeystore your-keystore.p12 -deststoretype pkcs12" - : '' - }` + `The format of the provided certificate '${certificate.name}' is not supported. Supported formats are: ${supportedCertificateFormats.join(', ')}.` ); } } diff --git a/packages/util/src/error-with-cause.ts b/packages/util/src/error-with-cause.ts index 62d90d68e7..7c5e13c959 100644 --- a/packages/util/src/error-with-cause.ts +++ b/packages/util/src/error-with-cause.ts @@ -38,7 +38,11 @@ export class ErrorWithCause extends Error { let response = ''; if (cause.response?.data) { try { - response = `${unixEOL}${JSON.stringify(cause.response?.data, null, 2)}`; + response = `${unixEOL}${JSON.stringify( + cause.response?.data, + null, + 2 + )}`; } catch (error) { logger.warn(`Failed to stringify response data: ${error.message}`); response = `${unixEOL}${cause.response?.data}`;