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.
| API | C function | Direction |
|---|---|---|
DigestCtx::update(data) | EVP_DigestUpdate | read from caller |
DigestCtx::finish(out) | EVP_DigestFinal_ex | write into caller |
DigestCtx::serialize(out) 1 | EVP_MD_CTX_serialize | write into caller |
DigestCtx::deserialize(data) 1 | EVP_MD_CTX_deserialize | read from caller |
DigestAlg::digest(data, out) | EVP_Digest | both |
CipherCtx::update(input, output) | EVP_Encrypt/DecryptUpdate | both |
CipherCtx::finalize(output) | EVP_Encrypt/DecryptFinal_ex | write into caller |
AeadEncryptCtx::set_aad(aad) | EVP_EncryptUpdate(out=NULL) | read only |
AeadEncryptCtx::tag(tag) | EVP_CIPHER_CTX_ctrl GCM_GET_TAG | write into caller |
AeadDecryptCtx::set_tag(tag) | EVP_CIPHER_CTX_ctrl GCM_SET_TAG | read from caller |
MacCtx::update(data) | EVP_MAC_update | read from caller |
MacCtx::finish(out) | EVP_MAC_final | write into caller |
Signer::update(data) | EVP_DigestSignUpdate | read from caller |
Verifier::update(data) | EVP_DigestVerifyUpdate | read from caller |
Verifier::verify(sig) | EVP_DigestVerifyFinal | read from caller |
RawSigner::sign(tbs, sig) | EVP_PKEY_sign | both |
RawVerifier::verify(tbs, sig) | EVP_PKEY_verify | read from caller |
DeriveCtx::derive(out) | EVP_PKEY_derive | write into caller |
PkeyEncryptCtx::encrypt(pt, ct) | EVP_PKEY_encrypt | both |
PkeyDecryptCtx::decrypt(ct, pt) | EVP_PKEY_decrypt | both |
Rand::fill(buf) | RAND_bytes | write into caller |
RandCtx::generate(out, req) | EVP_RAND_generate | write into caller |
X509::from_der(der) | d2i_X509 via local ptr | read from caller |
Pkey::from_der(der) | d2i_* via MemBioBuf | read from caller |
KdfBuilder::derive(out) | EVP_KDF_derive | write into caller |
Params::get_octet_string(key) | OSSL_PARAM_get_octet_string_ptr | borrow from Params |
Params::get_utf8_string(key) | OSSL_PARAM_get_utf8_string_ptr | borrow 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.
| API | What is copied | Why unavoidable |
|---|---|---|
CipherAlg::encrypt(key, iv) | Key + IV | OpenSSL schedules and zeroizes |
AeadEncryptCtx::new(key, iv) | Key + IV | Same |
MacCtx::init(key) | Key → IPAD/OPAD for HMAC | MAC context owns key schedule |
HkdfBuilder::derive | key/salt/info via push_octet_slice | KDF expands material internally |
Pbkdf2Builder::derive | password + salt | Iteration state |
ScryptBuilder::derive | password + salt + working memory | Large scratch space |
SshkdfBuilder::derive | key/xcghash/session-id via push_octet_slice | KDF builds internal state |
KbkdfBuilder::derive | key/label/context via push_octet_slice | KDF builds internal state |
X509::from_der / from_pem | d2i_X509 builds an ASN.1 tree | OpenSSL’s internal representation |
Pkey::from_der / from_pem | BIGNUM, EC point, etc. | Constant-time key management |
Pkey::from_params | all key fields | EVP_PKEY_fromdata copies into EVP_PKEY |
Params::get_bn | BIGNUM → 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 variant | Zero-copy primary | When 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::finishalways 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::signis zero-copy — the caller provides a pre-allocated buffer. Usesign_len(tbs_len)to query the required size, then callsign(tbs, &mut buf).sign_allocis the convenience variant that handles the allocation for you.
Params::get_bnalways allocates —OSSL_PARAM_get_BNallocates a newBIGNUMinternally. The wrapper converts it to aVec<u8>and frees theBIGNUM. Useget_octet_stringfor 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:
| Method | C function | Copies? | Use when |
|---|---|---|---|
push_octet_slice(key, val) | push_octet_string | Yes | val may not outlive Params |
push_octet_ptr(key, val: &'a [u8]) | push_octet_ptr | No | val lifetime 'a is guaranteed |
push_utf8_string(key, val) | push_utf8_string | Yes | Short 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_bin2bn | Yes | BIGNUM 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:
| Getter | C function | Allocates? | Notes |
|---|---|---|---|
get_int / get_uint / get_size_t / get_i64 / get_u64 | OSSL_PARAM_get_* | No | Scalar read into a local variable |
get_octet_string | OSSL_PARAM_get_octet_string_ptr | No | Returns &[u8] borrowing into the Params array |
get_utf8_string | OSSL_PARAM_get_utf8_string_ptr | No | Returns &CStr borrowing into the Params array |
get_bn | OSSL_PARAM_get_BN + BN_bn2bin | Yes — Vec<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:
-
native-ossl-sys/build.rs— theOsslCallbacks::int_macrohook readsOPENSSL_VERSION_NUMBER. When the value is>= 0x4000_0000, it emits:cargo::rustc-cfg=ossl_v400 cargo::metadata=OSSL400=1The
cargo::metadataline, combined withlinks = "ssl"innative-ossl-sys/Cargo.toml, causes Cargo to expose the value to dependent crates as theDEP_SSL_OSSL400environment variable. -
native-ossl/build.rs— readsDEP_SSL_OSSL400. If present, emitscargo::rustc-cfg=ossl_v400for thenative-osslcrate. Also unconditionally emitscargo::rustc-check-cfg=cfg(ossl_v400)so theunexpected_cfgslint 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.
-
Only compiled when
#[cfg(ossl_v400)]is active (OpenSSL >= 4.0). See cfg propagation below. ↩ ↩2