Skip to content

Add a dedicated static OpenSSL for packages that embed it - #420

Merged
smatula merged 3 commits into
calungaproject:mainfrom
smatula:builder-openssl-static-for-cryptography
Sep 17, 2026
Merged

smatula merged 3 commits into
calungaproject:mainfrom
smatula:builder-openssl-static-for-cryptography

Conversation

@smatula

@smatula smatula commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Gives cryptography wheels the same shape as the official PyPI wheels and restores six ciphers that are currently unusable. The shared OpenSSL that CPython, curl and Arrow link against stays on the 3.x line and keeps its own pin.

Change:

  • build-openssl-static.sh: new, builds OpenSSL no-shared no-module -fPIC into /opt/_internal/static-openssl-4.0 plus a version-independent static-openssl symlink
  • Containerfile: run it in build_cpython alongside the shared build, as a separate RUN with its own version and hash, and propagate the prefix into the final image
  • finalize.sh: exempt that prefix from the blanket *.a purge, and assert in the final stage that the archive survived with its legacy provider intact

Why:
Linked dynamically, cryptography gets libcrypto and libssl bundled into the wheel by auditwheel, but not ossl-modules/legacy.so -- OpenSSL dlopens that, and auditwheel only follows DT_NEEDED. The bundled libcrypto's MODULESDIR is also compiled to a builder path that does not exist at wheel runtime. OSSL_PROVIDER_load(NULL, "legacy") fails at import and Blowfish, CAST5, IDEA, SEED, ARC4 and RC2 all raise UnsupportedAlgorithm. Since cryptography 50.x that failure is only a warning, so the import succeeds, wheel-check passes and the broken wheel ships.

Upstream avoids this by embedding OpenSSL in _rust.abi3.so instead. This provides an OpenSSL that can be embedded the same way. Consuming it is opt-in from the index side, via OPENSSL_DIR and OPENSSL_STATIC in overrides/settings/cryptography.yaml.

Measured. cryptography 50.0.1 built from sdist, repaired with auditwheel, exercised on python:3.12-slim:

                    _rust.abi3.so   bundled libs   legacy ciphers
index today          6.32 MB        libcrypto,     1 of 7
                                    libssl
this change         13.66 MB        none           7 of 7
PyPI wheel          14.39 MB        none           7 of 7

the 1 that always works is TripleDES, which is in the default provider
DT_NEEDED on _rust.abi3.so is identical to the PyPI wheel's: libgcc_s,
libpthread, libdl, libc, ld-linux

Taken against a 3.5.4 static build, before the pin split below. What is
being measured is the mechanism, which does not change with the version;
the exact MB on 4.0.2 will differ slightly.

Two OpenSSL pins, not one:

An earlier revision moved both builds to 4.0.2. CI rejected it. OpenSSL 4.0 made struct asn1_string_st opaque and CPython 3.12's Modules/_ssl.c still reaches into it directly, so 3.12.12 fails to compile with eight "incomplete definition of type 'ASN1_OCTET_STRING'" errors across lines 1342-1548. There is no flag for it; the shared build cannot move to 4.x until CPython goes through ASN1_STRING_get0_data() upstream.

So the two builds get separate pins, which they can have because they already had separate prefixes and separate consumers:

  • shared, /opt/_internal/openssl-3.6, at 3.6.4 -- CPython, curl, Arrow, and anything auditwheel bundles. Up from 3.5.4.
  • static, /opt/_internal/static-openssl-4.0, at 4.0.2 -- cryptography only, opt-in.

This does close the version gap for the static build: PyPI's cryptography 50.0.1 ships OpenSSL 4.0.2 and so do we now. The shared build being on 3.x costs nothing, because there is no single "PyPI OpenSSL version" to match -- every project builds its own. pypa/manylinux pins 3.5.8 in its own Dockerfile, which is what cmake embeds; snowflake-connector-python 5.0.0b2 vendors 3.6.2 via the openssl-src crate. 3.6.4 is at or above both.

Isolation checks, on a full image build of this branch: the two prefixes do not overlap, the static prefix is absent from PKG_CONFIG_PATH and from the ldconfig cache, the shared build still ships its ossl-modules/legacy.so, and no wheel that does not set OPENSSL_DIR can reach the static one. The shared libcrypto.so.3 and libssl.so.3 are no longer byte-identical to main, because the shared pin moves 3.5.4 -> 3.6.4 -- a deliberate part of this change rather than a side effect of the static build.

