Hi,

contrib/pgcrypto/scripts/pgp_session_data.py can reject valid RSA keys from
PyCryptodome and fail before producing the regression SQL. PyCryptodome
computes d as the inverse of e modulo lcm(p-1, q-1), whereas the script
requires the stronger condition modulo (p-1)*(q-1).

RFC 8017 section 3.2 requires e*d = 1 modulo lambda(n):
https://www.rfc-editor.org/rfc/rfc8017.html#section-3.2

The attached patch uses lambda(n) = phi(n)/gcd(p-1,q-1) for this check
and corrects the corresponding comments. Other component checks remain
unchanged. This only affects the regression-data generator; it changes no
pgcrypto database code and is not a report of a new runtime vulnerability.

The patch was prepared against master at
3cde90ecc5f85e7c6cb9fba29d473b2f6dd40ee1.

The attached standalone reproduction script generates fresh ephemeral keys
locally. It contains no pre-generated key material and does not print or
save private parameters, generated SQL, or serialized packets.

Validation with that script on Python 3.12.14, PyCryptodome 4.1.0,
pwntools 4.15.0 and OpenSSL 3.5.7:
- 20 freshly generated 2048-bit keys all pass patched validation, actual
  message/key serialization, packet-field/checksum checks, and RSA
  round-trips. The old validator rejects 13 of those same valid keys.
- Phi-inverse compatibility and rejection of invalid components pass.
- OpenSSL independently validates a freshly generated lambda-only key.
- The complete main() path, using that same ephemeral key and real
  dependencies, fails with the original validator and succeeds after the
  patch. main() also succeeds with unmodified random generation.
- git diff --check passes. PostgreSQL and pgcrypto compile and install
  successfully from this base. Database regression execution was attempted
  but could not start a temporary server because this environment blocks
  Unix-domain sockets. The generated SQL has not been executed, and the
  pgcrypto database regression suite is not claimed to pass.

Run the reproduction with an original and a patched copy of the generator:
python3 test_generated_rsa.py /path/to/original.py /path/to/patched.py

It requires the generator's usual PyCryptodome and pwntools dependencies,
plus the openssl command. It uses at most 32 key-generation attempts to find
a distinguishing lambda-only key and prints only a validation summary.

This was originally proposed to IvorySQL; its maintainers asked that this
inherited generator change be sent upstream first:
https://github.com/IvorySQL/IvorySQL/pull/2512

AI assistance was used to prepare the patch, tests, and this message.
The reported validation results are from actual local runs.

Regards,
zmuxuny
From 4a5e57a38b6d3d0d6765f824673d64451958008d Mon Sep 17 00:00:00 2001
From: zmuxuny <[email protected]>
Date: Tue, 20 Oct 2026 21:13:43 -0700
Subject: [PATCH] Validate generated RSA private exponents modulo lambda(n)

PyCryptodome generates d as the inverse of e modulo lcm(p-1, q-1). Checking the 
stronger condition modulo phi(n) therefore rejects otherwise valid generated 
keys and intermittently prevents regeneration of the pgcrypto session-key 
regression data.

Use the condition from RFC 8017 section 3.2, keeping the other component checks 
unchanged. This only changes validation in the test-data generator, not the 
pgcrypto runtime.
---
 contrib/pgcrypto/scripts/pgp_session_data.py | 14 ++++++++------
 1 file changed, 8 insertions(+), 6 deletions(-)

diff --git a/contrib/pgcrypto/scripts/pgp_session_data.py 
b/contrib/pgcrypto/scripts/pgp_session_data.py
index 999350bb..0d41c527 100644
--- a/contrib/pgcrypto/scripts/pgp_session_data.py
+++ b/contrib/pgcrypto/scripts/pgp_session_data.py
@@ -39,7 +39,7 @@ def generate_rsa_keypair(key_size: int = 2048) -> dict:
     The generated key includes all components needed for PGP operations:
     - n: public modulus (p * q)
     - e: public exponent (typically 65537)
-    - d: private exponent (e^-1 mod phi(n))
+    - d: private exponent (e^-1 mod lcm(p-1, q-1))
     - p, q: prime factors of n
     - u: coefficient (p^-1 mod q) for CRT optimization
 
