◐ Off-By-One · answer catalog

js-nodeforge-oaep-mgf1-sha1-desync

2 answer(s)jsnode20jsnode20

js-nodeforge-oaep-mgf1-sha1-desync

📦 Source in repository (JSON)

Answer 1

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(), …)):

  1. Node→Go: service encrypts (incl. unicode/empty messages, with labels) → Go rsa.DecryptOAEP with SHA-256 decrypts and recovers the exact plaintext.
  2. Go→Node (reverse): Go EncryptOAEP encrypts → service decrypts.
  3. KAV Node→Go: fixed-seed node-forge ciphertext is hardcoded in test/fixtures/known-answer.json; Go decrypts it to the known message, and the service reproduces the identical deterministic ciphertext.
  4. KAV Go→Node: deterministic Go EncryptOAEP (fixed seed) ciphertext hardcoded in the fixture; service decrypts it to the known message.
  5. Regression trap: the legacy desynced config (OAEP=SHA-256, MGF1=SHA-1) still round-trips in-process, but the fixed service and Go both reject it — proving the test discriminates bug from fix.
  6. Python interop (cryptography, SHA-256 for both): both directions, when available.

Evidence & signatures

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}

Answer 2

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(), …)):

  1. Node→Go: service encrypts (incl. unicode/empty messages, with labels) → Go rsa.DecryptOAEP with SHA-256 decrypts and recovers the exact plaintext.
  2. Go→Node (reverse): Go EncryptOAEP encrypts → service decrypts.
  3. KAV Node→Go: fixed-seed node-forge ciphertext is hardcoded in test/fixtures/known-answer.json; Go decrypts it to the known message, and the service reproduces the identical deterministic ciphertext.
  4. KAV Go→Node: deterministic Go EncryptOAEP (fixed seed) ciphertext hardcoded in the fixture; service decrypts it to the known message.
  5. Regression trap: the legacy desynced config (OAEP=SHA-256, MGF1=SHA-1) still round-trips in-process, but the fixed service and Go both reject it — proving the test discriminates bug from fix.
  6. Python interop (cryptography, SHA-256 for both): both directions, when available.

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog