Skip to content

identity+crypto: atomic 0600 key file and zeroize secrets on drop#136

Merged
intendednull merged 2 commits into
mainfrom
claude/issue-126-127-identity-zeroize
Apr 11, 2026
Merged

identity+crypto: atomic 0600 key file and zeroize secrets on drop#136
intendednull merged 2 commits into
mainfrom
claude/issue-126-127-identity-zeroize

Conversation

@intendednull
Copy link
Copy Markdown
Owner

Closes #126
Closes #127
Progresses #108

Summary

Hardens on-disk identity storage and in-memory secret handling for Willow's identity / crypto layers.

Issue #126 — atomic key file write, 0600 perms, perm validation

  • Identity::load_or_generate now writes the key file atomically: opens a sibling temp file with O_CREAT|O_EXCL and (on Unix) mode 0o600, writes via with_secret_bytes so the buffer is zeroized, fsyncs, and renames into place. A crash mid-write can no longer leave a half-written or world-readable key on disk.
  • On load, the file mode is checked on Unix; any group/other access (anything matching mode & 0o077 != 0) is rejected with a new IdentityError::InsecurePermissions { path, mode } variant rather than silently using a leaked key.
  • All filesystem code stays under #[cfg(not(target_arch = "wasm32"))]; Unix-only bits sit under #[cfg(unix)] so Windows and WASM still build.
  • OpenOptions::new().write(true).create_new(true) (plus a sibling .<file>.tmp.<pid>.<nanos> name) catches the concurrent-startup race the issue called out.

Issue #127 — zeroize SecretKey and ChannelKey on drop

  • ChannelKey derives ZeroizeOnDrop (#[zeroize] pub(crate) [u8; 32]). Public API (as_bytes, from_bytes, the pub(crate) field) is unchanged, so willow-channel / willow-state / willow-client consumers don't need updates.
  • iroh_base::SecretKey already derives zeroize::ZeroizeOnDrop internally (verified by reading iroh-base-0.97.0/src/key.rs:235), so Identity derives ZeroizeOnDrop with #[zeroize(skip)] on the secret_key field. The derive is kept for the type-level guarantee that's enforced by the new compile-time _is_zeroize_on_drop tests — if the inner representation ever changes to a non-zeroizing type, the build will fail.
  • New Identity::with_secret_bytes(|bytes| ...) callback API that exposes the raw 32-byte secret only inside the closure and zeroizes the buffer on the way out. atomic_write_key uses this to avoid leaking the key bytes through a heap-allocated Vec<u8>.
  • Identity::to_bytes is marked #[must_use] with a doc comment urging callers to either zeroize the returned Vec<u8> or prefer with_secret_bytes. Existing call sites in crates/agent and crates/client are out-of-scope for this PR (per the task guardrails) and are unaffected because must_use only fires on truly-unused returns.

Scope guardrails respected

Test Plan

  • cargo test -p willow-identity — 20 tests pass, including:
    • load_or_generate_creates_file_with_0600_on_unix (gated #[cfg(unix)])
    • load_existing_with_loose_perms_returns_insecure_permissions (gated #[cfg(unix)])
    • load_existing_with_secure_perms_succeeds
    • load_or_generate_persists_identity (round-trip)
    • identity_is_zeroize_on_drop (compile-time assertion)
    • with_secret_bytes_exposes_full_secret
  • cargo test -p willow-crypto — 31 tests pass, including the new channel_key_is_zeroize_on_drop compile-time assertion.
  • cargo clippy --workspace -- -D warnings — clean.
  • cargo fmt --check — clean.
  • cargo check --target wasm32-unknown-unknown for the standard library set — clean.
  • Workspace tests pass for willow-identity, willow-crypto, willow-channel, willow-client, willow-state, willow-worker (worker's load_or_generate_* tests still pass against the new error path).

Note: three pre-existing flakes in willow-actor (send_dead_actor_returns_send_error, ask_dead_actor_returns_closed, recipient_dead_actor) intermittently race when the workspace is run under heavy parallelism. They pass in isolation and reproduce on origin/main without these changes — unrelated to this PR.

claude added 2 commits April 11, 2026 07:51
Hardens on-disk identity storage and in-memory secret handling:

* `Identity::load_or_generate` now writes the key file atomically:
  open a sibling temp file with `O_CREAT|O_EXCL` and mode `0o600`,
  fsync, then rename into place. A crash mid-write can no longer
  leave a half-written or world-readable key on disk.
* On load, the file mode is checked on Unix; any group/other access
  is rejected with a new `IdentityError::InsecurePermissions { path,
  mode }` error variant rather than silently using a leaked key.
* `ChannelKey` and `Identity` both derive `ZeroizeOnDrop` so secret
  bytes are wiped from memory when dropped. `iroh_base::SecretKey`
  already zeroizes internally, so `Identity` uses `#[zeroize(skip)]`
  on its `secret_key` field; the derive is kept for the type-level
  guarantee enforced by the new `_is_zeroize_on_drop` compile tests.
* Added `Identity::with_secret_bytes(|bytes| ...)`, a callback API
  that exposes the raw 32-byte secret only inside the closure and
  zeroizes the buffer afterwards. `atomic_write_key` uses it so the
  intermediate write buffer is wiped immediately. `Identity::to_bytes`
  is marked `#[must_use]` with a doc comment urging callers to
  zeroize the returned `Vec<u8>` (or use `with_secret_bytes`).

The on-disk key file format is unchanged (raw 32 Ed25519 bytes), and
nothing in the public API of `ChannelKey` changes — the `pub(crate)`
field stays put, so `willow-channel`/`willow-state` consumers don't
need updates.

Tests cover: 0o600 mode on freshly-created files, rejection of
loose-perm files with `InsecurePermissions`, round-trip via
`load_or_generate`, `with_secret_bytes` exposing the same 32 bytes
as `to_bytes`, and compile-time `ZeroizeOnDrop` assertions for both
`Identity` and `ChannelKey`.

Closes #126
Closes #127
Progresses #108
Audit nit: the regression test that verifies `with_secret_bytes`
actually exposes the raw 32-byte secret was itself leaking two
unzeroized `Vec<u8>` copies of the secret on the heap — ironic given
the PR's purpose. Wrap both locals in `zeroize::Zeroizing` so the test
zeroizes the scratch buffers on drop like the rest of the codebase.

No behavior change; `zeroize` is already a workspace dep with `derive`.
@intendednull intendednull merged commit b12c1d8 into main Apr 11, 2026
4 checks passed
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.

[identity+crypto] Zeroize SecretKey and ChannelKey on drop [identity] Atomic key file write, 0600 perms, and permission validation on load

2 participants