cargo / aws-lc-rs / audit
cargo : aws-lc-rs @ 1.17.0
PE Patrick Elsen signed 2026-05-28 published 2026-05-28

Claims

build-exec-deterministicbuild-exec-minimalbuild-exec-no-networkbuild-exec-no-write-outbuild-exec-safeconcurrency-documentedconcurrency-safeenvironment-safehas-binarieshas-build-exechas-fuzz-testshas-install-exechas-integration-testshas-property-testshas-unit-testsimpl-algorithmimpl-concurrencyimpl-cryptoimpl-datastructureimpl-interpreterimpl-jitimpl-parserimpl-protocolis-benignuses-concurrencyuses-cryptouses-environmentuses-execuses-filesystemuses-interpreteruses-jituses-networkuses-unsafe

Summary

aws-lc-rs 1.17.0 is Amazon's safe-Rust, ring-compatible crypto API: a typed FFI wrapper over the vendored AWS-LC C library, used as a rustls provider. Source is byte-equivalent to upstream. Crypto is implemented in C, not Rust; the Rust attack surface is 354 FFI unsafe blocks, with no network, filesystem, process, or runtime-environment access and a small benign build script. The AEAD, RNG, pointer, and key-zeroization paths reviewed are sound. C-library correctness was out of scope. No findings.

Report

Subject

aws-lc-rs 1.17.0 is Amazon's safe-Rust cryptography API, designed to be API-compatible with ring. It exposes AEAD (AES-GCM in several variants, AES-GCM-SIV, ChaCha20-Poly1305), digests (the SHA family), HMAC, HKDF and other KDFs, RSA and EC/Ed25519 signatures, ECDH and X25519 key agreement, KEM, AES key wrap, a CSPRNG, and a post-quantum DSA surface. The crate itself implements no cryptographic algorithm in Rust; it is a typed wrapper plus FFI-marshalling layer over the vendored AWS-LC C library, reached through the aws-lc-sys (default, non-FIPS) or aws-lc-fips-sys (fips feature) dependencies. It serves as a rustls crypto provider, an alternative to ring. The published artifact is about 31K lines of Rust across 95 source files with 354 unsafe occurrences in 48 files, almost all wrapping extern "C" calls into the C library.

Methodology

Tools: openvet 0.6.0, grep, diff, find. I compared the published contents/ tree against the upstream vcs/ checkout (github.com/aws/aws-lc-rs, commit 2201001603bde2dbe326ba47ad77c43a1b29c47c, subdirectory aws-lc-rs). I read the manifest and build.rs in full, the central FFI surfaces in full (the AEAD context construction and seal/open marshalling in src/aead/aead_ctx.rs and src/aead/unbound_key.rs, the pointer-lifetime machinery in src/ptr.rs, the RNG in src/rand.rs, library init in src/lib.rs), and surveyed the capability surface (network, filesystem, process, environment) and zeroize usage across the tree.

Scope. This is a scoped audit. Because the cryptography lives in the vendored AWS-LC C library and there are 354 FFI unsafe blocks, the following claims were not evaluated and are left unasserted, and must not be read as either satisfied or violated: crypto-impl-safe, crypto-impl-correct, crypto-safe, unsafe-safe, unsafe-documented, unsafe-minimal, unsafe-tested. This audit verifies supply-chain integrity (VCS byte-equivalence), the capability surface (uses-*), build-time execution, test presence, the impl-* categorization, dependency enumeration, and a representative review of the FFI marshalling. The uses-environment assertion reflects build-time only: the runtime library code contains no std::env access; build.rs reads several AWS_LC_RS_* and CARGO_CFG_* variables.

Results

The published crate is byte-equivalent to the upstream repository: diff -rq over contents/ and vcs/ shows only the expected normalization differences (the cargo-rewritten Cargo.toml, plus Cargo.lock, Cargo.toml.orig, and .cargo_vcs_info.json present only in the published artifact) and publish-excluded tests/, third_party/, and data/ directories. No source file differs.

The crate is benign (is-benign): a content scan of the published files found zero binary files (has-binaries), no obfuscation, and no network, filesystem, process-spawn, or runtime environment access. uses-network, uses-filesystem, and uses-exec are all false; the single std::fs reference is in a doc comment, not code. It exposes cryptography (uses-crypto) but does not implement it in Rust (impl-crypto is false), and likewise implements no parser, interpreter, JIT, protocol, datastructure, standalone algorithm, or concurrency primitive (impl-parser, impl-interpreter, impl-jit, impl-protocol, impl-datastructure, impl-algorithm, impl-concurrency all false). uses-interpreter and uses-jit are false.

A build.rs is present (has-build-exec), so this crate executes code at build time. The script validates that the fips and non-fips features are not both enabled, selects the sys crate, and emits cargo:rustc-cfg directives; it reads build-time environment (uses-environment, environment-safe) and re-exports the sys crate's DEP_AWS_LC_* version variables. It performs no network access, no filesystem writes, no process spawning, and no code generation, so build-exec-safe, build-exec-no-network, build-exec-no-write-out, build-exec-deterministic, and build-exec-minimal hold. Cargo has no install lifecycle, so has-install-exec is false.

The crate uses unsafe pervasively at the FFI boundary (uses-unsafe). The representative paths reviewed are sound: key length is validated against the algorithm before keys cross into EVP_AEAD_CTX_init (src/aead/aead_ctx.rs:235-275); the seal/open paths enforce input-length bounds and ciphertext/plaintext length equality, document their aliasing contract, and read C-written MaybeUninit<usize> output lengths only after the call reports success (src/aead/unbound_key.rs:126-164,438-473); OPENSSL_malloc results are null-checked through LcPtr::new and freed in Drop, with AWS-LC's free functions zeroizing on free (src/ptr.rs:60-130,222-225); and the RNG passes consistent pointer/length pairs and checks the return code (src/rand.rs:205-210). Secret key material is wiped on drop via the zeroize dependency across IVs, PKCS8 buffers, cipher keys, and HKDF/PBKDF2/KDF/RSA/KEM/TLS-PRF secrets.

Concurrency is limited to one-time thread-safe library initialization through std::sync::Once (src/lib.rs:301-309) plus unsafe impl Send/Sync on the immutable-after-init AEAD context; the crate uses but does not implement a synchronization primitive, so uses-concurrency, concurrency-safe, and concurrency-documented hold while impl-concurrency is false. Tests are 283 inline #[test] functions in #[cfg(test)] modules colocated with the source they exercise (has-unit-tests); the upstream tests/ integration directory is excluded from the published crate, and there is no fuzz or property-test harness, so has-integration-tests, has-fuzz-tests, and has-property-tests are false.

No findings were recorded.

Conclusion

aws-lc-rs 1.17.0 is a typed Rust API and FFI-marshalling layer over the vendored AWS-LC C library; the cryptographic implementation itself is out of crate. The published artifact is byte-equivalent to its upstream tag. Its attack surface in Rust is the FFI boundary: 354 unsafe blocks, no network, no filesystem, no process execution, no runtime environment access, and a small benign build script. The AEAD, RNG, pointer-lifetime, and key-zeroization paths reviewed marshal buffers and key material correctly, with consistent pointer/length pairs, null checks, post-success reads of uninitialized outputs, and zeroize-on-drop for secrets. Cryptographic correctness of the C library and exhaustive review of all 354 FFI blocks were out of scope, as noted in Methodology. No findings were recorded.

Findings

No findings.

Annotations(8)

Cargo.toml

Manifest. links = "aws_lc_rs_1_17_0_sys" and build = "build.rs" wire the crate to the native sys crate. Published artifact excludes tests/**/, third_party/NIST/, and test-vector files (*.txt, *.p8, *.der, *.bin), so the shipped crate contains no binary files (content scan found 0) and ships only the library plus two examples (cipher, digest) and inert dev scripts. zeroize is the only non-optional dependency; aws-lc-sys/aws-lc-fips-sys/untrusted are feature-gated. Justifies has-binaries, has-install-exec, is-benign, has-integration-tests, has-fuzz-tests, has-property-tests, and supports has-unit-tests.

build.rs

Build script. It validates that the mutually-exclusive fips/non-fips features are not both set (panics otherwise), selects the sys crate, and emits cargo:rustc-cfg / rustc-check-cfg lines. It reads only build-time environment (AWS_LC_RS_DISABLE_SLOW_TESTS, AWS_LC_RS_DEV_TESTS_ONLY, PROFILE, CARGO_CFG_) and re-exports the DEP_AWS_LC[FIPS] version variables that the sys crate publishes. No network, no filesystem writes, no process spawning, no code generation. Justifies has-build-exec, build-exec-safe, build-exec-no-network, build-exec-no-write-out, build-exec-deterministic, build-exec-minimal, uses-environment, environment-safe.

src/aead/aead_ctx.rs

