Skip to content

sealed: hold the pre-key between guard pages (guarded-block); 0.9.0-rc.15 - #268

Merged
Slurp9187 merged 5 commits into
mainfrom
guarded-prekey
Oct 21, 2026
Merged

Slurp9187 merged 5 commits into
mainfrom
guarded-prekey

Conversation

@Slurp9187

Copy link
Copy Markdown
Owner

Follows up the sealed-memory work tracked in #56. EFV asked for the pre-key to be split into four separately allocated, aligned and locked 4 KiB pages, against a Heartbleed-style over-read by C code in the same process (it links SQLCipher and vendored OpenSSL). That was measured before anything was built, beside three alternatives. This PR builds the one the numbers support.

What the probe measured

Real 64 KiB memcpy over-reads from live C malloc buffers, 20 fresh processes per case, Windows 11 and Linux (WSL2, glibc 2.43). Probe and raw output: docs/design/receipts/guarded-prekey/.

Layout One 64 KiB read captured the whole pre-key
Heap block (rc.13–rc.14) 60/60 runs, each platform
Four heap pages (requested) 27/60 each platform; part of it in every run
Mapping, no guards 0/60, but 16384/16384 bytes with readable memory placed against it
Mapping between guard pages (this PR) 0/60, and with readable memory placed against it the read faults before a byte

