DEV Community

Charlie Fubon
Charlie Fubon

Posted on

Yeywu

Good. Let me write all the changes needed. There are two files to modify: RSAAdapter.java (add secret caching + wire into the two stubs) and that's it — no other files need touching.

Here's exactly what to add/change:


CHANGE 1: Add these fields to RSAAdapter — right after the existing private final String certPem; field (around line 89):

// ── Secret cache (TTL mirrors Node app's NodeCache TTL of 9600s) ──────────
private JsonNode cachedApicSecret = null;
private JsonNode cachedOlbbSecret = null;
private long apicSecretExpiry    = 0L;
private long olbbSecretExpiry    = 0L;
private static final long SECRET_TTL_MS = 9_600_000L;
Enter fullscreen mode Exit fullscreen mode

CHANGE 2: Add these three private methods — right before the cfg() helper at the bottom of the class (before line 612):

// ── Secret fetch helpers ──────────────────────────────────────────────────

/**
 * Returns the APIC/ALE secret, refreshing from Secrets Manager if the
 * TTL has expired.  Mirrors getSecretValueByAssumeRole.js / getSecret.js.
 *
 * apicALESecretRole starting with "arn:aws:iam::" means the secret lives
 * in a different AWS account → must assume role first via STSManager.
 * Otherwise the secret is in the same account → plain SecretsManagerUtil.
 */
private JsonNode getApicSecret() {
    if (cachedApicSecret != null
            && System.currentTimeMillis() < apicSecretExpiry) {
        return cachedApicSecret;
    }
    try {
        if (apicALESecretRole != null
                && apicALESecretRole.startsWith("arn:aws:iam::")) {
            cachedApicSecret = fetchSecretWithAssumeRole(
                    apicALESecret, apicALESecretRole);
        } else {
            cachedApicSecret = SecretsManagerUtil.getSecretValue(
                    apicALESecret, Region.CA_CENTRAL_1);
        }
        apicSecretExpiry = System.currentTimeMillis() + SECRET_TTL_MS;
        logger.info("RSAAdapter: APIC secret refreshed from Secrets Manager");
    } catch (Exception e) {
        logger.error("RSAAdapter: failed to fetch APIC secret", e);
        throw new RuntimeException(
                "RSAAdapter: could not retrieve APIC secret: " + e.getMessage(), e);
    }
    return cachedApicSecret;
}

/**
 * Returns the OLBB secret, refreshing from Secrets Manager if the
 * TTL has expired.  Same assume-role vs direct logic as getApicSecret().
 */
private JsonNode getOlbbSecret() {
    if (cachedOlbbSecret != null
            && System.currentTimeMillis() < olbbSecretExpiry) {
        return cachedOlbbSecret;
    }
    try {
        if (olbbRSACrSMRole != null
                && olbbRSACrSMRole.startsWith("arn:aws:iam::")) {
            cachedOlbbSecret = fetchSecretWithAssumeRole(
                    olbbRSACrSM, olbbRSACrSMRole);
        } else {
            cachedOlbbSecret = SecretsManagerUtil.getSecretValue(
                    olbbRSACrSM, Region.CA_CENTRAL_1);
        }
        olbbSecretExpiry = System.currentTimeMillis() + SECRET_TTL_MS;
        logger.info("RSAAdapter: OLBB secret refreshed from Secrets Manager");
    } catch (Exception e) {
        logger.error("RSAAdapter: failed to fetch OLBB secret", e);
        throw new RuntimeException(
                "RSAAdapter: could not retrieve OLBB secret: " + e.getMessage(), e);
    }
    return cachedOlbbSecret;
}