src/aead/aead_ctx.rs, line 235-275

    fn build_context(
        aead_fn: unsafe extern "C" fn() -> *const aws_lc::evp_aead_st,
        key_bytes: &[u8],
        tag_len: usize,
        direction: Option<AeadDirection>,
    ) -> Result<LcPtr<EVP_AEAD_CTX>, Unspecified> {
        let aead = unsafe { aead_fn() };

        // We are performing the allocation ourselves as EVP_AEAD_CTX_new will call EVP_AEAD_CTX_init by default
        // and this avoid having to zero and reinitalize again if we need to set an explicit direction.
        let mut aead_ctx: LcPtr<EVP_AEAD_CTX> =
            LcPtr::new(unsafe { OPENSSL_malloc(size_of::<EVP_AEAD_CTX>()) }.cast())?;

        unsafe { EVP_AEAD_CTX_zero(aead_ctx.as_mut_ptr()) };

        if 1 != match direction {
            Some(direction) => unsafe {
                EVP_AEAD_CTX_init_with_direction(
                    aead_ctx.as_mut_ptr(),
                    aead,
                    key_bytes.as_ptr(),
                    key_bytes.len(),
                    tag_len,
                    direction.into(),
                )
            },
            None => unsafe {
                EVP_AEAD_CTX_init(
                    aead_ctx.as_mut_ptr(),
                    aead,
                    key_bytes.as_ptr(),
                    key_bytes.len(),
                    tag_len,
                    null_mut(),
                )
            },
        } {
            return Err(Unspecified);
        }
        Ok(aead_ctx)
    }

AEAD context construction marshalled into AWS-LC. Each public constructor validates key_bytes.len() against the algorithm key length before this point (e.g. AES_128_KEY_LEN, chacha::KEY_LEN), so the (key_bytes.as_ptr(), key_bytes.len()) pair handed to EVP_AEAD_CTX_init is consistent. The context buffer is OPENSSL_malloc-allocated and wrapped in LcPtr::new, which returns Err on null (propagated by ?); EVP_AEAD_CTX_zero initializes it before init; the FFI return code is checked (1 != ...). Representative of the FFI marshalling reviewed. Justifies uses-unsafe and supports uses-crypto.

src/aead/unbound_key.rs

src/aead/unbound_key.rs, line 126-164

    #[allow(clippy::too_many_arguments)]
    fn open_separate_gather_impl(
        &self,
        nonce: &Nonce,
        aad: &[u8],
        in_ciphertext: *const u8,
        in_ciphertext_len: usize,
        in_tag: &[u8],
        out_plaintext: *mut u8,
        out_plaintext_len: usize,
    ) -> Result<(), Unspecified> {
        self.check_per_nonce_max_bytes(in_ciphertext_len)?;

        // ensure that the lengths match
        if in_ciphertext_len != out_plaintext_len {
            return Err(Unspecified);
        }

        unsafe {
            let aead_ctx = self.ctx.as_ref();
            let nonce = nonce.as_ref();

            if 1 != EVP_AEAD_CTX_open_gather(
                aead_ctx.as_const_ptr(),
                out_plaintext,
                nonce.as_ptr(),
                nonce.len(),
                in_ciphertext,
                in_ciphertext_len,
                in_tag.as_ptr(),
                in_tag.len(),
                aad.as_ptr(),
                aad.len(),
            ) {
                return Err(Unspecified);
            }
            Ok(())
        }
    }

open_separate_gather_impl: the SAFETY doc states the aliasing contract (out may equal in, exactly), check_per_nonce_max_bytes bounds the input length, and in_ciphertext_len must equal out_plaintext_len before the EVP_AEAD_CTX_open_gather call. All pointer/length pairs are taken from the same slices. Justifies uses-unsafe.

src/aead/unbound_key.rs, line 438-473

    #[inline]
    fn seal_separate(
        &self,
        nonce: Nonce,
        aad: &[u8],
        in_out: &mut [u8],
    ) -> Result<(Nonce, Tag), Unspecified> {
        let mut tag = [0u8; MAX_TAG_LEN];
        let mut out_tag_len = MaybeUninit::<usize>::uninit();
        {
            let nonce = nonce.as_ref();

            debug_assert_eq!(nonce.len(), self.algorithm().nonce_len());

            if 1 != indicator_check!(unsafe {
                EVP_AEAD_CTX_seal_scatter(
                    self.ctx.as_ref().as_const_ptr(),
                    in_out.as_mut_ptr(),
                    tag.as_mut_ptr(),
                    out_tag_len.as_mut_ptr(),
                    tag.len(),
                    nonce.as_ptr(),
                    nonce.len(),
                    in_out.as_ptr(),
                    in_out.len(),
                    null(),
                    0usize,
                    aad.as_ptr(),
                    aad.len(),
                )
            }) {
                return Err(Unspecified);
            }
        }
        Ok((nonce, Tag(tag, unsafe { out_tag_len.assume_init() })))
    }

seal_separate: the tag output is a fixed MAX_TAG_LEN stack buffer; out_tag_len is MaybeUninit:: and is only read via assume_init() after EVP_AEAD_CTX_seal_scatter returns success (indicator_check!). The returned Tag carries the C-written length. Uninitialized-output handling is correct. Justifies uses-unsafe.