Notes:

  • no-shared does NOT imply no-module. Without no-module the providers stay separate .so files that a static link cannot reach, and the resulting wheel is broken in exactly the same way -- libcrypto.a then contains no ossl_legacy_provider_init at all.
  • The prefix is named so it does not match the /opt/_internal/openssl* globs the final stage uses to publish the shared build into /usr/local. If it matched, its pkgconfig files would be symlinked over the shared ones and every package would silently switch to the static build.
  • Adds 2.3 MB of headers plus the two archives to the builder image -- about 17 MB total on the 3.5.4 measurement, to be reconfirmed on 4.0.2 -- and nothing to any wheel that does not opt in. The 7.8 MB openssl CLI that install_sw also lays down is removed; rebuilding cryptography without it gave the same extension module, no bundled libs and 7 of 7 ciphers, so nothing reads it.

On the finalize.sh change, because it is the least obvious part:

The first full image build of this branch produced a static-openssl prefix with headers, .pc files and an empty lib/ -- and no libcrypto.a anywhere in the image. finalize.sh drops every archive under /opt/_internal:

find /opt/_internal -name '*.a' -print0 | xargs -0 rm -f

That line is original to the builder (044d5ad) and is aimed at libpython*.a, which the lines above it tar up first. It is unscoped, and the Containerfile invokes it in the same RUN that copies static-openssl* in, one instruction later. Nothing before this branch needed an .a to survive into the final image, so nothing noticed. What is left behind looks populated, so cryptography would have failed to link at re-onboarding time rather than here.

build-openssl-static.sh already asserted ossl_legacy_provider_init was present, and that assertion passed -- it runs in build_cpython, two stages earlier, and cannot see what happens to the archive afterwards. A build-stage check on an artifact that ships from the final stage cannot do the job alone, so the same assertion now also runs on the finished filesystem.

Validated by a full image build, which is what found both of the problems above; the earlier in-image rebuild of OpenSSL alone would not have caught either.

Assisted-by: Claude Opus 5 noreply@anthropic.com

@sourcery-ai

sourcery-ai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR adds an isolated, opt-in static OpenSSL installation for packages such as cryptography, compiling providers—including legacy algorithms—into libcrypto.a so embedded wheels remain self-contained and auditwheel-independent, while keeping the existing shared OpenSSL installation synchronized and unaffected.

Sequence diagram for building an embedded cryptography wheel

sequenceDiagram
    participant Build as Builder image
    participant Static as build-openssl-static.sh
    participant Crypto as cryptography build
    participant Wheel as Repaired wheel
    participant Runtime as Python runtime

    Build->>Static: Configure no-shared no-module -fPIC
    Static->>Static: make install_sw
    Static->>Static: Check ossl_legacy_provider_init in libcrypto.a
    Static-->>Build: /opt/_internal/static-openssl
    Build->>Crypto: Set OPENSSL_DIR and OPENSSL_STATIC
    Crypto->>Crypto: Link static libcrypto.a into _rust.abi3.so
    Crypto-->>Wheel: Self-contained extension without bundled libcrypto/libssl
    Wheel->>Runtime: OSSL_PROVIDER_load(NULL, legacy)
    Runtime-->>Wheel: Legacy ciphers available
Loading

File-Level Changes

Change Details Files
Build and install a dedicated static OpenSSL alongside the existing shared build for explicitly embedded consumers.
  • Add a no-shared, no-module, -fPIC OpenSSL build with the legacy provider compiled into libcrypto.a.
  • Reuse the shared build’s pinned version, download URL, and checksum, and assert the legacy provider symbol is present.
  • Install under an isolated versioned prefix with a stable symlink, remove the unused CLI, and copy the prefix into the final image without exposing it through shared OpenSSL publication paths.
builder/build_scripts/build-openssl-static.sh
builder/Containerfile
Keep shared and static OpenSSL artifacts synchronized while preserving the shared installation’s behavior.
  • Run both OpenSSL builds in the same builder layer and update the pinned release and checksum.
  • Propagate static-openssl artifacts separately during final image assembly so existing CPython, curl, Arrow, and shared-library consumers continue using the shared build.
builder/Containerfile
Update the documented OpenSSL release used by the builder.
  • Change the library version strategy entry from OpenSSL 3.5.4 to 4.0.2.
builder/LIBRARY_VERSION_STRATEGY.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@jvulgan

jvulgan commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

LGTM

@smatula
smatula force-pushed the builder-openssl-static-for-cryptography branch 2 times, most recently from f948576 to 4ff9962 Compare September 15, 2026 20:30
Gives cryptography wheels the same shape as the official PyPI wheels and
restores six ciphers that are currently unusable, without changing the
shared OpenSSL that CPython, curl and Arrow link against.

Change:
- build-openssl-static.sh: new, builds OpenSSL no-shared no-module -fPIC
  into /opt/_internal/static-openssl-3.5 plus a version-independent
  static-openssl symlink
