Cryptography

Does obfuscation break key generation and signing options?

Key generation is one of the few places where a missing option does not produce a smaller feature, it produces a weaker artifact that looks exactly like the strong one. We protected a file that generates an RSA key pair, encrypts the private half at rest, and signs with RSA-PSS, then renamed the option names one group at a time and read the result from a verifier that was never rebuilt.

What the sample actually does

The file calls crypto.generateKeyPairSync for a 2048-bit RSA pair. The public half is exported as SPKI PEM. The private half is exported as PKCS#8 PEM and encrypted at rest with cipher: 'aes-256-cbc' and a passphrase. It then loads the private key back, signs a short invoice string with RSA-PSS at a digest-length salt, and hands the signature to a separate module that was copied into the output directory unprotected.

That last detail is the whole design. If the verifier is protected in the same run, both halves of the contract move together and every check passes, which proves nothing. The party that verifies your signature is not rebuilt with your release, so the verifier here pins RSA-PSS with a 32-byte salt and never changes.

Before renaming anything, protection alone was applied on five presets, including both targets and the compressed profile. All five produced byte-identical output to the unprotected run. Nothing in this article is a defect in the engine; it is what member renaming does when the names in an options object belong to somebody else.

The result worth the whole exercise: half the pair fails loudly, both fail silently

Renaming cipher on its own stops the program dead with ERR_INVALID_ARG_VALUE: the property options.privateKeyEncoding.cipher is invalid. Renaming passphrase on its own does the same. Either failure is instant, at build-verification time, with the offending property named in the message. That is the best possible outcome.

Renaming both together, which is exactly what a regular expression scoped to an options block does, produces no error at all. The generated PEM changes from -----BEGIN ENCRYPTED PRIVATE KEY----- to -----BEGIN PRIVATE KEY-----. Our reported value moved from private-key-at-rest=encrypted to private-key-at-rest=PLAINTEXT. Everything downstream still worked, the signature was still 256 bytes, the far side still accepted it, and the process exited zero.

The reason is worth internalising, because it generalises well beyond this API. Node validates the pair against each other, not against your intention: a cipher without a passphrase is an invalid combination, and so is a passphrase without a cipher, but neither present is a perfectly legal request to export a key unencrypted. A paired option that is only checked against its partner fails safe when you lose half of it and fails silently when you lose all of it.

A key file that is the wrong kind of correct

The output is still a valid PEM. It still parses. It still loads without the passphrase your loader passes, because an unencrypted PKCS#8 key ignores one. Anything that greps for BEGIN PRIVATE KEY to confirm a key was written will pass. The only line that differs is the header, and the header is the part nobody reads.

That matters because the failure lands on an artifact, not on a run. A build that generates a signing key and writes it to a mounted secret, or to a customer installer, or to a config directory on a device, produces the weak file once and then walks away. There is no request to retry, no error rate to alert on, and no exception to attach to a release.

If you generate keys inside protected code, add one assertion next to the write: read the first line back and require the word ENCRYPTED. It costs nothing, and unlike the option names it cannot be renamed out of the file, because the string is a literal and string literals are not member names.

The signature scheme moves without breaking anything you own

The signing call pins padding to RSA-PSS and saltLength to 32, both of which are non-default: node's default RSA padding is PKCS#1 v1.5, and its default PSS salt is the maximum permissible length rather than the digest length. Renaming padding reverted the signature to PKCS#1 v1.5. The signature was still produced, still 256 bytes, and still verified, but only by the right reader.

Our fixed verifier reported the change precisely. The verdict from the verifier that pins PSS went from accepted to REJECTED, and the verdict from a legacy verifier that still accepts PKCS#1 v1.5 went from REJECTED to accepted. In other words, the peers who did the work of migrating to PSS are the ones who break, and the peers who never migrated keep working. That is the opposite of the failure ordering anyone would predict.

Renaming saltLength alone kept PSS but moved the salt to node's maximum-length default. A verifier that pins a digest-length salt rejected the result; a verifier configured to detect the salt automatically would not have noticed. Whether this shows up at all is decided by how strict the far side is, which is a property of somebody else's code.

ECDSA: the wire encoding is an option too

