DEV Community

Charlie Fubon
Charlie Fubon

Posted on

Hahjs


package com.bmo.dc.securityapi.era.util.common;

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.OAEPParameterSpec;
import javax.crypto.spec.MGF1ParameterSpec;
import javax.crypto.spec.PSource;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.MessageDigest;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

import com.fasterxml.jackson.databind.JsonNode;

/**
 * 1:1 port of CG2V_SCCG-RSAAdaptiveAuthentication-CDK_26777/service/lambda-functions/service/utils.js
 * decrypt() and encrypt() functions.
 *
 * Envelope encryption scheme: an AES-256-CBC "data encryption key" (DEK/CEK) is itself
 * encrypted with RSA-OAEP, and the result is carried as a dot-delimited string:
 *   keyVersion.base64(RSA-OAEP-encrypted CEK).base64(SHA-256 hash of CEK).base64(IV)
 *
 * ============================================================================
 * !!! UNVERIFIED — MUST CONFIRM BEFORE PRODUCTION USE !!!
 * The old Node code calls crypto.privateDecrypt/publicEncrypt with only
 * { key, padding: RSA_PKCS1_OAEP_PADDING } — no oaepHash/mgf1Hash options.
 * Node's crypto module defaults BOTH the OAEP hash and the MGF1 hash to SHA-1
 * when unspecified. This class assumes that default (OAEPWithSHA-1AndMGF1Padding)
 * to match. THIS HAS NOT BEEN EMPIRICALLY VERIFIED against a real round-trip
 * between the Node Lambda and this Java code. If decryption fails or produces
 * garbage, this is the first thing to check — try SHA-256 next.
 * See KT doc — verify via round-trip test (Node-encrypt -> Java-decrypt) with a
 * scratch RSA keypair before relying on this in any environment beyond local dev.
 * ============================================================================
 */
public final class RSACryptoUtil {

    private RSACryptoUtil() {
    }

