Skip to content

Improve secret key handling using zero-copy memfd_secret isolation#52

Open
NilanjanDaw wants to merge 3 commits into
GoogleCloudPlatform:mainfrom
NilanjanDaw:protected-mem-zero-copy
Open

Improve secret key handling using zero-copy memfd_secret isolation#52
NilanjanDaw wants to merge 3 commits into
GoogleCloudPlatform:mainfrom
NilanjanDaw:protected-mem-zero-copy

Conversation

@NilanjanDaw

Copy link
Copy Markdown
Collaborator

Implement a zero-copy security architecture to protect KEM and Binding private keys stored inside secure memfd_secret mappings (Vault). This change eliminates standard stack and heap process memory residency of raw private key material during active cryptographic lifecycles:

  1. Scoped Vault Borrows: Replaced the copy-heavy Vault::get_secret() with the closure-based Vault::with_secret() scoped borrow pattern, preventing standard heap re-allocations.
  2. Lifetime-Bound Key References: Transitioned crypto traits and downstream APIs to consume borrowed PrivateKeyRef<'a> structures linked directly to Vault mapping lifetimes.
  3. Stackless Cryptographic FFI: Bypassed standard array-owning PrivateKey value types during X25519 decapsulation, calling raw FFI bssl_sys::X25519 pointers. Derived shared secrets are written directly into heap-allocated SecretBox destinations, ensuring zero stack footprint.
  4. Direct-to-Vault Keypair Generation: Implemented stackless keypair generation directly inside pre-allocated Vault instances via ScopedEvpHpkeKey and raw BoringSSL key generation FFI extraction.
  5. Library & Downstream Integration: Propagated zero-copy patterns to KeyRecord and downstream handlers (key_protection_service and workload_service). Added test-utils constructors to safely mock KeyRecords in tests.

All unit and integration tests pass successfully.

Implement a robust, zero-copy security architecture to protect KEM and
Binding private keys stored inside secure memfd_secret mappings (Vault).
This change eliminates standard stack and heap process memory residency of
raw private key material during active cryptographic lifecycles:

1. Scoped Vault Borrows: Replaced the copy-heavy Vault::get_secret() with
   the closure-based Vault::with_secret() scoped borrow pattern,
   preventing standard heap re-allocations.
2. Lifetime-Bound Key References: Transitioned crypto traits and downstream
   APIs to consume borrowed PrivateKeyRef<'a> structures linked directly to
   Vault mapping lifetimes.
3. Stackless Cryptographic FFI: Bypassed standard array-owning PrivateKey
   value types during X25519 decapsulation, calling raw FFI bssl_sys::X25519
   pointers. Derived shared secrets are written directly into heap-allocated
   SecretBox destinations, ensuring zero stack footprint.
4. Direct-to-Vault Keypair Generation: Implemented stackless keypair
   generation directly inside pre-allocated Vault instances via
   ScopedEvpHpkeKey and raw BoringSSL key generation FFI extraction.
5. Library & Downstream Integration: Propagated zero-copy patterns to
   KeyRecord and downstream handlers (key_protection_service and
   workload_service). Added test-utils constructors to safely mock
   KeyRecords in tests.

All unit and integration tests pass successfully.
@NilanjanDaw NilanjanDaw force-pushed the protected-mem-zero-copy branch from 0e646cb to 892b3f8 Compare May 26, 2026 14:41
@NilanjanDaw NilanjanDaw requested a review from atulpatildbz May 26, 2026 14:42
@NilanjanDaw NilanjanDaw force-pushed the protected-mem-zero-copy branch from 892b3f8 to 4bdbc51 Compare May 26, 2026 15:05
Comment thread km_common/src/crypto/x25519.rs Outdated
X25519PrivateKey(SecretBox::new(sk)),
)
/// A scope-managed raw EVP_HPKE_KEY wrapper to guarantee zeroization on drop.
struct ScopedEvpHpkeKey(bssl_sys::EVP_HPKE_KEY);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ScopedEvpHpkeKey places the full EVP_HPKE_KEY struct including private_key bytes on the Rust stack as a local variable via std::mem::zeroed, so this isn't genuinely stackless.

