Skip to content

Sealed memory: SealedFixed, SealedDynamic, locked Prekey (#56) - #265

Merged
Slurp9187 merged 18 commits into
mainfrom
sealed-memory
Oct 19, 2026
Merged

Slurp9187 merged 18 commits into
mainfrom
sealed-memory

Conversation

@Slurp9187

@Slurp9187 Slurp9187 commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Builds the sealed-memory family tracked in #56, following docs/design/sealed_memory.md (sequoia-openpgp's mem::Encrypted model), plus the additions EFV asked for: pre-key page locking, a soft lock failure, a wipeable owner object instead of a static, and sealed_fixed_newtype!.

Kept open until EFV has tested it against the branch. Merge and publish follow EFV's sign-off; rc.13 is prepared but not tagged.

Release order (owner decision, 2026-10-18, relayed by EFV COORDINATOR)

All six publish together once EFV's #59 integration test is green:

  1. secure-gate 0.9.0-rc.13 (this PR, merged, then published by the owner)
  2. the five siblings, each re-pinned to =0.9.0-rc.13: aescrypt-rs, msoffice-crypto, odf-crypto, age-pq-hpke, age-pq-keys
  3. EFV bumps

Not only a version bump for the siblings: rc.13 rejects derive: [ConstantTimeEq] on byte-shaped fixed_newtype! arms, where it now comes for free. Checked by EFV COORDINATOR against the copies in EFV's lockfile: only age-pq-hpke uses it, four times in src/aliases.rs (lines 39, 59, 65, 92 at age-pq-workspace 4e77123; E0119 against rc.13). Its re-pin release drops those four derive: lists. age-pq-keys, aescrypt-rs, msoffice-crypto and odf-crypto change only the pin.

EFV tests against a local, never-pushed port of this PR onto v0.9.0-rc.12 (owner's choice), because the siblings pin =0.9.0-rc.12.

Shape

  • Feature sealed = ["std", "rand", chacha20poly1305 0.11, sha2 0.11, region 4.0.1], not part of full. Default deps unchanged.
  • Prekey: 16 KiB, aligned to 16 KiB, locked with region::lock, held as Arc<Prekey> by every sealed object. Wiped while still locked, then unlocked, then freed when the last reference drops. lock_status() reports a refused lock (LockError, OS code only), never panics.
  • Sealing key SHA-256(salt ‖ pre-key) recomputed per access, never stored; ChaCha20-Poly1305, detached tag, zero nonce, fresh salt on every seal and re-seal.
  • SealedFixed<N>: inline ciphertext, opens into a stack Fixed; no allocation per access.
  • SealedDynamic<Vec<u8> | String>: with_secret_mut gives &mut [u8] / &mut str, so it cannot grow.
  • scoped::RevealScoped (not at the root; glob-import hazard checked in tests/sealed_glob_import.rs).
  • sealed_fixed_newtype!(pub SealedAppKey, AppKey, "doc"): seals/unseals its own role only; with_role / with_role_mut hand the closure &AppKey (compile_fail doctests).
  • No expose_secret, no RevealSecret, no Hash; Debug is [REDACTED].

Slices

  • S1 types, pre-key, trait, macro
  • S2 tests (unit, integration, lock, compile-fail; falsified)
  • S3 benchmark (SealedFixed<32> vs Fixed<[u8; 32]>, SHA-256 vs BLAKE3)
  • S4 docs: design note status, SECURITY.md §3, README, CHANGELOG rc.13

Deferred to the end pass

  • fmt, clippy -D warnings on stable for every CI feature row, plus --all-features
  • full test suite: default, --features=full, --all-features; trybuild compile_fail suite on 1.85 (no CARGO_TARGET_DIR)
  • MSRV 1.85: cargo +1.85 check --workspace --all-features --all-targets after cargo +1.85 update
  • cargo doc --all-features with RUSTDOCFLAGS=-D warnings, stable and nightly --cfg docsrs
  • thumbv7em no_std build matrix unaffected
  • cargo audit / dependency tree: new deps, no duplicate rand_core
  • review note: new crypto surface; independent audit before merge. Four-lens audit, four findings, all addressed:
    • Sealing key left on the stack by the ChaCha20 backend (reproduced 4/4). Fixed: every seal and open ends with scrub_stack(), a 32 KiB zeroizing #[inline(never)] frame (sequoia's zero_stack, in safe Rust).
    • Plaintext copy on the stack in release. Fixed: SealedFixed opens in place inside an #[inline(never)] frame below the scrub.
    • Probe before/after: key 4→0 (debug), plaintext 1→0 (release); falsified by deleting the scrub (key copies return). Receipts in docs/design/receipts/sealed-stack-residue/. x86_64 Windows only.
    • Pages larger than 16 KiB would make the pre-key share a page. Fixed: not locked; lock_status() reports page_too_large(). Traced, not run (no 64 KiB-page machine).
    • ~256 GiB seal failure freed an unwiped copy. Fixed: held in Zeroizing until encrypted.

Gate run (local, RUSTFLAGS=-D warnings, stable 1.98.1 / 1.85 / nightly): 39 of 39 steps green: fmt; clippy and doctests over 7 feature rows; tests over 13 feature rows plus release full and release --all-features; trybuild compile_fail on 1.85; rustdoc stable and nightly --cfg docsrs; thumbv7em ×4; residue-probe. Sealed binaries confirmed run in the --all-features and --no-default-features --features=sealed rows. MSRV: cargo +1.85 update then check default / full / --workspace --all-features --all-targets, clean. cargo deny check advisories ok; cargo tree -d on normal deps empty.

Release checks, read-only, done at f798236: crates.io index has no 0.9.0-rc.13; manifest = heading; no v0.9.0-rc.13 tag local or remote; cargo publish --locked --dry-run green (47 files); the tarball also builds with sealed, --all-features (stable and 1.85) and full, and documents with -D warnings.

Release steps (owner-approved 2026-10-19; run when EFV signs off on #59)

  1. Merge Sealed memory: SealedFixed, SealedDynamic, locked Prekey (#56) #265 into main.
  2. On main, the cut: ## [0.9.0-rc.13] - Unreleased → the ISO date. Nothing else.
  3. cargo publish --locked --dry-run on that commit; must be green.
  4. git tag -a v0.9.0-rc.13 -m "0.9.0-rc.13" on the cut commit, then git push --follow-tags origin main (annotated tag only; never --tags). Checked: every local annotated tag is already on origin, so only the new one travels.
  5. Owner publishes. If the upload fails after the tag is pushed, the tag may be moved while rc.13 is unpublished.
  6. Siblings in the agreed order; then remove both worktrees (secure-gate-sealed-memory, secure-gate-sealed-rc12-test).

Resume here: waiting on EFV's sign-off (NAKED SECRETS rebuilding #1331 against the test copy at 2e5418e).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2

Slurp9187 and others added 5 commits October 18, 2026 18:31
…S1)

New `sealed` feature. Secrets are held as ChaCha20-Poly1305 ciphertext and
opened only for one closure call. Each access derives the sealing key afresh
as SHA-256(salt || 16 KiB pre-key); the key is never stored. The pre-key is
an Arc-owned object, not a static: page-aligned, locked with region::lock
(a failed lock is reported through lock_status, never fatal), and wiped
before it is unlocked and freed. Also adds scoped::RevealScoped (kept out of
the crate root) and sealed_fixed_newtype! for sealing fixed_newtype! roles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
Comment thread src/sealed/prekey.rs Fixed
Comment thread src/sealed/prekey.rs Fixed
Comment thread src/sealed/prekey.rs Fixed
Slurp9187 and others added 13 commits October 18, 2026 18:42
…ges larger than the pre-key (#56)

From the independent audit. The ChaCha20 backend leaves its state block (constant,
then the sealing key) on the stack, and release builds left one copy of the
plaintext; an inline(never) zeroizing frame laid over that region after each
seal and open removes both. On a page larger than 16 KiB the pre-key would share
a page, so it is not locked and lock_status says why. SealedDynamic::seal holds
its plaintext copy in a wiping buffer until it is ciphertext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
…he residue probe (#56)

The probe from the audit found the plaintext once more in release after the
first fix: the temporary was moved into the caller's frame, above the scrub.
It is now built as zeros and decrypted in place inside an inline(never) frame.
Before/after runs and the probe are in docs/design/receipts/sealed-stack-residue/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
…t a literal (#56)

with_role hands the closure the fixed_newtype! role itself, for code that takes
&AppKey rather than bytes, opened and wrapped inside the scrubbed frame. Asked
for by EFV, whose key consumers take the role. fresh_salt and two unit tests no
longer start from a literal array, which CodeQL read as a hard-coded salt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
Routing the salt through Fixed::from_random().into_inner() let CodeQL trace every
Fixed::new([literal]) in tests and fuzz into a salt sink: 20 alerts in files this
PR does not touch. Built from SysRng words with array::from_fn instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
CodeQL's closure-capture flow joined every Fixed::new([literal]) in tests and fuzz
to the &self.salt read inside open()'s closure: 20 false 'hard-coded salt'
alerts. SealedFixed's open_into shape is not flagged; SealedDynamic now matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
…and the secrecy migration note (#56)

Requested by the owner via EFV COORDINATOR. Cites iqlusioninc/crates#1140,
where secrecy 0.10 dropped its stack Secret<T> for the same reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
…ence; unlink sealed types in the feature table (#56)

The crate-root feature table linked SealedFixed / SealedDynamic / Prekey, which
do not exist under --features=full, so cargo doc --features=full failed again
(the break rc.13 already fixed once). CI documents --all-features only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
- Scrub on every exit: accesses run through scrubbed(), which catches an unwind
  (a panicking closure or a tag mismatch), scrubs, and resumes it. Panic paths
  left the sealing key on the stack before.
- unseal opens in place in the caller's frame; the by-value return can still
  leave a copy there, the SECURITY.md section 1 hazard, now documented on unseal
  and the sealed role's unseal with a pointer to with_secret / with_role.
- Lock the pre-key while it holds zeros, then fill it.
- The page-size refusal is lock_block(), and its test calls it (deleting the
  condition used to leave every test green).
- Sealed types are no longer Clone under cloneable (owner decision), with a
  compile_fail doctest.
- Unit tests that lock pages share a quota guard, so the quota test has the
  quota to itself; lock tests accept refusals that the code reports by design.
- String unseal wipes non-UTF-8 bytes before its unreachable panic.
- Docs: panic paths, unseal, debug vs release probe counts, stack headroom,
  tag checked before decryption, is_empty; second probe and runs in receipts.
- Regenerate fixed_zero_size.stderr for the line shift from 5805536.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
…to (#56)

Suggested by the review: under --all-features, sealed adds a second E0509 to
newtype_manual_drop, an expected mismatch rather than a regression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
… review pass (#56)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jd6eJgv7urGn4rvMcVASo2
@Slurp9187
Slurp9187 marked this pull request as ready for review October 19, 2026 05:31
@Slurp9187
Slurp9187 merged commit cbc4639 into main Oct 19, 2026
49 checks passed
@Slurp9187
Slurp9187 deleted the sealed-memory branch October 19, 2026 06:47
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