Skip to content

Commit c279766

Browse files
committed
Add NexoSecurityManager
1 parent 3ce8601 commit c279766

File tree

2 files changed

+235
-54
lines changed

2 files changed

+235
-54
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
* ######
3+
* ######
4+
* ############ ####( ###### #####. ###### ############ ############
5+
* ############# #####( ###### #####. ###### ############# #############
6+
* ###### #####( ###### #####. ###### ##### ###### ##### ######
7+
* ###### ###### #####( ###### #####. ###### ##### ##### ##### ######
8+
* ###### ###### #####( ###### #####. ###### ##### ##### ######
9+
* ############# ############# ############# ############# ##### ######
10+
* ############ ############ ############# ############ ##### ######
11+
* ######
12+
* #############
13+
* ############
14+
* Adyen NodeJS API Library
15+
* Copyright (c) 2025 Adyen B.V.
16+
* This file is open source and available under the MIT license.
17+
* See the LICENSE file for more info.
18+
*/
19+
20+
import NexoSecurityManager from "../../security/nexoSecurityManager";
21+
import { EncryptionCredentialDetails } from "../../security/encryptionCredentialDetails";
22+
import { MessageHeader, SaleToPOISecuredMessage, MessageCategoryType, MessageClassType, MessageType } from "../../typings/cloudDevice/models";
23+
import InvalidSecurityKeyException from "../../security/exception/invalidSecurityKeyException";
24+
import NexoCryptoException from "../../services/exception/nexoCryptoException";
25+
26+
describe("NexoSecurityManager", (): void => {
27+
const credentials: EncryptionCredentialDetails = {
28+
KeyIdentifier: "SpecTest",
29+
KeyVersion: 1,
30+
Passphrase: "spec-test-passphrase",
31+
AdyenCryptoVersion: 1,
32+
};
33+
34+
const messageHeader: MessageHeader = {
35+
MessageCategory: MessageCategoryType.Payment,
36+
MessageClass: MessageClassType.Service,
37+
MessageType: MessageType.Request,
38+
POIID: "P400Plus-123456789",
39+
ProtocolVersion: "3.0",
40+
SaleID: "001",
41+
ServiceID: "001",
42+
};
43+
44+
const saleToPoiMessageJson = JSON.stringify({
45+
PaymentRequest: {
46+
SaleData: {
47+
SaleTransactionID: {
48+
TransactionID: "001",
49+
TimeStamp: "2025-01-01T00:00:00.000Z",
50+
},
51+
},
52+
},
53+
});
54+
55+
it("should encrypt and decrypt a message successfully", (): void => {
56+
// Encrypt
57+
const securedMessage: SaleToPOISecuredMessage = NexoSecurityManager.encrypt(
58+
messageHeader,
59+
saleToPoiMessageJson,
60+
credentials,
61+
);
62+
63+
expect(securedMessage).toBeDefined();
64+
expect(securedMessage.MessageHeader).toEqual(messageHeader);
65+
expect(securedMessage.NexoBlob).toBeDefined();
66+
expect(typeof securedMessage.NexoBlob).toBe("string");
67+
expect(securedMessage.SecurityTrailer).toBeDefined();
68+
expect(securedMessage.SecurityTrailer.KeyIdentifier).toBe(credentials.KeyIdentifier);
69+
expect(securedMessage.SecurityTrailer.KeyVersion).toBe(credentials.KeyVersion);
70+
expect(securedMessage.SecurityTrailer.AdyenCryptoVersion).toBe(credentials.AdyenCryptoVersion);
71+
expect(typeof securedMessage.SecurityTrailer.Hmac).toBe("string");
72+
expect(typeof securedMessage.SecurityTrailer.Nonce).toBe("string");
73+
74+
// Decrypt
75+
const decryptedJson = NexoSecurityManager.decrypt(securedMessage, credentials);
76+
77+
expect(decryptedJson).toBe(saleToPoiMessageJson);
78+
});
79+
80+
it("should throw InvalidSecurityKeyException on decrypt with invalid credentials", (): void => {
81+
const invalidCredentials: any[] = [
82+
{},
83+
{ ...credentials, Passphrase: "" },
84+
{ ...credentials, KeyIdentifier: "" },
85+
{ ...credentials, KeyVersion: NaN },
86+
{ ...credentials, AdyenCryptoVersion: NaN },
87+
null,
88+
undefined,
89+
];
90+
91+
const securedMessage: SaleToPOISecuredMessage = NexoSecurityManager.encrypt(
92+
messageHeader,
93+
saleToPoiMessageJson,
94+
credentials,
95+
);
96+
97+
invalidCredentials.forEach((cred): void => {
98+
expect((): string => NexoSecurityManager.decrypt(securedMessage, cred)).toThrow(
99+
new InvalidSecurityKeyException("Invalid Encryption Credentials")
100+
);
101+
});
102+
103+
104+
});
105+
106+
it("should throw NexoCryptoException on decrypt with wrong HMAC", (): void => {
107+
const securedMessage: SaleToPOISecuredMessage = NexoSecurityManager.encrypt(
108+
messageHeader,
109+
saleToPoiMessageJson,
110+
credentials,
111+
);
112+
113+
// Tamper with the HMAC
114+
const tamperedMessage = {
115+
...securedMessage,
116+
SecurityTrailer: {
117+
...securedMessage.SecurityTrailer,
118+
Hmac: "dGFtcGVyZWRIbWFj", // "tamperedHmac" in base64
119+
},
120+
};
121+
122+
expect((): string => NexoSecurityManager.decrypt(tamperedMessage, credentials))
123+
.toThrow(NexoCryptoException);
124+
});
125+
126+
it("should throw NexoCryptoException on decrypt with wrong passphrase", (): void => {
127+
const securedMessage: SaleToPOISecuredMessage = NexoSecurityManager.encrypt(
128+
messageHeader,
129+
saleToPoiMessageJson,
130+
credentials,
131+
);
132+
133+
const wrongCredentials = {
134+
...credentials,
135+
Passphrase: "wrong-passphrase",
136+
};
137+
138+
expect((): string => NexoSecurityManager.decrypt(securedMessage, wrongCredentials))
139+
.toThrow(NexoCryptoException);
140+
141+
});
142+
});