    private static final String RSA_TRANSFORM = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding"; // see warning above
    private static final String AES_TRANSFORM = "AES/CBC/PKCS5Padding"; // Node aes-256-cbc default padding is PKCS#7,
                                                                          // equivalent to PKCS5Padding in JCE for this block size
    private static final OAEPParameterSpec OAEP_SHA1_SPEC = new OAEPParameterSpec(
            "SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT);

    /**
     * 1:1 port of utils.js decrypt(payload, xApicCryptoKey, secret).
     *
     * @param payload        JsonNode with a "secure" field (base64 ciphertext) — mirrors payload.secure in JS
     * @param xApicCryptoKey the dot-delimited key-material string from the x-apic-crypto-key header
     * @param secret         JsonNode (or Map-like) of secret material, expected to contain a key named
     *                       "APICPayloadDecryptionKey-<keyVersion>" holding a PEM private key string
     * @return decrypted payload as a String (caller is expected to JSON.parse it, matching old serviceFunc.js)
     */
    public static String decrypt(JsonNode payload, String xApicCryptoKey, JsonNode secret) throws Exception {
        // tokens[0]=keyVersion, [1]=encrypted_CEK_B64, [2]=hashCEK_B64 (UNUSED here, see class javadoc /
        // KT doc — parsed only because it's positional in the dot-delimited format; it's the CEK integrity
        // hash produced by encrypt(), decrypt() never needs it), [3]=iv_B64
        String[] tokens = xApicCryptoKey.split("\\.");
        String keyVersion = tokens[0];
        String encryptedCekB64 = tokens[1];
        // String hashCekB64 = tokens[2];   // intentionally unused — see javadoc above
        String ivB64 = tokens[3];

        String keyName = "APICPayloadDecryptionKey-" + keyVersion;
        String pKeyRaw = secret.get(keyName).asText();

        // Old JS: pKey.replace(/\\n/g, "\n") — un-escape literal backslash-n sequences from secret storage
        // into real newlines, required for PEM parsing. MUST replicate exactly.
        String pKeyPem = pKeyRaw.replace("\\n", "\n");

        byte[] encryptedDek = Base64.getDecoder().decode(encryptedCekB64);
        PrivateKey privateKey = parsePkcs8PrivateKeyFromPem(pKeyPem);

        byte[] dataEncryptionKey = rsaOaepDecrypt(privateKey, encryptedDek);

        byte[] iv = Base64.getDecoder().decode(ivB64);

        Cipher aesCipher = Cipher.getInstance(AES_TRANSFORM);
        aesCipher.init(Cipher.DECRYPT_MODE,
                new javax.crypto.spec.SecretKeySpec(dataEncryptionKey, "AES"),
                new IvParameterSpec(iv));

        byte[] cipherBytes = Base64.getDecoder().decode(payload.get("secure").asText());
        byte[] plainBytes = aesCipher.doFinal(cipherBytes);

        return new String(plainBytes, java.nio.charset.StandardCharsets.UTF_8);
    }

    /**
     * 1:1 port of utils.js encrypt(payload, pApicCryptoKey, secret, secretRKP).
     *
     * Two branches, matching the old JS exactly:
     *   - xApicCryptoKey == null: generate a fresh CEK/IV, RSA-OAEP encrypt the CEK with the
     *     PUBLIC cert from `secret` (APICPayloadEncryptionKey-<currentVersion>). This branch is
     *     NOT the one exercised by RSAAdapter's actual call path (RSA always passes an existing
     *     xApiCryptoKey from the request headers) — kept here because encrypt() is a shared
     *     utility other vendors/flows may use the generate-new-key branch for. Flagging this so
     *     it isn't mistaken for dead/untested code specific to RSA.
     *   - xApicCryptoKey != null: parse the existing dot-delimited key material and RSA-OAEP
     *     DECRYPT the CEK using the PRIVATE key from `secretRKP` (APICPayloadDecryptionKey-<keyVersion>),
     *     i.e. reuse the session key rather than generating a new one. THIS is the branch RSAAdapter
     *     actually hits, since analyzeAxios.js (and siblings) always pass a non-null xApiCryptoKey.
     *
     * @return result object equivalent to JS's { xApicCryptoKey, payload } — see EncryptResult below
     */
    public static EncryptResult encrypt(String payload, String xApicCryptoKey, JsonNode secret, JsonNode secretRKP)
            throws Exception {
        String keyVersion;
        String encryptedCekB64;
        String hashCekB64;
        String ivB64;
        byte[] dataEncryptionKey;

        if (xApicCryptoKey == null) {
            // ---- Branch: generate new ephemeral key (NOT the RSA-adapter code path, see javadoc) ----
            keyVersion = secret.get("currentVersion").asText();

            SecureRandom random = new SecureRandom();
            byte[] cek = new byte[32]; // 256-bit AES key
            random.nextBytes(cek);
            byte[] ivBytes = new byte[16];
            random.nextBytes(ivBytes);

            encryptedCekB64 = null; // computed below after RSA-OAEP encrypt
            ivB64 = Base64.getEncoder().encodeToString(ivBytes);

            MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
            hashCekB64 = Base64.getEncoder().encodeToString(sha256.digest(cek));

            String certName = "APICPayloadEncryptionKey-" + keyVersion;
            String certRaw = secret.get(certName).asText();
            String certPem = certRaw.replace("\\n", "\n");

            PublicKey publicKey = parseX509PublicKeyFromPem(certPem);
            byte[] encryptedCek = rsaOaepEncrypt(publicKey, cek);
            encryptedCekB64 = Base64.getEncoder().encodeToString(encryptedCek);

            dataEncryptionKey = cek;

            xApicCryptoKey = keyVersion + "." + encryptedCekB64 + "." + hashCekB64 + "." + ivB64;
        } else {
            // ---- Branch: reuse existing key material (THIS is RSAAdapter's actual code path) ----
            String[] tokens = xApicCryptoKey.split("\\.");
            keyVersion = tokens[0];
            encryptedCekB64 = tokens[1];
            hashCekB64 = tokens[2]; // unused below, same as decrypt() — see class javadoc
            ivB64 = tokens[3];

            String keyName = "APICPayloadDecryptionKey-" + keyVersion;
            String pKeyRaw = secretRKP.get(keyName).asText();
            String pKeyPem = pKeyRaw.replace("\\n", "\n");

            PrivateKey privateKey = parsePkcs8PrivateKeyFromPem(pKeyPem);
            byte[] encryptedCek = Base64.getDecoder().decode(encryptedCekB64);
            dataEncryptionKey = rsaOaepDecrypt(privateKey, encryptedCek);
        }

        byte[] iv = Base64.getDecoder().decode(ivB64);
        Cipher aesCipher = Cipher.getInstance(AES_TRANSFORM);
        aesCipher.init(Cipher.ENCRYPT_MODE,
                new javax.crypto.spec.SecretKeySpec(dataEncryptionKey, "AES"),
                new IvParameterSpec(iv));

        byte[] plainBytes = payload.getBytes(java.nio.charset.StandardCharsets.UTF_8);
        byte[] cipherBytes = aesCipher.doFinal(plainBytes);
        String theCipher = Base64.getEncoder().encodeToString(cipherBytes);

        return new EncryptResult(xApicCryptoKey, theCipher);
    }

    /** Mirrors JS's `{ xApicCryptoKey, payload }` return shape from encrypt(). */
    public static final class EncryptResult {
        public final String xApicCryptoKey;
        public final String payload;

        public EncryptResult(String xApicCryptoKey, String payload) {
            this.xApicCryptoKey = xApicCryptoKey;
            this.payload = payload;
        }
    }

    // ---- Internal helpers -----------------------------------------------------------------

    private static byte[] rsaOaepDecrypt(PrivateKey privateKey, byte[] data) throws Exception {
        Cipher cipher = Cipher.getInstance(RSA_TRANSFORM);
        cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SHA1_SPEC);
        return cipher.doFinal(data);
    }