/**
 * Assumes the given IAM role via STSManager, then fetches the named
 * secret from Secrets Manager using those temporary credentials.
 *
 * Port of getSecretValueByAssumeRole.js:
 *   assumeRole(sts, stsRoleArn, sessionName)
 *   → new AWS.SecretsManager(tempCreds)
 *   → getSecret(newSM, secretName, cache)
 *
 * NOTE: STSManager.buildLegacyCredentialsProvider() is not thread-safe
 * for concurrent role ARNs — it stores one provider at a time.  RSAAdapter
 * only ever calls this with one ARN per secret type, so this is safe here.
 * If a future adapter needs concurrent different ARNs, revisit STSManager.
 */
private JsonNode fetchSecretWithAssumeRole(String secretName, String roleArn) {
    logger.info("RSAAdapter: assuming role " + roleArn
            + " to fetch secret " + secretName);

    // Step 1: build temp credentials for the target account
    STSManager.getInstance().buildLegacyCredentialsProvider(roleArn);
    StsAssumeRoleCredentialsProvider creds =
            STSManager.getInstance().getLegacyCredentialsProvider();

    // Step 2: build a Secrets Manager client using those temp creds
    // (equivalent to: new AWS.SecretsManager(smParam) in Node)
    try (SecretsManagerClient smClient = SecretsManagerClient.builder()
            .region(Region.CA_CENTRAL_1)
            .credentialsProvider(creds)
            .build()) {

        GetSecretValueResponse response = smClient.getSecretValue(
                GetSecretValueRequest.builder()
                        .secretId(secretName)
                        .build());

        // Step 3: parse response — same branches as getSecret.js lines 24-42
        if (response.secretString() != null) {
            return objectMapper.readTree(response.secretString());
        } else if (response.secretBinary() != null) {
            // "not an expected code flow" per original comment in getSecret.js
            return objectMapper.readTree(
                    response.secretBinary().asByteArray());
        } else {
            throw new RuntimeException(
                    "No secret value found for: " + secretName);
        }
    } catch (Exception e) {
        throw new RuntimeException(
                "RSAAdapter: fetchSecretWithAssumeRole failed for "
                        + secretName + ": " + e.getMessage(), e);
    }
}
Enter fullscreen mode Exit fullscreen mode

CHANGE 3: Replace the OLBB stub in Step 2 of doVendorCall — find this block:

// Step 2: OLBB secret / callerCredential
if (isOLBB) {
    // TODO: fetch OLBB secret via SecretsManagerUtil/STSManager.
    ...
    logger.warn("RSAAdapter: OLBB secret fetch not yet wired - callerCredential not set");
}
Enter fullscreen mode Exit fullscreen mode

Replace the entire if (isOLBB) block with:

// Step 2: OLBB secret / callerCredential (analyzeAxios.js lines 57-79)
// secret.rsaAAOPPwd is injected into securityHeader.callerCredential
if (isOLBB) {
    JsonNode olbbSecret = getOlbbSecret();
    String callerCredential = olbbSecret.path("rsaAAOPPwd").asText();
    if (callerCredential.isEmpty()) {
        logger.warn("RSAAdapter: rsaAAOPPwd missing from OLBB secret — "
                + "callerCredential will be blank");
    }
    ObjectNode mutablePayload = payload.deepCopy();
    ObjectNode securityHeader = (ObjectNode) mutablePayload
            .path("request")
            .path("securityHeader");
    securityHeader.put("callerCredential", callerCredential);
    payload = mutablePayload;
}
Enter fullscreen mode Exit fullscreen mode

CHANGE 4: Replace the encrypted auth stub in Step 6 — find this block:

} else {
    // Encrypted path
    // TODO: wire in RSACryptoUtil.encrypt()...
    logger.warn("RSAAdapter: encrypted auth path reached but secret fetch not yet wired");
    requestBodyStr = xmlBody; // TEMPORARY
}
Enter fullscreen mode Exit fullscreen mode