The locked quota is unchanged (11 pre-keys under Windows' default), as is the per-access cost (5.95 µs). The derivation is also unchanged, pinned to a vector computed in Python.

What changes

  • New crate guarded-block 0.1.0 (guarded-block/, its own workspace like residue-probe). It's a safe API over region's page protection: a mapping of its own, no-access guard pages, read-only once filled, locked, MADV_DONTDUMP on Linux, wiped on drop, and fork-aware lock_status. All the unsafe is there, so secure-gate keeps #![forbid(unsafe_code)]. sealed depends on it instead of region.
  • Prekey now sits on top of it. Prekey::new_idle_noaccess() (opt-in) also keeps the pre-key no-access between key derivations. On Windows that keeps it out of ReadProcessMemory and full-memory crash dumps taken while idle (measured). But PAGE_NOACCESS drops a VirtualLock (measured: error 158 on unlock, and pages leave the working set), so this mode doesn't lock there, and lock_status() says so. Prekey::new() stays the default.
  • LockError: page_too_large removed (no per-target alignment table is needed any more). Added idle_noaccess(), and forked(), since a fork() child no longer repeats the parent's lock result.
  • Re-seals derive the cipher before copying plaintext into the ciphertext buffer. An independent audit of this branch found that a failed derivation in idle mode could otherwise leave plaintext there. It has a test with an injected failure, falsified.
  • CI: a new job runs guarded-block and the sealed tests on Ubuntu, Windows and macOS, at the 1.85 MSRV and on stable. Every other job is Ubuntu-only.
  • Docs: SECURITY.md §3 rows (over-read, core dump, crash dump), the module docs, and the design note's "Locking, and the guarded mapping". CHANGELOG has an rc.15 entry under Unreleased.

Verification

Locally, on the final tree, 59 of 60 gates pass:

  • fmt;
  • clippy and tests across the feature matrix with -D warnings;
  • release-profile tests;
  • the 1.85 compile-fail suite and an MSRV check;
  • rustdoc (stable, nightly docsrs);
  • thumbv7em;
  • guarded-block on stable, 1.85 and release, and its rustdoc;
  • cross-checks for x86_64/aarch64 Linux, x86_64/aarch64 macOS and ppc64le Linux;
  • the guarded-block publish dry-run.

The one expected failure is secure-gate's publish dry-run: guarded-block is not on crates.io yet. It passes once guarded-block 0.1.0 is published, which has to happen first. guarded-block's tests also pass on WSL2. Every new or changed test was checked to fail with the code it guards removed.

Not published, not tagged. The merge waits for an external review.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2

…9.0-rc.15

The pre-key moves off the heap into a mapping of its own: a no-access guard
page, its pages (read-only once filled, locked), and a no-access guard page,
marked MADV_DONTDUMP on Linux. A 64 KiB over-read from a live C malloc buffer
captured the whole heap-held pre-key in 60/60 runs on Windows and on Linux;
guarded, in none, and with readable memory placed directly against it the read
faults before reading a byte. The requested split into four heap pages was
measured first and still fully captured in 27/60 runs per platform.

The unsafe page protection needs lives in a new crate in this repository,
guarded-block 0.1.0, so secure-gate keeps #![forbid(unsafe_code)]; `sealed`
depends on it in place of region.

- Prekey::new_idle_noaccess(): also no-access between key derivations. On
  Windows that keeps the pre-key out of ReadProcessMemory and full-memory
  dumps taken while idle, but PAGE_NOACCESS drops a VirtualLock (measured), so
  it does not lock there and lock_status() says so. Prekey::new() stays the
  default.
- LockError: page_too_large removed (no per-target alignment table is needed
  any more); idle_noaccess() and forked() added. In a fork() child
  lock_status() now reports the lock as not inherited.
- Re-seals derive the cipher before copying plaintext into the ciphertext
  buffer, so a failed derivation (idle mode) cannot leave plaintext there.
- The derivation is unchanged, pinned to a vector computed outside Rust.
- CI: guarded-block and the sealed tests on Ubuntu, Windows and macOS.

Probe and raw output: docs/design/receipts/guarded-prekey/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
Comment thread src/sealed/prekey.rs Dismissed
Slurp9187 and others added 4 commits October 20, 2026 18:34
- guarded-block: size the mapping with checked arithmetic and return MapError
  instead of wrapping. Not reachable today (rustc rejects [u8; N] above
  isize::MAX, and no N up to that can overflow), but fill's soundness no
  longer rests on that.
- guarded-block: document that a refused return to no-access leaves an idle
  block readable until a later access closes it; edition 2024.
- Numbers the receipts support: the split was fully captured 26/60 on Windows
  and 27/60 on Linux (not 27 on each); the Windows idle-mode overhead is mostly
  re-faulting the pages into the working set, the protection changes alone
  being 0.65-0.97 us; MADV_DONTDUMP was checked as a flag, no core dump taken;
  the guard pages are evidenced by the neighbor test, not the C-buffer runs,
  which an unguarded mapping also passes.
- Sealed docs: Prekey and LockError examples (doctests), the sealed types in
  the crate root's "which type" table, and guarded-block in SECURITY.md's
  table of crates that lock correctly (memsec's row now says its API is
  unsafe fns).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
A cfg_attr row: a real intra-doc link where `sealed` is enabled (docs.rs), plain
text where it is not, so the `--features=full` doc gate has no broken link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
…tates them

The refused return to no-access, and MADV_DONTDUMP as the kernel's documented
behavior rather than a dump we took, now read the same on Prekey,
new_idle_noaccess, the sealed module, GuardedBlock::new_with_idle_noaccess and
guarded-block's README; the Windows idle cost is attributed to working-set
re-faults there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
Every push to main failed Miri from the sealed merge (rc.13) on: the first
Prekey test reached region's integer-to-pointer cast, which
-Zmiri-strict-provenance rejects, and Miri cannot emulate mlock / mprotect /
VirtualLock in any case. full,std is exactly what --all-features was before
sealed existed (and is the same set on release/0.8); the sealed and
guarded-block tests run natively on three OSes in ci.yml instead. Checked
with CI's exact command and flags on this branch under WSL: 446 tests in 24 binaries passed,
no errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
@Slurp9187
Slurp9187 merged commit 2ea496e into main Oct 21, 2026
55 checks passed
@Slurp9187
Slurp9187 deleted the guarded-prekey branch October 21, 2026 02:14
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