    private static byte[] rsaOaepEncrypt(PublicKey publicKey, byte[] data) throws Exception {
        Cipher cipher = Cipher.getInstance(RSA_TRANSFORM);
        cipher.init(Cipher.ENCRYPT_MODE, publicKey, OAEP_SHA1_SPEC);
        return cipher.doFinal(data);
    }

    /**
     * Parses a PEM-encoded PKCS#8 private key (the typical "-----BEGIN PRIVATE KEY-----" format).
     * NOTE: if the secret's PEM is actually PKCS#1 ("-----BEGIN RSA PRIVATE KEY-----"), this will
     * fail and will need a PKCS#1-aware parser instead (e.g. via BouncyCastle) — confirm the actual
     * PEM header format from a real secret value before relying on this.
     */
    private static PrivateKey parsePkcs8PrivateKeyFromPem(String pem) throws Exception {
        String stripped = pem
                .replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "")
                .replaceAll("\\s+", "");
        byte[] der = Base64.getDecoder().decode(stripped);
        KeyFactory kf = KeyFactory.getInstance("RSA");
        return kf.generatePrivate(new PKCS8EncodedKeySpec(der));
    }

    private static PublicKey parseX509PublicKeyFromPem(String pem) throws Exception {
        // Handles both "-----BEGIN PUBLIC KEY-----" and "-----BEGIN CERTIFICATE-----" forms.
        // If `pem` is a full X.509 certificate (not a bare public key), this strip-based approach
        // will need to go through CertificateFactory instead — confirm actual secret format.
        String stripped = pem
                .replace("-----BEGIN PUBLIC KEY-----", "")
                .replace("-----END PUBLIC KEY-----", "")
                .replace("-----BEGIN CERTIFICATE-----", "")
                .replace("-----END CERTIFICATE-----", "")
                .replaceAll("\\s+", "");
        byte[] der = Base64.getDecoder().decode(stripped);
        KeyFactory kf = KeyFactory.getInstance("RSA");
        return kf.generatePublic(new X509EncodedKeySpec(der));
    }
}

Enter fullscreen mode Exit fullscreen mode

Top comments (0)