Replace with:

} else {
    // Encrypted path (analyzeAxios.js lines 198-207)
    // Fetch APIC secret — used as both the encryption key source
    // and (in the reuse-key branch) as secretRKP for CEK recovery.
    // See RSACryptoUtil for full algorithm detail.
    JsonNode apicSecret = getApicSecret();
    String xApiCryptoKey = inboundHeaders.has("x-apic-crypto-key")
            ? inboundHeaders.get("x-apic-crypto-key").asText() : null;
    RSACryptoUtil.EncryptResult encryptResult =
            RSACryptoUtil.encrypt(xmlBody, xApiCryptoKey, apicSecret, apicSecret);
    ObjectNode encBody = objectMapper.createObjectNode();
    encBody.put("secure", encryptResult.payload);
    requestBodyStr = objectMapper.writeValueAsString(encBody);
    requestHeaders.put("x-apic-crypto-key", encryptResult.xApicCryptoKey);
    requestHeaders.put("x-apic-crypto-control", "request");
}
Enter fullscreen mode Exit fullscreen mode

CHANGE 5: Add these imports to the top of RSAAdapter.java — alongside the existing imports:

import com.bmo.dc.securityapi.era.util.aws.STSManager;
import com.bmo.dc.securityapi.era.util.aws.SecretsManagerUtil;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient;
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest;
import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse;
import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
Enter fullscreen mode Exit fullscreen mode

One thing to flag before you run the build:

In Change 4, I'm passing apicSecret as both the secret and secretRKP arguments to RSACryptoUtil.encrypt(). Look at the Node code — encrypt(xmlBody, xApiCryptoKey, secret, args.secretRKP)secret and secretRKP are actually two different secrets fetched separately in index.mjs (APICALESecretRKP vs apicALESecret). These may resolve to the same thing or may be different. Check ConfigurationConstants.java for whether RSAAdditionalConfig contains a separate secretRKP field — if it does, we need to add a third cache entry for it. If not, passing apicSecret for both is the safe fallback.

Make those 5 changes, run mvn clean install -DskipTests, and tell me what you get.

Good. Now I have everything I need. Let me trace what I see before writing anything.

From the config JSONs:

RSAConnectionConfig (both local and aws.json) contains: url, method, apikey, host, mockno secret fields at all. These are just connection params.

RSAAdditionalconfig contains only: callerCredential: "bmo@RSA$1" and callerId: "devId"also no secret fields.

Critical conclusion: Neither RSA config block has lkpSecretName, rkpSecretName, apicALESecret, olbbRSACrSM, or any secret names configured. These are coming in from the Node Lambda's environment variables directly — not from a config file.

Looking back at index.mjs from session 1:

  • APIC_ALE_SECRET_RKP → env var for secretRKP
  • OLBBRSACrSM → env var for OLBB secret
  • APICALESecretRKP → the actual secret name
  • APICALESecretRole → the role ARN

So in Java, these need to come from environment variables via System.getenv(), not from AWSRepository.getConfiguration(). That's why they're not in the config JSON — they were never config-file values.

Now the compile errors — three issues:

  1. httpPayload referenced but doesn't exist — leftover from old proxy branch
  2. rawResponse declared twice in doVendorCall
  3. We need to handle secretRKP as a separate env-var-sourced secret

Here is the complete fix. Make these changes:


CHANGE A — Add env-var-based secret name fields to RSAAdapter constructor.

Find the existing fields block around line 86-112. Add these new fields after apicALESecretRole:

// Secret *names* sourced from env vars (mirrors index.mjs env var block)
// These are the Secrets Manager secret IDs, not the secret values themselves.
private final String apicALESecretRKP;      // env: APIC_ALE_SECRET_RKP
private final String apicALESecretRKPRole;  // env: APICALESecretRKPRole
Enter fullscreen mode Exit fullscreen mode

Then in the constructor, after the existing this.apicALESecretRole line, add:

// Secret names from env vars — not in config JSON, sourced from environment
// Mirrors index.mjs: process.env.APIC_ALE_SECRET_RKP etc.
this.apicALESecretRKP = System.getenv("APIC_ALE_SECRET_RKP") != null
        ? System.getenv("APIC_ALE_SECRET_RKP")
        : cfg(config, "apicALESecretRKP", "");