@@ -57,7 +57,7 @@ def generate_rsa_keypair(key_size: int = 2048) -> dict:
     rsa_components = {
         'n': key.n,      # Public modulus (p * q)
         'e': key.e,      # Public exponent (typically 65537)
-        'd': key.d,      # Private exponent (e^-1 mod phi(n))
+        'd': key.d,      # Private exponent (e^-1 mod lcm(p-1, q-1))
         'p': key.p,      # First prime factor
         'q': key.q,      # Second prime factor
         'u': inverse(key.p, key.q)  # Coefficient for CRT: p^-1 mod q
@@ -78,7 +78,7 @@ def validate_rsa_key(rsa: dict) -> None:
     Validations performed:
     1. n = p * q (modulus is product of primes)
     2. gcd(e, phi(n)) = 1 (public exponent is coprime to phi(n))
-    3. (d * e) mod(phi(n)) = 1 (private exponent is multiplicative inverse)
+    3. (d * e) mod(lcm(p-1, q-1)) = 1 (private exponent is inverse)
     4. (u * p) (mod q) = 1 (coefficient is correct for CRT)
     """
 
@@ -104,9 +104,11 @@ def validate_rsa_key(rsa: dict) -> None:
     if gcd(e, phi_n) != 1:
         raise ValueError("RSA validation failed: gcd(e, phi(n)) <> 1")
 
-    # Check that (d * e) mod(phi(n)) = 1
-    if (d * e) % phi_n != 1:
-        raise ValueError("RSA validation failed: d * e <> 1 (mod phi(n))")
+    # RFC 8017, Section 3.2 requires the inverse modulo lambda(n), not
+    # phi(n).  PyCryptodome also generates d using this least common multiple.
+    lambda_n = phi_n // gcd(p - 1, q - 1)
+    if (d * e) % lambda_n != 1:
+        raise ValueError("RSA validation failed: d * e <> 1 (mod lambda(n))")
 
     # Check that (u * p) (mod q) = 1
     if (u * p) % q != 1:
-- 
2.52.0

#!/usr/bin/env python3
"""Compare original and patched pgcrypto generators using only ephemeral keys.

Requires PyCryptodome, pwntools, and openssl. Run:
  python3 test_generated_rsa.py /path/to/original.py /path/to/patched.py

All keys are freshly generated in this process. No key parameters, generated
SQL, PEM data, or serialized packets are written to files or printed. There
are no embedded keys, fixtures, or pre-generated private parameters.
"""
from contextlib import redirect_stderr, redirect_stdout
import importlib.util
import io
import math
import subprocess
import sys
from unittest.mock import patch
from Crypto.PublicKey import RSA


def require(condition, description):
    if not condition:
        raise AssertionError(description)


def load(path, name):
    spec = importlib.util.spec_from_file_location(name, path)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def rejected(validator, components, expected_text=None):
    try:
        validator(components)
    except ValueError as error:
        if expected_text:
            require(expected_text in str(error), 'Unexpected validation error')
        return True
    return False


def check_packet(module, components):
    with redirect_stderr(io.StringIO()):
        message = module.build_message_data(components)
        packet = module.build_key_data(components)
    require(bool(message), 'Missing generated message')
    require(packet[0] == 0xC7 and 192 <= packet[1] <= 223,
            'Unexpected secret-subkey packet header')
    length = ((packet[1] - 192) << 8) + packet[2] + 192
    body = packet[3:]
    require(len(body) == length and body[0] == 4 and body[5] == 2,
            'Unexpected packet metadata')
    pos = 6
    for field in ('n', 'e', 'd', 'p', 'q', 'u'):
        if field == 'd':
            require(body[pos] == 0, 'Unexpected secret-material encoding')
            pos += 1
            private_start = pos
        bits = int.from_bytes(body[pos:pos + 2], 'big')
        pos += 2
        size = (bits + 7) // 8
        value = int.from_bytes(body[pos:pos + size], 'big')
        pos += size
        require(value == components[field] and bits == value.bit_length(),
                'Serialized MPI differs from generated component')
    require(len(body) == pos + 2, 'Unexpected packet size')
    require(int.from_bytes(body[pos:], 'big') ==
            sum(body[private_start:pos]) & 0xffff, 'Invalid packet checksum')


def main():
    if len(sys.argv) != 3:
        raise SystemExit('Pass the original and patched generator paths')
    original = load(sys.argv[1], 'original_generator')
    fixed = load(sys.argv[2], 'fixed_generator')
    keys = [fixed.generate_rsa_keypair() for _ in range(20)]
    candidate = None
    baseline_rejections = 0
    for components in keys:
        phi = (components['p'] - 1) * (components['q'] - 1)
        only_lambda = components['d'] * components['e'] % phi != 1
        require(rejected(original.validate_rsa_key, components,
                         'mod phi(n)') == only_lambda,
                'Baseline did not distinguish phi and lambda conditions')
        baseline_rejections += only_lambda
        if only_lambda:
            candidate = components
        check_packet(fixed, components)
        for message in (0, 1, 42, components['p'], components['q'],
                        components['n'] - 1):
            encrypted = pow(message, components['e'], components['n'])
            require(pow(encrypted, components['d'], components['n']) == message,
                    'RSA round-trip failed')
    print('Fresh 2048-bit keys: 20/20 validated, serialized, and round-tripped')
    print('Baseline rejected %d/20 of the same valid keys' % 
baseline_rejections)

    # Bound the unlikely case where the initial sample has no lambda-only key.
    for _ in range(12):
        if candidate is not None:
            break
        components = fixed.generate_rsa_keypair()
        phi = (components['p'] - 1) * (components['q'] - 1)
        if components['d'] * components['e'] % phi != 1:
            candidate = components
    require(candidate is not None, 'No distinguishing key found in 32 attempts')
    components = candidate
    phi = (components['p'] - 1) * (components['q'] - 1)
    require(components['d'] * components['e'] %
            math.lcm(components['p'] - 1, components['q'] - 1) == 1,
            'Generated key does not satisfy the lambda condition')
    phi_inverse = dict(components, d=pow(components['e'], -1, phi))
    for module in (original, fixed):
        module.validate_rsa_key(phi_inverse)
        for field in ('n', 'u'):
            changed = dict(phi_inverse)
            changed[field] += 1
            require(rejected(module.validate_rsa_key, changed,
                             'n <> p * q' if field == 'n' else 'u * p'),
                    'Invalid component was accepted')
        require(rejected(module.validate_rsa_key,
                         dict(phi_inverse, d=phi_inverse['d'] + 1), 'd * e'),
                'Invalid private exponent was accepted')
        require(rejected(module.validate_rsa_key,
                         dict(phi_inverse, e=components['p'] - 1), 'gcd'),
                'Non-coprime public exponent was accepted')
        require(rejected(module.validate_rsa_key,
                         dict(phi_inverse, q=components['p'],
                              n=components['p'] ** 2), 'p = q'),
                'Equal prime factors were accepted')
    print('Phi-inverse compatibility and invalid-component checks: PASS')

    key = RSA.construct(tuple(components[k] for k in ('n', 'e', 'd', 'p', 'q', 
'u')))
    result = subprocess.run(['openssl', 'rsa', '-check', '-noout'],
                            input=key.export_key(), capture_output=True,
                            timeout=15)
    require(result.returncode == 0 and b'RSA key ok' in result.stdout,
            'OpenSSL validation failed')
    print('OpenSSL independently validated a freshly generated key: PASS')
    for module, should_pass in ((original, False), (fixed, True)):
        stdout, stderr = io.StringIO(), io.StringIO()
        with patch.object(module.RSA, 'generate', return_value=key):
            try:
                with redirect_stdout(stdout), redirect_stderr(stderr):
                    module.main()
            except ValueError as error:
                require(not should_pass and 'mod phi(n)' in str(error),
                        'Unexpected full-generator failure')
                require(not stdout.getvalue(), 'Baseline emitted unexpected 
SQL')
            else:
                require(should_pass, 'Baseline unexpectedly accepted this key')
                require('SELECT pgp_pub_decrypt_bytea(' in stdout.getvalue(),
                        'Patched main did not generate SQL')
    print('Same ephemeral lambda-only key: original main rejects; patched main 
passes')
    stdout, stderr = io.StringIO(), io.StringIO()
    with redirect_stdout(stdout), redirect_stderr(stderr):
        fixed.main()
    require('SELECT pgp_pub_decrypt_bytea(' in stdout.getvalue(),
            'Random generation did not produce SQL')
    print('Patched main with unmodified random generation: PASS')
    print('No private parameters or generated SQL were persisted or printed')


if __name__ == '__main__':
    main()

Reply via email to