- Containerfile: run it in build_cpython from the same RUN as the shared
  build, so the two cannot drift apart on version or hash, and propagate
  the prefix into the final image

Why:
Linked dynamically, cryptography gets libcrypto and libssl bundled into
the wheel by auditwheel, but not ossl-modules/legacy.so -- OpenSSL
dlopens that, and auditwheel only follows DT_NEEDED. The bundled
libcrypto's MODULESDIR is also compiled to a builder path that does not
exist at wheel runtime. OSSL_PROVIDER_load(NULL, "legacy") fails at
import and Blowfish, CAST5, IDEA, SEED, ARC4 and RC2 all raise
UnsupportedAlgorithm. Since cryptography 50.x that failure is only a
warning, so the import succeeds, wheel-check passes and the broken wheel
ships.

Upstream avoids this by embedding OpenSSL in _rust.abi3.so instead.
This provides an OpenSSL that can be embedded the same way. Consuming it
is opt-in from the index side, via OPENSSL_DIR and OPENSSL_STATIC in
overrides/settings/cryptography.yaml.

Measured. cryptography 50.0.1 built from sdist, repaired with auditwheel,
exercised on python:3.12-slim:

                        _rust.abi3.so   bundled libs   legacy ciphers
    index today          6.32 MB        libcrypto,     1 of 7
                                        libssl
    this change         13.66 MB        none           7 of 7
    PyPI wheel          14.39 MB        none           7 of 7

  the 1 that always works is TripleDES, which is in the default provider
  DT_NEEDED on _rust.abi3.so is identical to the PyPI wheel's: libgcc_s,
  libpthread, libdl, libc, ld-linux

Isolation checks on the same image: the shared libcrypto.so.3 and
libssl.so.3 are byte-identical to before, its ossl-modules/legacy.so is
still in place, the static prefix is absent from PKG_CONFIG_PATH and from
the ldconfig cache, and builder CPython still does TLS 1.3 to pypi.org
with curl returning 200. No other wheel is affected.

Notes:
- no-shared does NOT imply no-module. Without no-module the providers
  stay separate .so files that a static link cannot reach, and the
  resulting wheel is broken in exactly the same way -- libcrypto.a then
  contains no ossl_legacy_provider_init at all. The script asserts that
  symbol is present so this cannot regress silently.
- The prefix is named so it does not match the /opt/_internal/openssl*
  globs the final stage uses to publish the shared build into
  /usr/local. If it matched, its pkgconfig files would be symlinked over
  the shared ones and every package would silently switch to the static
  build.
- Adds 17 MB to the builder image (14 MB archives, 2.4 MB headers) and
  nothing to any wheel that does not opt in. The 7.8 MB openssl CLI that
  install_sw also lays down is removed; rebuilding cryptography without
  it gave the same 13.66 MB extension module, no bundled libs and 7 of 7
  ciphers, so nothing reads it.
- Does not close the OpenSSL version gap: the builder is on 3.5.4 while
  PyPI's cryptography 50.0.1 ships 4.0.2. Separate change.

Validated by rebuilding OpenSSL inside the existing builder image and
committing it, not by a full image build, so the compiler was in-image
gcc rather than the static_clang cross-compiler. The PR's own image build
is the real confirmation.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
@smatula
smatula force-pushed the builder-openssl-static-for-cryptography branch from 4ff9962 to d44e958 Compare September 16, 2026 12:21
@smatula
smatula marked this pull request as ready for review September 16, 2026 12:22

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!

Sourcery assessment

Needs a human reviewer. This changes the OpenSSL code embedded in published wheels and changes which legacy crypto providers are available, so a build or version mistake could cause cryptographic failures or security-relevant behavior in installed packages. Reverting would stop future builds but would not remove already published wheels; those would require replacement or yanking.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

@smatula
smatula force-pushed the builder-openssl-static-for-cryptography branch from d44e958 to 0ced5cf Compare September 16, 2026 14:21
Comment thread builder/LIBRARY_VERSION_STRATEGY.md Outdated
Parity review compares the OpenSSL version embedded in our wheels against
the PyPI ones, and 3.5.4 (30 Sep 2025) is roughly eleven months behind.
cryptography 50.0.1 on PyPI statically links OpenSSL 4.0.2, read out of
the shipped binary:

    $ strings -a cryptography/hazmat/bindings/_rust.abi3.so \
        | grep -E "^OpenSSL [0-9]"
    OpenSSL 4.0.2 25 Aug 2026