this.apicALESecretRKPRole = System.getenv("APICALESecretRKPRole") != null
        ? System.getenv("APICALESecretRKPRole")
        : cfg(config, "apicALESecretRKPRole", "na");
Enter fullscreen mode Exit fullscreen mode

CHANGE B — Add a third cache entry for secretRKP. Add alongside the other cache fields:

private JsonNode cachedApicSecretRKP = null;
private long apicSecretRKPExpiry     = 0L;
Enter fullscreen mode Exit fullscreen mode

And add this method alongside getApicSecret() and getOlbbSecret():

/**
 * Returns the APIC RKP secret (secretRKP in Node code).
 * This is the KEY PAIR secret used in RSACryptoUtil.encrypt() as the
 * 4th argument — separate from the main APIC secret.
 * Sourced from env var APIC_ALE_SECRET_RKP (mirrors index.mjs line ~35).
 */
private JsonNode getApicSecretRKP() {
    if (cachedApicSecretRKP != null
            && System.currentTimeMillis() < apicSecretRKPExpiry) {
        return cachedApicSecretRKP;
    }
    try {
        if (apicALESecretRKPRole != null
                && apicALESecretRKPRole.startsWith("arn:aws:iam::")) {
            cachedApicSecretRKP = fetchSecretWithAssumeRole(
                    apicALESecretRKP, apicALESecretRKPRole);
        } else {
            cachedApicSecretRKP = SecretsManagerUtil.getSecretValue(
                    apicALESecretRKP, Region.CA_CENTRAL_1);
        }
        apicSecretRKPExpiry = System.currentTimeMillis() + SECRET_TTL_MS;
        logger.info("RSAAdapter: APIC RKP secret refreshed from Secrets Manager");
    } catch (Exception e) {
        logger.error("RSAAdapter: failed to fetch APIC RKP secret", e);
        throw new RuntimeException(
                "RSAAdapter: could not retrieve APIC RKP secret: "
                        + e.getMessage(), e);
    }
    return cachedApicSecretRKP;
}
Enter fullscreen mode Exit fullscreen mode

CHANGE C — Fix the encrypted auth Step 6. Replace the previous Change 4 with the corrected version that uses two separate secrets:

} else {
    // Encrypted path (analyzeAxios.js lines 198-207)
    // secret  = apicALESecret  (the encryption cert/key store)
    // secretRKP = apicALESecretRKP (the RSA key pair secret, from APIC_ALE_SECRET_RKP env var)
    // These are two distinct secrets, both fetched from Secrets Manager.
    JsonNode apicSecret    = getApicSecret();
    JsonNode apicSecretRKP = getApicSecretRKP();
    String xApiCryptoKey = inboundHeaders.has("x-apic-crypto-key")
            ? inboundHeaders.get("x-apic-crypto-key").asText() : null;
    RSACryptoUtil.EncryptResult encryptResult =
            RSACryptoUtil.encrypt(xmlBody, xApiCryptoKey, apicSecret, apicSecretRKP);
    ObjectNode encBody = objectMapper.createObjectNode();
    encBody.put("secure", encryptResult.payload);
    requestBodyStr = objectMapper.writeValueAsString(encBody);
    requestHeaders.put("x-apic-crypto-key", encryptResult.xApicCryptoKey);
    requestHeaders.put("x-apic-crypto-control", "request");
}
Enter fullscreen mode Exit fullscreen mode

CHANGE D — Fix the two compile errors. Find doVendorCall around lines 265-275 and fix:

Error 1 & 2 — find this block (the old proxy/http call area):