The same file signs with a P-256 key and dsaEncoding: 'ieee-p1363', the fixed-width concatenated form that WebCrypto and JWS both expect. Renaming that one name reverts node to DER, its default. Our reported shape moved from p1363-64 to der-variable, and the JWS-shaped verifier rejected the signature because it was not 64 bytes.

This is the friendliest failure in the article, and only by accident: a fixed-width reader has an obvious length check, so it refuses immediately. A reader that hands the bytes to a more permissive library, or that only logs a verification failure and continues, would degrade instead of stopping.

It is also the arm most likely to survive a staging environment. DER signatures are perfectly valid; every node-to-node test where both ends use library defaults will pass. The mismatch only appears at the boundary with a browser, a mobile client, or a service that speaks the JOSE dialect.

Containers, required options, and the loud arms

Three arms failed immediately and cleanly, which is worth recording because it tells you where the sharp edges are not. Renaming modulusLength threw ERR_INVALID_ARG_TYPE, because node has no default RSA key size and requires the number. Renaming namedCurve threw for the same reason. Renaming type and format together threw on the public key encoding block.

Renaming the two container names, publicKeyEncoding and privateKeyEncoding, produced the most visible damage of the pass. Without an encoding block, node returns KeyObject instances rather than PEM strings. Our header line became [object KeyObject], the PEM check went false, reloading the key failed with a type error, and nothing could be signed at all.

Read those together with the silent arm above and the pattern is clear. The names that are structurally required fail at the first call. The names that express a security choice, where the absence of the name is itself a legal request, fail quietly and produce a weaker artifact. The dangerous options are exactly the ones whose absence means permission.

What to do about it

The mitigation is the same one every article in this series arrives at, and it is cheap. Member renaming is opt-in and scoped by a regular expression, so exclude the names that belong to a library or a runtime. For this area that is a short list: cipher, passphrase, modulusLength, namedCurve, type, format, padding, saltLength, dsaEncoding, publicKeyEncoding, privateKeyEncoding.

Then narrow the question. The only option names that can hurt you are the ones you pass a non-default value to, because renaming a name whose value already matches the runtime default changes nothing. Walk your options objects and mark every value that differs from the library default; that list is short, and it is your exclusion list.

Finally, protect the artifact, not the intention. Assert on the PEM header after writing a key, and verify a freshly produced signature with a verifier that pins the scheme you think you are using, in the same build step that produced it. Both checks are two lines, both survive renaming, and both would have caught every silent result in this article.

Frequently asked questions

Does obfuscation change how a key pair is generated?

Not by itself. Protection alone, on all five presets we tested including both targets and the compressed profile, produced byte-identical output to the unprotected file. The behaviour only changed when member renaming was pointed at the option names that node reads.

How can a private key end up unencrypted with no error?

Node validates cipher and passphrase against each other rather than against your intent. Renaming either one alone is an invalid combination and throws. Renaming both removes the request for encryption entirely, which is a legal thing to ask for, so the key is exported as plain PKCS#8 and the process exits normally.

Would a test suite catch the plaintext key?

Only if a test reads the PEM header. Every functional check still passed in our run: the key loaded, the signature was produced, and the far side accepted it. Assert that the first line contains the word ENCRYPTED, because that string is a literal and cannot be renamed.

What happens to an RSA-PSS signature when padding is renamed?

It reverts to node's default PKCS#1 v1.5. In our measurement the verifier that pinned PSS rejected the signature and a legacy verifier that still accepts PKCS#1 v1.5 accepted it, so the peers who migrated break while the peers who did not carry on.

Is ECDSA affected in the same way?

The dsaEncoding option is. Renaming it moves node from the fixed-width IEEE P-1363 form back to DER, and a JWS or WebCrypto reader refuses the result because it is not 64 bytes. Node-to-node tests will not show this, because both ends default to DER.

Which names should I exclude from member renaming?

For this area: cipher, passphrase, modulusLength, namedCurve, type, format, padding, saltLength, dsaEncoding, publicKeyEncoding and privateKeyEncoding. More generally, exclude any option name you pass a non-default value to, since those are the only ones whose loss changes behaviour.

Does this mean obfuscation is unsafe for cryptographic code?

No. It means one optional transform needs a scoped regular expression. Renaming is off unless you turn it on, and it only touches names your pattern matches, so the fix is configuration rather than a decision about whether to protect the file at all.

Related reading