An earlier revision of this commit moved both builds to 4.0.2 on the
assumption that nothing in this stage depended on what 4.0 removed. That
was wrong, and CI proved it: OpenSSL 4.0 made struct asn1_string_st
opaque, and CPython 3.12's Modules/_ssl.c still reaches into it.

    ./Modules/_ssl.c:1342:31: error: incomplete definition of type
        'ASN1_OCTET_STRING' (aka 'struct asn1_string_st')
     1342 |     if (name->d.ip->length == 4) {
    6 warnings and 8 errors generated.
    make: *** [Makefile:3134: Modules/_ssl.o] Error 1

Eight errors across lines 1342-1548, all direct ->data and ->length
accesses. There is no flag for it; the shared build cannot move to 4.x
until CPython goes through ASN1_STRING_get0_data() upstream. The check
that missed this looked for ENGINE_ references and the
PY_PROTO_MINIMUM_AVAILABLE guard and found both clear, but never looked
at struct member access.

So the two builds get separate pins. They already had separate prefixes
and separate consumers; only the version was shared.

The static build is reachable only by a package that sets OPENSSL_DIR and
OPENSSL_STATIC, which today means cryptography alone, and that path does
not go through CPython's _ssl. It tracks 4.x. The failing run got past
both OpenSSL builds before dying in CPython, so 4.0.2 with no-shared
no-module is already known to compile and to keep ossl_legacy_provider_init
in libcrypto.a -- the static build's assertion passed on the real
cross-compiler.

The shared build tracks 3.x, at 3.6.4, the newest release on that line
and published the same day as 4.0.2. Being behind 4.x costs nothing here,
because PyPI has no single OpenSSL version to match: every project builds
its own. pypa/manylinux pins 3.5.8 in its own Dockerfile, which is what
cmake 4.4.3 embeds; snowflake-connector-python 5.0.0b2 vendors 3.6.2 via
the openssl-src crate; cryptography ships its own image built on 4.0.2.
3.6.4 is at or above every consumer of the shared build:

    package    ours (3.6.4)   PyPI
    cmake      3.6.4          3.5.8    ahead
    snowflake  3.6.4          3.6.2    ahead

Both tarball hashes were taken from the release artifacts and
cross-checked against the .sha256 published beside them.

The shared prefix moves from /opt/_internal/openssl-3.5 to openssl-3.6
and the static one lands at static-openssl-4.0. The final stage globs
openssl* and static-openssl* separately, so the two stay isolated, and
the version-independent static-openssl symlink means consumers setting
OPENSSL_DIR need no change.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
@smatula
smatula force-pushed the builder-openssl-static-for-cryptography branch from 0ced5cf to e0ad777 Compare September 16, 2026 18:50
The static OpenSSL was building correctly and then being deleted before
it reached the final image. Pulled from the PR build of 0ced5cf:

    /opt/_internal/static-openssl -> /opt/_internal/static-openssl-4.0
    /opt/_internal/static-openssl-4.0/include/openssl/*.h   present
    /opt/_internal/static-openssl-4.0/lib/
      cmake/  ossl-modules/  pkgconfig/                     present
                                                            no libcrypto.a
                                                            no libssl.a

    $ find /opt/_internal -name '*.a' | wc -l
    0

finalize.sh drops every archive under /opt/_internal:

    find /opt/_internal -name '*.a' -print0 | xargs -0 rm -f

That line is original to the builder (044d5ad) and is aimed at
libpython*.a, which the lines above it tar up first. It is unscoped, and
the Containerfile invokes it in the same RUN that copies static-openssl*
in, one instruction later, so the archives are deleted a few seconds
after they arrive. Nothing before this branch needed an .a to survive
into the final image, so nothing noticed.

What is left behind is worse than an empty directory: the headers, the
.pc files and lib/ itself all survive, so the prefix looks populated.
cryptography would set OPENSSL_DIR and OPENSSL_STATIC, openssl-sys would
go looking for lib/libcrypto.a, and the failure would land on whoever
re-onboarded it rather than here.

So exempt that one prefix. Everything else still goes, including the
shared openssl-3.x archives and libpython*.a; verified in the image by
seeding the pre-purge state and running the new predicate.

The second half of this is the check. build-openssl-static.sh already
asserts ossl_legacy_provider_init is in libcrypto.a, and that assertion
passed on the real cross-compiler in the failing build -- it runs in
build_cpython, two stages before anything here, and cannot see what
happens to the archive afterwards. A build-stage check on an artifact
that ships from the final stage cannot do the job on its own, so the
same assertion now also runs on the finished filesystem. Against the
0ced5cf image it fails on the missing archive, which is what it is for.

Assisted-by: Claude Opus 5 <noreply@anthropic.com>
@smatula
smatula merged commit 4fceea9 into calungaproject:main Sep 17, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants