Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Zero-Copy Patterns

What “Zero-Copy” Means Here

In this crate, zero-copy means no intermediate byte buffer is allocated between the caller’s memory and the OpenSSL C API boundary. It does not mean OpenSSL never copies — OpenSSL always builds its own internal representation of keys, certificates, and algorithm state. The goal is to eliminate unnecessary Rust-side copies before or after the C call.

Tier 1 — Fully Zero-Copy

These APIs pass the caller’s buffer pointer directly to OpenSSL and write back into caller-provided memory with no intermediate allocation.

APIC functionDirection
DigestCtx::update(data)EVP_DigestUpdateread from caller
DigestCtx::finish(out)EVP_DigestFinal_exwrite into caller
DigestCtx::serialize(out) 1EVP_MD_CTX_serializewrite into caller
DigestCtx::deserialize(data) 1EVP_MD_CTX_deserializeread from caller
DigestAlg::digest(data, out)EVP_Digestboth
CipherCtx::update(input, output)EVP_Encrypt/DecryptUpdateboth
CipherCtx::finalize(output)EVP_Encrypt/DecryptFinal_exwrite into caller
AeadEncryptCtx::set_aad(aad)EVP_EncryptUpdate(out=NULL)read only
AeadEncryptCtx::tag(tag)EVP_CIPHER_CTX_ctrl GCM_GET_TAGwrite into caller
AeadDecryptCtx::set_tag(tag)EVP_CIPHER_CTX_ctrl GCM_SET_TAGread from caller
MacCtx::update(data)EVP_MAC_updateread from caller
MacCtx::finish(out)EVP_MAC_finalwrite into caller
Signer::update(data)EVP_DigestSignUpdateread from caller
Verifier::update(data)EVP_DigestVerifyUpdateread from caller
Verifier::verify(sig)EVP_DigestVerifyFinalread from caller
RawSigner::sign(tbs, sig)EVP_PKEY_signboth
RawVerifier::verify(tbs, sig)EVP_PKEY_verifyread from caller
DeriveCtx::derive(out)EVP_PKEY_derivewrite into caller
PkeyEncryptCtx::encrypt(pt, ct)EVP_PKEY_encryptboth
PkeyDecryptCtx::decrypt(ct, pt)EVP_PKEY_decryptboth
Rand::fill(buf)RAND_byteswrite into caller
RandCtx::generate(out, req)EVP_RAND_generatewrite into caller
X509::from_der(der)d2i_X509 via local ptrread from caller
Pkey::from_der(der)d2i_* via MemBioBufread from caller
KdfBuilder::derive(out)EVP_KDF_derivewrite into caller
Params::get_octet_string(key)OSSL_PARAM_get_octet_string_ptrborrow from Params
Params::get_utf8_string(key)OSSL_PARAM_get_utf8_string_ptrborrow from Params

Tier 2 — Unavoidable Copies (in OpenSSL, not Rust)

OpenSSL must own copies of this data for security reasons — key scheduling, zeroization on free, constant-time access. These copies happen inside C and cannot be eliminated without bypassing OpenSSL’s security model.

APIWhat is copiedWhy unavoidable
CipherAlg::encrypt(key, iv)Key + IVOpenSSL schedules and zeroizes
AeadEncryptCtx::new(key, iv)Key + IVSame
MacCtx::init(key)Key → IPAD/OPAD for HMACMAC context owns key schedule
HkdfBuilder::derivekey/salt/info via push_octet_sliceKDF expands material internally
Pbkdf2Builder::derivepassword + saltIteration state
ScryptBuilder::derivepassword + salt + working memoryLarge scratch space
SshkdfBuilder::derivekey/xcghash/session-id via push_octet_sliceKDF builds internal state
KbkdfBuilder::derivekey/label/context via push_octet_sliceKDF builds internal state
X509::from_der / from_pemd2i_X509 builds an ASN.1 treeOpenSSL’s internal representation
Pkey::from_der / from_pemBIGNUM, EC point, etc.Constant-time key management
Pkey::from_paramsall key fieldsEVP_PKEY_fromdata copies into EVP_PKEY
Params::get_bnBIGNUM → Vec<u8>OSSL_PARAM_get_BN allocates a BIGNUM; wrapper converts to bytes

Tier 3 — Allocating Convenience Variants

These variants allocate a Vec<u8> and are explicitly opt-in. A zero-copy primary path always exists alongside them.

Allocating variantZero-copy primaryWhen to prefer allocating
DigestAlg::digest_to_vec(data)digest(data, out)Output size unknown
MacCtx::finish_to_vec()finish(out)MAC size varies
HmacCtx::finish_to_vec()finish(out)Same
Signer::finish()(always allocates)Signature size algorithm-dependent
RawSigner::sign_alloc(tbs)sign(tbs, sig)Signature buffer size not known
DeriveCtx::derive_to_vec()derive(out)Secret length not known
KdfBuilder::derive_to_vec(len)derive(out)No preallocated buffer
Rand::bytes(n)Rand::fill(buf)Dynamic size
X509::to_der()(always allocates)Length query required
Pkey::to_pem()(always allocates)Length query required
Pkey::export()Pkey::get_params(&mut query)Full dump vs. targeted query
Params::get_bn(key)(always allocates)BIGNUM-to-bytes conversion unavoidable

Signer::finish always allocates — the signature length varies by algorithm (RSA-2048 produces 256 bytes; Ed25519 produces 64 bytes; ECDSA produces a variable-length DER blob). The method queries the size, allocates, then writes.

RawSigner::sign is zero-copy — the caller provides a pre-allocated buffer. Use sign_len(tbs_len) to query the required size, then call sign(tbs, &mut buf). sign_alloc is the convenience variant that handles the allocation for you.

Params::get_bn always allocatesOSSL_PARAM_get_BN allocates a new BIGNUM internally. The wrapper converts it to a Vec<u8> and frees the BIGNUM. Use get_octet_string for raw byte fields that do not need BIGNUM semantics; that call is zero-copy.

PEM I/O — MemBio and MemBioBuf

Input (zero-copy read)

MemBioBuf wraps BIO_new_mem_buf, which creates a read-only BIO backed by a pointer into the caller’s slice. No bytes are copied:

#![allow(unused)]
fn main() {
// Internal implementation pattern:
let bio = MemBioBuf::from_slice(pem)?;  // BIO_new_mem_buf — no copy
let cert = unsafe { sys::PEM_read_bio_X509(bio.as_ptr(), ...) };
}

The MemBioBuf borrows the slice; the BIO is freed before the function returns. OpenSSL reads directly from the caller’s memory.

Output (always allocates)

MemBio wraps BIO_s_mem, which maintains an internal growable buffer. After writing PEM/DER, BIO_get_mem_data returns a slice into that buffer. The wrapper copies it into a Vec<u8> before dropping the BIO:

#![allow(unused)]
fn main() {
// Internal implementation pattern:
let bio = MemBio::new()?;
unsafe { sys::PEM_write_bio_X509(bio.as_ptr(), self.ptr) };
let pem = bio.into_vec()?;  // BIO_get_mem_data → Vec::from_slice
}

The bio.data() method exposes the buffer as &[u8] without copying, for callers who only need to write the bytes directly (e.g. to a socket) before dropping the BIO.

ParamBuilder — Copy vs. Pointer (Building)

OSSL_PARAM_BLD has two families of push functions:

MethodC functionCopies?Use when
push_octet_slice(key, val)push_octet_stringYesval may not outlive Params
push_octet_ptr(key, val: &'a [u8])push_octet_ptrNoval lifetime 'a is guaranteed
push_utf8_string(key, val)push_utf8_stringYesShort runtime strings
push_utf8_ptr(key, val: &'static CStr)(copies in practice)No intent'static literals
push_bn(key, bigendian_bytes)push_BN via BN_bin2bnYesBIGNUM fields; caller’s bytes not retained

KDF builders hold &'a [u8] slices and pass them via push_octet_slice at derive() time — the copy is inside OpenSSL, not an extra Rust-side allocation. Cipher and MAC key material is also passed via push_octet_slice since OpenSSL immediately schedules the key.

Params — Zero-Copy vs. Allocating (Reading)

When reading values out of a Params array, allocation behaviour depends on the type:

GetterC functionAllocates?Notes
get_int / get_uint / get_size_t / get_i64 / get_u64OSSL_PARAM_get_*NoScalar read into a local variable
get_octet_stringOSSL_PARAM_get_octet_string_ptrNoReturns &[u8] borrowing into the Params array
get_utf8_stringOSSL_PARAM_get_utf8_string_ptrNoReturns &CStr borrowing into the Params array
get_bnOSSL_PARAM_get_BN + BN_bn2binYesVec<u8>BIGNUM must be converted to bytes

get_octet_string and get_utf8_string use the _ptr variants of the OpenSSL getters, which return a pointer into the existing OSSL_PARAM storage rather than copying the data. The returned reference is tied to the lifetime of &self, so it cannot outlive the Params value.

get_bn cannot be zero-copy because OpenSSL’s OSSL_PARAM_get_BN allocates a new BIGNUM object internally. The Bn helper in params.rs converts this to a Vec<u8> and frees the BIGNUM before returning.

DER Parsing — No Cursor Advancement

The C d2i_* functions advance a const unsigned char ** pointer past the parsed bytes, enabling sequential parsing from one buffer. The Rust wrappers do not expose this cursor API. All from_der methods take &[u8] and consume the full slice. A local pointer is created inside the wrapper and discarded after the call:

#![allow(unused)]
fn main() {
// Internal pattern for X509::from_der:
pub fn from_der(der: &[u8]) -> Result<Self, ErrorStack> {
    let mut ptr = der.as_ptr();
    // SAFETY: ptr is valid for der.len() bytes; X509** is NULL (allocate new)
    let x = unsafe { sys::d2i_X509(std::ptr::null_mut(), &mut ptr, der.len() as i64) };
    if x.is_null() { return Err(ErrorStack::drain()); }
    Ok(X509 { ptr: x })
}
}

The external der reference is not mutated. Use separate calls to from_der with sub-slices if you need to parse multiple sequential DER objects.

cfg Propagation — ossl_v400

Some zero-copy APIs (currently DigestCtx::serialize and DigestCtx::deserialize) are only available when the crate is built against OpenSSL >= 4.0. They are compiled conditionally behind #[cfg(ossl_v400)].

The ossl_v400 flag is propagated through two build scripts:

  1. native-ossl-sys/build.rs — the OsslCallbacks::int_macro hook reads OPENSSL_VERSION_NUMBER. When the value is >= 0x4000_0000, it emits:

    cargo::rustc-cfg=ossl_v400
    cargo::metadata=OSSL400=1
    

    The cargo::metadata line, combined with links = "ssl" in native-ossl-sys/Cargo.toml, causes Cargo to expose the value to dependent crates as the DEP_SSL_OSSL400 environment variable.

  2. native-ossl/build.rs — reads DEP_SSL_OSSL400. If present, emits cargo::rustc-cfg=ossl_v400 for the native-ossl crate. Also unconditionally emits cargo::rustc-check-cfg=cfg(ossl_v400) so the unexpected_cfgs lint is satisfied whether or not OpenSSL 4.0 is installed.

Call sites guarded by #[cfg(ossl_v400)] compile to nothing on OpenSSL 3.x with no stub or runtime check needed.

What Zero-Copy Cannot Cover

Semantic-level copies are unavoidable. When OpenSSL parses a Pkey or X509, it builds its own internal representation (BIGNUMs for RSA, EC points, ASN.1 trees). These are not zero-copy at the semantic level, and they should not be — OpenSSL manages key memory independently for zeroization and constant-time guarantees.

The zero-copy guarantee is at the byte-transfer level: no intermediate byte buffer is allocated on the Rust side before handing data to OpenSSL. What OpenSSL does with that data internally is its own concern.


  1. Only compiled when #[cfg(ossl_v400)] is active (OpenSSL >= 4.0). See cfg propagation below. ↩2