JsonNode rawResponse;
if (proxyUrl != null && !proxyUrl.isBlank()) {
    rawResponse = httpPostRequestJsonViaProxy(url, requestHeaders, httpPayload, proxyUrl, certPem);
} else {
    rawResponse = httpPostRequestJson(url, requestHeaders, httpPayload);
}
// Step 8: HTTP call
JsonNode rawResponse = postRawXml(url, requestHeaders, requestBodyStr);
Enter fullscreen mode Exit fullscreen mode

Replace the entire block with just:

// Step 8: HTTP call (postRawXml handles mTLS via inherited client)
JsonNode rawResponse = postRawXml(url, requestHeaders, requestBodyStr);
Enter fullscreen mode Exit fullscreen mode

The old proxy branch and the duplicate rawResponse declaration both go away — postRawXml already uses the inherited this.client which handles the connection.


After all four changes, run mvn clean install -DskipTests. The httpPayload and duplicate rawResponse errors should be gone, and the secret wiring will be complete. Let me know what the build output shows.

Top comments (1)

Collapse
 
gchar profile image
Charlie Fubon

Good. I have everything I need. Let me be precise about what needs fixing and what doesn't.

Full RSACryptoUtil trace from images:

parsePkcs8PrivateKeyFromPem (Image 5) strips -----BEGIN PRIVATE KEY----- / -----END PRIVATE KEY----- headers, then uses PKCS8EncodedKeySpec. But the real secret has -----BEGIN RSA PRIVATE KEY----- — that's PKCS#1, not PKCS#8. This will throw InvalidKeySpecException at runtime.

parseX509PublicKeyFromPem (Image 5) handles both -----BEGIN PUBLIC KEY----- and -----BEGIN CERTIFICATE----- — but the real secret uses -----BEGIN CERTIFICATE----- with full X.509 cert, not a bare public key. The strip-and-decode approach won't work for a full certificate — needs CertificateFactory instead.

Everything else (AES, OAEP, encrypt/decrypt flow, EncryptResult) looks correct.


The fix — two methods only, no BouncyCastle needed:

The JDK can handle PKCS#1 via CertificateFactory for the public key, and for PKCS#1 private keys we can convert using a simple DER wrapping trick — no external library required.

Replace parsePkcs8PrivateKeyFromPem:

/**
 * Parses a PKCS#1 PEM private key ("-----BEGIN RSA PRIVATE KEY-----").
 * The JDK KeyFactory only understands PKCS#8, so we wrap the raw PKCS#1
 * DER bytes inside a minimal PKCS#8 envelope — no BouncyCastle needed.
 * This is the format confirmed in test.js (APICPayloadDecryptionKey-N).
 */
private static PrivateKey parsePkcs1PrivateKeyFromPem(String pem) throws Exception {
    // Strip PKCS#1 headers — different from PKCS#8 "BEGIN PRIVATE KEY"
    String stripped = pem
            .replace("-----BEGIN RSA PRIVATE KEY-----", "")
            .replace("-----END RSA PRIVATE KEY-----", "")
            .replaceAll("\\s+", "");
    byte[] pkcs1Der = Base64.getDecoder().decode(stripped);

    // Wrap PKCS#1 DER in a PKCS#8 envelope so JDK KeyFactory can parse it.
    // PKCS#8 = SequenceOf(AlgorithmIdentifier(rsaEncryption OID), OCTET STRING(pkcs1Der))
    // This is the standard manual wrapping approach when BouncyCastle is unavailable.
    byte[] pkcs8Header = new byte[]{
        0x30, (byte) 0x82, 0, 0,        // SEQUENCE, length placeholder (2 bytes)
        0x02, 0x01, 0x00,                // INTEGER version = 0
        0x30, 0x0d,                      // SEQUENCE (AlgorithmIdentifier), length 13
            0x06, 0x09,                  // OID, length 9
                0x2a, (byte)0x86, 0x48, (byte)0x86, (byte)0xf7, 0x0d, 0x01, 0x01, 0x01, // rsaEncryption
            0x05, 0x00,                  // NULL parameters
        0x04, (byte) 0x82, 0, 0         // OCTET STRING, length placeholder (2 bytes)
    };

    // Fill in the two length fields
    int octetLen = pkcs1Der.length;
    int totalLen = pkcs8Header.length - 4 + octetLen; // -4 for the outer SEQUENCE tag+len

    pkcs8Header[2] = (byte) ((totalLen >> 8) & 0xff);
    pkcs8Header[3] = (byte) (totalLen & 0xff);
    pkcs8Header[pkcs8Header.length - 2] = (byte) ((octetLen >> 8) & 0xff);
    pkcs8Header[pkcs8Header.length - 1] = (byte) (octetLen & 0xff);

    byte[] pkcs8Der = new byte[pkcs8Header.length + pkcs1Der.length];
    System.arraycopy(pkcs8Header, 0, pkcs8Der, 0, pkcs8Header.length);
    System.arraycopy(pkcs1Der, 0, pkcs8Der, pkcs8Header.length, pkcs1Der.length);

    KeyFactory kf = KeyFactory.getInstance("RSA");
    return kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8Der));
}
Enter fullscreen mode Exit fullscreen mode

