js-nodeforge-oaep-mgf1-sha1-desync
Root cause. The service selected SHA-256 for the OAEP content hash but left MGF1 pinned to its SHA-1 default. node-forge used the same desynced options for both encrypt and decrypt, so in-process round-trips succeeded — the trap. Go (crypto/rsa) and Python (cryptography) derive MGF1 from the OAEP hash and cannot express a SHA-256-OAEP / SHA-1-MGF1 hybrid, so they rejected the ciphertexts with decryption error.
The fix (~/oaep-fix/src/encryption-service.js):
const forge = require('node-forge');
const OAEP_HASH = 'sha256';
const MGF1_HASH = OAEP_HASH; // FIX: was 'sha1' -> desynced from OAEP
function createHash(name) { /* forge.md[name].create() with whitelist */ }
function oaepOptions(label) {
return {
label: label || undefined,
md: createHash(OAEP_HASH), // OAEP content hash
mgf1: { md: createHash(MGF1_HASH) }, // MGF1 now explicitly = OAEP hash
};
}
// encrypt / decrypt both use oaepOptions() so the two digests can never
// silently diverge again:
function encrypt(publicKeyPem, plaintext, label) {
const pub = forge.pki.publicKeyFromPem(publicKeyPem);
const cipher = pub.encrypt(forge.util.encodeUtf8(plaintext), 'RSA-OAEP', oaepOptions(label));
return forge.util.encode64(cipher);
}
function decrypt(privateKeyPem, ciphertextB64, label) {
const priv = forge.pki.privateKeyFromPem(privateKeyPem);
const bytes = priv.decrypt(forge.util.decode64(ciphertextB64), 'RSA-OAEP', oaepOptions(label));
return forge.util.decodeUtf8(bytes);
}
Regression tests (test/cross-language.test.js, Go harness at go/oaepcli/main.go using rsa.DecryptOAEP(sha256.New(), …) / rsa.EncryptOAEP(sha256.New(), …)):
rsa.DecryptOAEP with SHA-256 decrypts and recovers the exact plaintext.EncryptOAEP encrypts → service decrypts.test/fixtures/known-answer.json; Go decrypts it to the known message, and the service reproduces the identical deterministic ciphertext.EncryptOAEP (fixed seed) ciphertext hardcoded in the fixture; service decrypts it to the known message.cryptography, SHA-256 for both): both directions, when available.Verified by running `npm test` (builds the Go helper, then runs `node --test`): ``` ok 1 - in-process sanity: service encrypt/decrypt round trip (fixed config) ok 2 - Node service encrypts -> Go rsa.DecryptOAEP(SHA-256) decrypts ok 3 - Go rsa.EncryptOAEP(SHA-256) encrypts -> Node service decrypts (reverse) ok 4 - KAV (Node -> Go): fixed-seed node-forge ciphertext decrypts via Go to known message ok 5 - KAV (Go -> Node): deterministic Go ciphertext decrypts via service to known message ok 6 - regression: the OLD buggy config (MGF1=SHA-1) is rejected by Go ok 7 - Python cryptography client interop (both directions, SHA-256 OAEP+MGF1) # tests 7 # pass 7 # fail 0 ``` **Negative control** — temporarily reverting to `MGF1_HASH = 'sha1'` makes the suite fail exactly where it should (4 failures: tests 2, 3, 5, 6), while the in-process test still passes (the trap). Restored the fix; all green again. `go vet ./...` is clean; a from-scratch rebuild (`go build -o test/.bin/oaepcli ./go/oaepcli`) succeeds. Edge cases tested: empty message + empty label, non-ASCII/unicode (emoji, CJK), non-empty OAEP labels, deterministic seeds on both the Node and Go sides, and the desynced-ciphertext rejection path. Project: `~/oaep-fix` (`src/encryption-service.js`, `go/oaepcli/main.go`, `test/cross-language.test.js`, `test/fixtures/known-answer.json` + RSA-2048 PEMs, `scripts/gen-fixtures.js`, `scripts/gen-go-kav.sh`, `README.md`).
{"model": "deepseek-v4-flash", "problem_class": "js-nodeforge-oaep-mgf1-sha1-desync", "result": "passed", "tests": 7}Root cause. The service selected SHA-256 for the OAEP content hash but left MGF1 pinned to its SHA-1 default. node-forge used the same desynced options for both encrypt and decrypt, so in-process round-trips succeeded — the trap. Go (crypto/rsa) and Python (cryptography) derive MGF1 from the OAEP hash and cannot express a SHA-256-OAEP / SHA-1-MGF1 hybrid, so they rejected the ciphertexts with decryption error.
The fix (~/oaep-fix/src/encryption-service.js):
const forge = require('node-forge');
const OAEP_HASH = 'sha256';
const MGF1_HASH = OAEP_HASH; // FIX: was 'sha1' -> desynced from OAEP
function createHash(name) { /* forge.md[name].create() with whitelist */ }
function oaepOptions(label) {
return {
label: label || undefined,
md: createHash(OAEP_HASH), // OAEP content hash
mgf1: { md: createHash(MGF1_HASH) }, // MGF1 now explicitly = OAEP hash
};
}
// encrypt / decrypt both use oaepOptions() so the two digests can never
// silently diverge again:
function encrypt(publicKeyPem, plaintext, label) {
const pub = forge.pki.publicKeyFromPem(publicKeyPem);
const cipher = pub.encrypt(forge.util.encodeUtf8(plaintext), 'RSA-OAEP', oaepOptions(label));
return forge.util.encode64(cipher);
}
function decrypt(privateKeyPem, ciphertextB64, label) {
const priv = forge.pki.privateKeyFromPem(privateKeyPem);
const bytes = priv.decrypt(forge.util.decode64(ciphertextB64), 'RSA-OAEP', oaepOptions(label));
return forge.util.decodeUtf8(bytes);
}
Regression tests (test/cross-language.test.js, Go harness at go/oaepcli/main.go using rsa.DecryptOAEP(sha256.New(), …) / rsa.EncryptOAEP(sha256.New(), …)):
rsa.DecryptOAEP with SHA-256 decrypts and recovers the exact plaintext.EncryptOAEP encrypts → service decrypts.test/fixtures/known-answer.json; Go decrypts it to the known message, and the service reproduces the identical deterministic ciphertext.EncryptOAEP (fixed seed) ciphertext hardcoded in the fixture; service decrypts it to the known message.cryptography, SHA-256 for both): both directions, when available.Verified by running `npm test` (builds the Go helper, then runs `node --test`): ``` ok 1 - in-process sanity: service encrypt/decrypt round trip (fixed config) ok 2 - Node service encrypts -> Go rsa.DecryptOAEP(SHA-256) decrypts ok 3 - Go rsa.EncryptOAEP(SHA-256) encrypts -> Node service decrypts (reverse) ok 4 - KAV (Node -> Go): fixed-seed node-forge ciphertext decrypts via Go to known message ok 5 - KAV (Go -> Node): deterministic Go ciphertext decrypts via service to known message ok 6 - regression: the OLD buggy config (MGF1=SHA-1) is rejected by Go ok 7 - Python cryptography client interop (both directions, SHA-256 OAEP+MGF1) # tests 7 # pass 7 # fail 0 ``` **Negative control** — temporarily reverting to `MGF1_HASH = 'sha1'` makes the suite fail exactly where it should (4 failures: tests 2, 3, 5, 6), while the in-process test still passes (the trap). Restored the fix; all green again. `go vet ./...` is clean; a from-scratch rebuild (`go build -o test/.bin/oaepcli ./go/oaepcli`) succeeds. Edge cases tested: empty message + empty label, non-ASCII/unicode (emoji, CJK), non-empty OAEP labels, deterministic seeds on both the Node and Go sides, and the desynced-ciphertext rejection path. Project: `~/oaep-fix` (`src/encryption-service.js`, `go/oaepcli/main.go`, `test/cross-language.test.js`, `test/fixtures/known-answer.json` + RSA-2048 PEMs, `scripts/gen-fixtures.js`, `scripts/gen-go-kav.sh`, `README.md`).
{"model": "deepseek-v4-flash", "problem_class": "js-nodeforge-oaep-mgf1-sha1-desync", "result": "passed", "tests": 7}