The Drop impl (line 275) also provides no real zeroization: EVP_HPKE_KEY_cleanup is a no-op in BoringSSL right now (ref).

Since bssl_sys::X25519_keypair is available and Vault::write_secret accepts a FnOnce(&mut [u8]) closure, we could skip the HPKE wrapper entirely and generate directly into the vault:

let mut vault = Vault::new_empty(32)?;
unsafe {
    vault.write_secret(|priv_buf| {
        bssl_sys::X25519_keypair(pub_key_buf.as_mut_ptr(), priv_buf.as_mut_ptr())
    });
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thats a great point, updated to directly use bssl_sys::X25519_keypair

Comment on lines +93 to +95
/// The caller must ensure that any raw pointers or FFI calls writing into the mutable slice
/// do not write out of bounds. The slice length is guaranteed to be exactly the initialized size.
pub unsafe fn write_secret<F, T>(&mut self, f: F) -> T

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

unsafe isn't needed here right?

If the closure f contains FFI calls (which are indeed unsafe), the responsibility for maintaining safety lies inside the closure, at the point where the FFI call is made.

Comment on lines +102 to +108
pub struct X25519PrivateKeyRef<'a>(pub(crate) &'a [u8]);

impl From<X25519PrivateKey> for SecretBox {
fn from(key: X25519PrivateKey) -> SecretBox {
key.0
impl<'a> X25519PrivateKeyRef<'a> {
fn to_public(&self) -> Result<[u8; 32], Status> {
let mut pub_key = [0u8; 32];
unsafe {
bssl_sys::X25519_public_from_private(pub_key.as_mut_ptr(), self.0.as_ptr());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

slice &[u8] can be of any length. and the length is only known at runtime.
this is passed to bssl_sys::X25519_public_from_private which expects the 2nd argument to point to 32 byte memory. see https://github.com/google/boringssl/blob/ef4f3c2197f90c96a44716aedaac55a10cb4e479/include/openssl/curve25519.h#L58

this is safe for now since to_public is only called from decaps_internal. where we check for self.0.len() != 32. but there's no compile time check.

And to_public itself should enforce this.

  1. Instead of wrapping a dynamically-sized slice &[u8], we should wrap a statically-sized array reference &[u8; 32].
  2. After this self.0 would be guaranteed to point to 32 byte memory. we can remove Result return type
  3. in key_types.rs use try_into() conversion when we construct X25519PrivateKeyRef

By doing this, it becomes impossible to compile code that constructs a X25519PrivateKeyRef with a wrongly-sized slice

@@ -1,10 +1,12 @@
#[cfg(any(test, feature = "test-utils"))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we should be added new tests:

  1. That generate_keypair() actually populates the Vault with a key matching the returned public key (public_from_private round-trip).
  2. That a randomly written byte cookie in the Vault round-trips through with_secret (you have this for Vault::new but not for new_empty + write_secret).
  3. That the public key bytes returned correspond to the private key in the Vault (i.e., X25519_public_from_private(vault_priv) == returned_pub). A simple test that decaps a fresh keypair would catch wiring bugs.

Simplify `generate_keypair` in `km_common/src/crypto/x25519.rs` by directly
calling `bssl_sys::X25519_keypair` instead of using `ScopedEvpHpkeKey`.
This avoids stack-allocated HPKE wrapper structures and allows writing
the private key directly into the protected `Vault` memory (zero-copy).

Add unit tests:
- `test_generate_keypair_roundtrip` to verify that the generated public
  key matches the private key, and that encapsulation/decapsulation works.
- `test_vault_empty_write_retrieval` to verify `Vault::new_empty` and
  `write_secret`/`with_secret` round-trip.
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