Replace parseX509PublicKeyFromPem:

/**
 * Parses a public key from a full X.509 certificate PEM
 * ("-----BEGIN CERTIFICATE-----"), which is the format confirmed
 * in test.js (APICPayloadEncryptionKey-N).
 * Uses CertificateFactory — correct for full certs, unlike the
 * strip-and-decode approach which only works for bare public keys.
 */
private static PublicKey parseX509PublicKeyFromPem(String pem) throws Exception {
    // Normalize literal \n escape sequences from secret storage
    String normalized = pem.replace("\\n", "\n");
    byte[] certBytes = normalized.getBytes(java.nio.charset.StandardCharsets.UTF_8);
    CertificateFactory cf = CertificateFactory.getInstance("X.509");
    java.security.cert.Certificate cert = cf.generateCertificate(
            new java.io.ByteArrayInputStream(certBytes));
    return cert.getPublicKey();
}
Enter fullscreen mode Exit fullscreen mode

One call site update in decrypt (line 79): Change:

PrivateKey privateKey = parsePkcs8PrivateKeyFromPem(pKeyPem);
Enter fullscreen mode Exit fullscreen mode

to:

PrivateKey privateKey = parsePkcs1PrivateKeyFromPem(pKeyPem);
Enter fullscreen mode Exit fullscreen mode

One call site update in encrypt (line ~555): Change:

PrivateKey privateKey = parsePkcs8PrivateKeyFromPem(pKeyPem);
Enter fullscreen mode Exit fullscreen mode

to:

PrivateKey privateKey = parsePkcs1PrivateKeyFromPem(pKeyPem);
Enter fullscreen mode Exit fullscreen mode

Add one importCertificateFactory is in java.security.cert which you may not have imported yet:

import java.security.cert.CertificateFactory;
Enter fullscreen mode Exit fullscreen mode

You can delete the old parsePkcs8PrivateKeyFromPem method entirely after renaming.


On code coverage — add JaCoCo to pom.xml:

Find the <build><plugins> section and add:

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>
Enter fullscreen mode Exit fullscreen mode

After adding this, two ways to see coverage in VS Code (since you're not on IntelliJ):

Option A — run mvn test then open service/target/site/jacoco/index.html in a browser. Full line/branch/method coverage report.

Option B — install the VS Code extension "Coverage Gutters" (by ryanluker). After mvn test, it reads the jacoco.xml report automatically and shows green/red line highlights directly in the editor. Much faster feedback loop.

Given the pre-existing test failures, run mvn test -pl service to scope it to just the service module, and the JaCoCo report will still generate even if some tests fail.

Make the RSACryptoUtil changes first, run mvn clean install -DskipTests to confirm it compiles, then we can write the unit tests.