Files
fips/src/identity/encoding.rs
T
Johnathan Corgan ee1c624cef Clear every copy of private key material the crate can reach
The earlier change cleared the symmetric keys. This one covers the rest, and
covers it by enumerating where key bytes actually live rather than by pattern,
because three successive passes each cleared one place and missed another.

The handshake state and the identity now clear their keypairs on drop. That
matters more than the stack copies already handled: the ephemeral key was
being wiped in two short-lived locals and then stored in a field that outlived
both. These types are ordinary structs, so they can carry a drop even though
the keypair inside them cannot.

Also cleared: the temporary each of the fourteen elliptic-curve calls makes
from a keypair, the by-value keypair parameters, the identity generation and
parsing paths, the encoded secret strings, and the private key as it passes
through configuration. The config file text is treated as secret for as long
as it is held, since the key can be written straight into it.

Two places assign over the configured key rather than dropping the struct that
holds it. Assignment frees the old string without running the drop, so both
now clear it first.

What is deliberately not cleared, and why: the hash and key-derivation states,
and the cached cipher keys inside ring. None of those crates offers a clearing
route at the versions we pin, which I checked in their sources rather than
assuming, and reaching for unsafe here was not worth it for a residue that
needs local memory access to read.

One limitation is worth stating plainly. These key types are copyable, so the
compiler may duplicate them where we cannot see. This clears the copies the
crate owns, not every copy that ever existed. The drops also have no test:
reading a dropped struct's bytes means reading freed memory.
2026-08-16 16:34:40 +00:00

84 lines
2.7 KiB
Rust

//! NIP-19 bech32 encoding for Nostr keys.
use bech32::{Bech32, Hrp};
use secp256k1::{SecretKey, XOnlyPublicKey};
use zeroize::{Zeroize, Zeroizing};
use super::IdentityError;
/// Human-readable part for npub (NIP-19).
const NPUB_HRP: Hrp = Hrp::parse_unchecked("npub");
/// Human-readable part for nsec (NIP-19).
const NSEC_HRP: Hrp = Hrp::parse_unchecked("nsec");
/// Encode an x-only public key as a bech32 npub string (NIP-19).
pub fn encode_npub(pubkey: &XOnlyPublicKey) -> String {
bech32::encode::<Bech32>(NPUB_HRP, &pubkey.serialize()).expect("npub encoding cannot fail")
}
/// Decode an npub string to an x-only public key.
pub fn decode_npub(npub: &str) -> Result<XOnlyPublicKey, IdentityError> {
let (hrp, data) = bech32::decode(npub)?;
if hrp != NPUB_HRP {
return Err(IdentityError::InvalidNpubPrefix(hrp.to_string()));
}
if data.len() != 32 {
return Err(IdentityError::InvalidNpubLength(data.len()));
}
let pubkey = XOnlyPublicKey::from_slice(&data)?;
Ok(pubkey)
}
/// Encode a secret key as a bech32 nsec string (NIP-19).
///
/// The returned string is the private key in another encoding, so it is the
/// caller's to clear. What this function clears is the raw byte copy
/// `secret_bytes` hands back, which would otherwise sit in an unnamed
/// temporary until the end of the statement.
pub fn encode_nsec(secret_key: &SecretKey) -> String {
let mut secret_bytes = secret_key.secret_bytes();
let nsec =
bech32::encode::<Bech32>(NSEC_HRP, &secret_bytes).expect("nsec encoding cannot fail");
secret_bytes.zeroize();
nsec
}
/// Decode an nsec string to a secret key.
pub fn decode_nsec(nsec: &str) -> Result<SecretKey, IdentityError> {
let (hrp, data) = bech32::decode(nsec)?;
// `data` is the raw private key. The guard clears it on every exit path,
// including the two length and prefix rejections below.
let data = Zeroizing::new(data);
if hrp != NSEC_HRP {
return Err(IdentityError::InvalidNsecPrefix(hrp.to_string()));
}
if data.len() != 32 {
return Err(IdentityError::InvalidNsecLength(data.len()));
}
let secret_key = SecretKey::from_slice(&data)?;
Ok(secret_key)
}
/// Decode a secret key from either nsec (bech32) or hex format.
pub fn decode_secret(s: &str) -> Result<SecretKey, IdentityError> {
if s.starts_with("nsec1") {
decode_nsec(s)
} else {
// `bytes` is the raw private key; the guard clears it on both the
// length rejection and the normal return.
let bytes = Zeroizing::new(hex::decode(s)?);
if bytes.len() != 32 {
return Err(IdentityError::InvalidNsecLength(bytes.len()));
}
let secret_key = SecretKey::from_slice(&bytes)?;
Ok(secret_key)
}
}