Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/legal-pandas-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sap-cloud-sdk/connectivity': minor
---

[New Functionality] Support certificates in JKS format for `ClientCertificateAuthentication`.
1 change: 1 addition & 0 deletions packages/connectivity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
46 changes: 44 additions & 2 deletions packages/connectivity/src/http-agent/http-agent.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<typeof jks.toPem>).mockReturnValue(
mockPemKeystore
);

const destination: HttpDestination = {
url: 'https://destination.example.com',
authentication: 'ClientCertificateAuthentication',
Expand All @@ -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."
);
});

Expand Down
44 changes: 34 additions & 10 deletions packages/connectivity/src/http-agent/http-agent.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(', ')}.`
);
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/util/src/error-with-cause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
Loading