From d45318281c4728a62bcb9a6ff0f20b6e89349548 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:57:24 -0500 Subject: [PATCH 1/3] fix(core): route seed access through the zeroizing wrapper (#41) Remove Keypair::seed_bytes(), which returned a bare [u8; 32] copy of the Ed25519 private seed. Because [u8; 32] is Copy with no Drop, each caller's copy was released without being scrubbed, leaving secret key material in freed memory. The zeroizing accessor to_seed() -> Zeroizing<[u8; 32]> already existed beside it; this makes it the sole seed accessor and migrates the two in-crate callers (envelope open path and a crypto test) to it via deref coercion. No behavior change to envelope encryption: the derived X25519 secret is byte-identical, covered by the existing round-trip tests. Adds an identity test pinning to_seed() as the only seed accessor. --- crates/gitlawb-core/src/encrypt.rs | 4 ++-- crates/gitlawb-core/src/identity.rs | 21 ++++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/gitlawb-core/src/encrypt.rs b/crates/gitlawb-core/src/encrypt.rs index 03362703..a6d3dc20 100644 --- a/crates/gitlawb-core/src/encrypt.rs +++ b/crates/gitlawb-core/src/encrypt.rs @@ -123,7 +123,7 @@ pub fn open_blob(envelope: &[u8], keypair: &Keypair) -> Result> { .context("decode header")?; let body = &envelope[p + hlen..]; - let my_x = XSecret::from(x25519_secret_from_seed(&keypair.seed_bytes())); + let my_x = XSecret::from(x25519_secret_from_seed(&keypair.to_seed())); // Identities are blinded: no entry says which recipient it belongs to, so // try each one. The ChaChaBox AEAD tag authenticates, so exactly the @@ -185,7 +185,7 @@ mod tests { // The X25519 public derived from the Ed25519 public must equal the // X25519 public of the X25519 secret derived from the same seed. let kp = Keypair::generate(); - let seed = kp.seed_bytes(); + let seed = kp.to_seed(); let xpub_from_public = x25519_public(&kp.verifying_key()).unwrap(); let xsec = x25519_secret_from_seed(&seed); let xpub_from_secret = crypto_box::SecretKey::from(xsec).public_key().to_bytes(); diff --git a/crates/gitlawb-core/src/identity.rs b/crates/gitlawb-core/src/identity.rs index 9d3fea1f..14018d71 100644 --- a/crates/gitlawb-core/src/identity.rs +++ b/crates/gitlawb-core/src/identity.rs @@ -52,13 +52,9 @@ impl Keypair { URL_SAFE_NO_PAD.encode(sig.to_bytes()) } - /// The raw 32-byte Ed25519 seed. Used to derive the X25519 secret for - /// envelope decryption (see `crate::encrypt`). - pub fn seed_bytes(&self) -> [u8; 32] { - self.signing_key.to_bytes() - } - - /// Export the signing key as raw 32-byte seed (wrapped in Zeroizing). + /// The raw 32-byte Ed25519 seed, wrapped in `Zeroizing` so the copy is + /// scrubbed on drop. This is the only seed accessor; it is used to derive + /// the X25519 secret for envelope decryption (see `crate::encrypt`). pub fn to_seed(&self) -> Zeroizing<[u8; 32]> { Zeroizing::new(self.signing_key.to_bytes()) } @@ -172,6 +168,17 @@ mod tests { assert_eq!(kp.verifying_key(), kp2.verifying_key()); } + // `to_seed()` is the only seed accessor on `Keypair` (the raw, unzeroized + // `seed_bytes()` was removed in #41). It must hand out the seed + // deterministically, so the same seed reconstructs the same DID. + #[test] + fn to_seed_is_sole_seed_accessor() { + let kp = Keypair::generate(); + let seed = kp.to_seed(); + assert_eq!(*kp.to_seed(), *seed); // stable across calls + assert_eq!(Keypair::from_seed(&seed).unwrap().did(), kp.did()); + } + #[test] fn sign_b64_decodes_to_valid_signature() { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; From 4f814459cc4ba928f6358693e8ff1edaa7f1ea8b Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:52:38 -0500 Subject: [PATCH 2/3] test(core): pin to_seed's Zeroizing return type, drop duplicate round-trip The to_seed_is_sole_seed_accessor test was named for a compile-time property its body couldn't assert, and its from_seed -> DID check duplicated from_seed_round_trip. Replace it with a type-annotated binding that fails to compile if to_seed ever returns a bare [u8; 32] again, plus the determinism check, which is the only behavior it uniquely covered. --- crates/gitlawb-core/src/identity.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/gitlawb-core/src/identity.rs b/crates/gitlawb-core/src/identity.rs index 14018d71..511e1203 100644 --- a/crates/gitlawb-core/src/identity.rs +++ b/crates/gitlawb-core/src/identity.rs @@ -169,14 +169,17 @@ mod tests { } // `to_seed()` is the only seed accessor on `Keypair` (the raw, unzeroized - // `seed_bytes()` was removed in #41). It must hand out the seed - // deterministically, so the same seed reconstructs the same DID. + // `seed_bytes()` was removed in #41). Pin its return type so a bare-array + // accessor can't slip back in, and confirm it is deterministic. The + // from_seed -> verifying_key round-trip is already covered by + // `from_seed_round_trip`. #[test] - fn to_seed_is_sole_seed_accessor() { + fn to_seed_returns_zeroizing_and_is_deterministic() { let kp = Keypair::generate(); - let seed = kp.to_seed(); + // The type annotation is the regression guard: the seed must be handed + // out wrapped in `Zeroizing`, never as a bare `[u8; 32]`. + let seed: Zeroizing<[u8; 32]> = kp.to_seed(); assert_eq!(*kp.to_seed(), *seed); // stable across calls - assert_eq!(Keypair::from_seed(&seed).unwrap().did(), kp.did()); } #[test] From e4f5e87e0b9999eea8d76d1f655c24e882059b0e Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:14:40 -0500 Subject: [PATCH 3/3] fix(node): migrate node_seed pin caller to to_seed() main grew a third seed_bytes() caller (repos.rs, from #40) after this branch was cut. Route it through to_seed() too: node_seed is moved into a long-lived tokio::spawn pin task, so the Zeroizing wrapper scrubs the copy when that task ends. encrypt_and_pin takes &[u8; 32]; the wrapper deref-coerces, so the call site is unchanged. --- crates/gitlawb-node/src/api/repos.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index 3fae91e1..6d159488 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -689,7 +689,7 @@ pub async fn git_receive_pack( let irys_url = state.config.irys_url.clone(); let http_client = std::sync::Arc::clone(&state.http_client); let node_did_str = state.node_did.to_string(); - let node_seed = state.node_keypair.seed_bytes(); + let node_seed = state.node_keypair.to_seed(); let repo_name = record.name.clone(); tokio::spawn(async move { let pinned = crate::ipfs_pin::pin_new_objects(