src/hkdf.rs

src/hkdf.rs, line 237-256

struct ZeroizeBoxSlice<T: Zeroize>(Box<[T]>);

impl<T: Zeroize> core::ops::Deref for ZeroizeBoxSlice<T> {
    type Target = [T];

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl<T: Clone + Zeroize> From<&[T]> for ZeroizeBoxSlice<T> {
    fn from(value: &[T]) -> Self {
        Self(Vec::from(value).into_boxed_slice())
    }
}

impl<T: Zeroize> Drop for ZeroizeBoxSlice<T> {
    fn drop(&mut self) {
        self.0.zeroize();
    }

ZeroizeBoxSlice wraps a Box<[T]> and zeroizes it in Drop, used for HKDF secrets. Representative of the zeroize-on-drop pattern applied across IVs, PKCS8 buffers, cipher keys, RSA/KEM/KDF/TLS-PRF secrets. Supports the assessment that secret key material is wiped via the zeroize dependency.

src/lib.rs

src/lib.rs, line 301-309

static START: Once = Once::new();

#[inline]
/// Initialize the *AWS-LC* library. (This should generally not be needed.)
pub fn init() {
    START.call_once(|| unsafe {
        CRYPTO_library_init();
    });
}

init() drives CRYPTO_library_init exactly once via std::sync::Once::call_once, giving thread-safe one-time initialization. This is the crates only synchronization primitive and it uses (does not implement) one. Justifies uses-concurrency, concurrency-safe, concurrency-documented, and supports impl-concurrency being false.

src/ptr.rs

src/ptr.rs, line 60-130

    }

    pub fn project_const_lifetime<'a, C>(
        &'a self,
        f: unsafe fn(&'a Self) -> *const C,
    ) -> Result<ConstPointer<'a, C>, ()> {
        let ptr = unsafe { f(self) };
        if ptr.is_null() {
            return Err(());
        }
        Ok(ConstPointer {
            ptr,
            _lifetime: PhantomData,
        })
    }

    #[inline]
    pub fn as_mut_ptr(&mut self) -> *mut P::T {
        self.pointer.as_mut_ptr()
    }
}

impl<P: Pointer> DetachablePointer<P> {
    #[inline]
    pub fn as_mut_ptr(&mut self) -> *mut P::T {
        self.pointer.as_mut().unwrap().as_mut_ptr()
    }
}

#[derive(Debug)]
#[allow(clippy::module_name_repetitions)]
pub(crate) struct DetachablePointer<P: Pointer> {
    pointer: Option<P>,
}

impl<P: Pointer> DetachablePointer<P> {
    #[inline]
    pub fn new<T: IntoPointer<P>>(value: T) -> Result<Self, ()> {
        if let Some(pointer) = value.into_pointer() {
            Ok(Self {
                pointer: Some(pointer),
            })
        } else {
            Err(())
        }
    }

    #[inline]
    pub fn detach(mut self) -> P {
        self.pointer.take().unwrap()
    }
}

impl<P: Pointer> From<DetachablePointer<P>> for ManagedPointer<P> {
    #[inline]
    fn from(mut dptr: DetachablePointer<P>) -> Self {
        match dptr.pointer.take() {
            Some(pointer) => ManagedPointer { pointer },
            None => {
                // Safety: pointer is only None when DetachableLcPtr is detached or dropped
                unreachable!()
            }
        }
    }
}

impl<P: Pointer> Drop for DetachablePointer<P> {
    #[inline]
    fn drop(&mut self) {
        if let Some(mut pointer) = self.pointer.take() {
            pointer.free();

Smart-pointer wrappers (ManagedPointer / DetachablePointer / LcPtr) over AWS-LC heap objects. Construction null-checks the raw pointer and returns Err on null; Drop calls the matching XXX_free. The comment at lines 222-225 documents that AWS-LC free functions (unlike OpenSSL) zeroize the memory on free, which is how secret-bearing C structs are wiped. This is the central RAII machinery that makes the FFI safe. Justifies uses-unsafe.

src/rand.rs

src/rand.rs, line 205-210

pub fn fill(dest: &mut [u8]) -> Result<(), Unspecified> {
    if 1 != indicator_check!(unsafe { RAND_bytes(dest.as_mut_ptr(), dest.len()) }) {
        return Err(Unspecified);
    }
    Ok(())
}

fill() passes (dest.as_mut_ptr(), dest.len()) as a consistent pair to RAND_bytes and checks the return code via indicator_check!. The CSPRNG output is the C librarys. Justifies uses-unsafe and supports uses-crypto.