src/security/nexoSecurityManager.ts

Lines changed: 93 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
NexoDerivedKey,
2525
SaleToPOISecuredMessage,
2626
SecurityTrailer,
27-
} from "../typings/terminal/models";
27+
} from "../typings/cloudDevice/models";
2828
import { EncryptionCredentialDetails } from "./encryptionCredentialDetails";
2929
import InvalidSecurityKeyException from "./exception/invalidSecurityKeyException";
3030
import NexoDerivedKeyGenerator from "./nexoDerivedKeyGenerator";
@@ -45,43 +45,54 @@ enum Modes {
4545
* - Constructs and validates {@link SecurityTrailer}
4646
*/
4747
class NexoSecurityManager {
48+
4849
/**
4950
* Encrypts a SaleToPOI message.
5051
*
5152
* @param messageHeader - The Nexo message header
5253
* @param saleToPoiMessageJson - The message body in JSON string format
5354
* @param credentials - The encryption credentials
54-
* @returns A secured SaleToPOI message with encrypted payload and security trailer
55+
* @returns A instance of SaleToPOISecuredMessage with MessageHeader, NexoBlob (the encrypted payload) and SecurityTrailer
5556
*/
5657
public static encrypt(
5758
messageHeader: MessageHeader,
5859
saleToPoiMessageJson: string,
5960
credentials: EncryptionCredentialDetails,
6061
): SaleToPOISecuredMessage {
61-
const derivedKey: NexoDerivedKey = NexoDerivedKeyGenerator.deriveKeyMaterial(credentials.Passphrase);
62-
const saleToPoiMessageByteArray = Buffer.from(saleToPoiMessageJson, "utf-8");
63-
const ivNonce = NexoSecurityManager.generateRandomIvNonce();
64-
const encryptedSaleToPoiMessage = NexoSecurityManager.crypt(
65-
saleToPoiMessageByteArray,
66-
derivedKey,
67-
ivNonce,
68-
Modes.ENCRYPT,
69-
);
70-
const encryptedSaleToPoiMessageHmac = NexoSecurityManager.hmac(saleToPoiMessageByteArray, derivedKey);
71-
72-
const securityTrailer: SecurityTrailer = {
73-
AdyenCryptoVersion: credentials.AdyenCryptoVersion,
74-
Hmac: encryptedSaleToPoiMessageHmac.toString("base64"),
75-
KeyIdentifier: credentials.KeyIdentifier,
76-
KeyVersion: credentials.KeyVersion,
77-
Nonce: ivNonce.toString("base64"),
78-
};
79-
80-
return {
81-
MessageHeader: messageHeader,
82-
NexoBlob: encryptedSaleToPoiMessage.toString("base64"),
83-
SecurityTrailer: securityTrailer,
84-
};
62+
63+
try {
64+
65+
const derivedKey: NexoDerivedKey = NexoDerivedKeyGenerator.deriveKeyMaterial(credentials.Passphrase);
66+
const saleToPoiMessageByteArray = Buffer.from(saleToPoiMessageJson, "utf-8");
67+
const ivNonce = NexoSecurityManager.generateRandomIvNonce();
68+
const encryptedSaleToPoiMessage = NexoSecurityManager.crypt(
69+
saleToPoiMessageByteArray,
70+
derivedKey,
71+
ivNonce,
72+
Modes.ENCRYPT,
73+
);
74+
const encryptedSaleToPoiMessageHmac = NexoSecurityManager.hmac(saleToPoiMessageByteArray, derivedKey);
75+
76+
const securityTrailer: SecurityTrailer = {
77+
AdyenCryptoVersion: credentials.AdyenCryptoVersion,
78+
Hmac: encryptedSaleToPoiMessageHmac.toString("base64"),
79+
KeyIdentifier: credentials.KeyIdentifier,
80+
KeyVersion: credentials.KeyVersion,
81+
Nonce: ivNonce.toString("base64"),
82+
};
83+
84+
return {
85+
MessageHeader: messageHeader,
86+
NexoBlob: encryptedSaleToPoiMessage.toString("base64"),
87+
SecurityTrailer: securityTrailer,
88+
};
89+
90+
} catch (err: any) {
91+
// an error has occurred
92+
console.error(err);
93+
throw new NexoCryptoException(err?.message || "Unknown error during encryption");
94+
}
95+
8596
}
8697

8798
/**
@@ -90,29 +101,40 @@ class NexoSecurityManager {
90101
* @param saleToPoiSecureMessage - The secured message to decrypt
91102
* @param credentials - The encryption credentials
92103
* @throws {InvalidSecurityKeyException} If the credentials are invalid
93-
* @throws {NexoCryptoException} If HMAC validation fails
104+
* @throws {NexoCryptoException} When an error occurs
94105
* @returns The decrypted SaleToPOI message as a UTF-8 string
95106
*/
96107
public static decrypt(
97108
saleToPoiSecureMessage: SaleToPOISecuredMessage,
98109
credentials: EncryptionCredentialDetails,
99110
): string {
100-
NexoSecurityManager.validateEncryptionCredentials(credentials);
101-
102-
const encryptedSaleToPoiMessageByteArray = Buffer.from(saleToPoiSecureMessage.NexoBlob, "base64");
103-
const derivedKey = NexoDerivedKeyGenerator.deriveKeyMaterial(credentials.Passphrase);
104-
const ivNonce = Buffer.from(saleToPoiSecureMessage.SecurityTrailer.Nonce, "base64");
105-
const decryptedSaleToPoiMessageByteArray = NexoSecurityManager.crypt(
106-
encryptedSaleToPoiMessageByteArray,
107-
derivedKey,
108-
ivNonce,
109-
Modes.DECRYPT,
110-
);
111-
112-
const receivedHmac = Buffer.from(saleToPoiSecureMessage.SecurityTrailer.Hmac, "base64");
113-
NexoSecurityManager.validateHmac(receivedHmac, decryptedSaleToPoiMessageByteArray, derivedKey);
114-
115-
return decryptedSaleToPoiMessageByteArray.toString("utf-8");
111+
112+
try {
113+
NexoSecurityManager.validateEncryptionCredentials(credentials);
114+
115+
// decrypt content of NexoBlob
116+
const encryptedSaleToPoiMessageByteArray = Buffer.from(saleToPoiSecureMessage.NexoBlob, "base64");
117+
118+
const derivedKey = NexoDerivedKeyGenerator.deriveKeyMaterial(credentials.Passphrase);
119+
const ivNonce = Buffer.from(saleToPoiSecureMessage.SecurityTrailer.Nonce, "base64");
120+
const decryptedSaleToPoiMessageByteArray = NexoSecurityManager.crypt(
121+
encryptedSaleToPoiMessageByteArray,
122+
derivedKey,
123+
ivNonce,
124+
Modes.DECRYPT,
125+
);
126+
127+
const receivedHmac = Buffer.from(saleToPoiSecureMessage.SecurityTrailer.Hmac, "base64");
128+
NexoSecurityManager.validateHmac(receivedHmac, decryptedSaleToPoiMessageByteArray, derivedKey);
129+
130+
return decryptedSaleToPoiMessageByteArray.toString("utf-8");
131+
132+
} catch (err: any) {
133+
// an error has occurred
134+
console.error(err);
135+
throw new NexoCryptoException(err?.message || "Unknown error during decryption");
136+
}
137+
116138
}
117139

118140
private static validateEncryptionCredentials(credentials: EncryptionCredentialDetails): void {
@@ -127,19 +149,36 @@ class NexoSecurityManager {
127149
}
128150
}
129151

152+
/**
153+
* Performs AES-256-CBC encryption or decryption.
154+
*
155+
* @param bytes The data to be encrypted or decrypted.
156+
* @param dk The derived key containing the cipher key and IV.
157+
* @param ivNonce The random nonce to be XORed with the IV.
158+
* @param mode The operation mode (ENCRYPT or DECRYPT).
159+
* @throws {NexoCryptoException} If an error occurs during the cryptographic operation.
160+
* @returns The resulting encrypted or decrypted data as a Buffer.
161+
*/
130162
private static crypt(bytes: Buffer, dk: NexoDerivedKey, ivNonce: Buffer, mode: Modes): Buffer {
131-
const actualIV = Buffer.alloc(NexoEnum.IV_LENGTH);
132-
for (let i = 0; i < NexoEnum.IV_LENGTH; i++) {
133-
actualIV[i] = dk.iv[i] ^ ivNonce[i];
163+
try {
164+
const actualIV = Buffer.alloc(NexoEnum.IV_LENGTH);
165+
for (let i = 0; i < NexoEnum.IV_LENGTH; i++) {
166+
actualIV[i] = dk.iv[i] ^ ivNonce[i];
167+
}
168+
169+
const cipher = mode === Modes.ENCRYPT
170+
? createCipheriv("aes-256-cbc", dk.cipherKey, actualIV)
171+
: createDecipheriv("aes-256-cbc", dk.cipherKey, actualIV);
172+
173+
let encrypted = (cipher as Cipher).update(bytes);
174+
encrypted = Buffer.concat([encrypted, cipher.final()]);
175+
return encrypted;
176+
177+
} catch (err: any) {
178+
// an error has occurred
179+
console.error(err);
180+
throw new NexoCryptoException(err?.message || "Unknown error during AES encryption or decryption");
134181
}
135-
136-
const cipher = mode === Modes.ENCRYPT
137-
? createCipheriv("aes-256-cbc", dk.cipherKey, actualIV)
138-
: createDecipheriv("aes-256-cbc", dk.cipherKey, actualIV);
139-
140-
let encrypted = (cipher as Cipher).update(bytes);
141-
encrypted = Buffer.concat([encrypted, cipher.final()]);
142-
return encrypted;
143182
}
144183

145184
private static hmac(bytes: Buffer, derivedKey: NexoDerivedKey): Buffer {

0 commit comments

Comments
 (0)