Skip to main content

nula_core/nips/
nip49.rs

1//! [NIP-49] Private Key Encryption (`ncryptsec`).
2//!
3//! Encrypts a 32-byte secp256k1 secret key with a user-supplied
4//! password and returns a `bech32`-encoded `ncryptsec1...` string. The
5//! format is *intentionally* expensive to brute-force: the password is
6//! NFKC-normalised, then run through `scrypt` with caller-chosen
7//! `log_n`, then the secret is sealed under XChaCha20-Poly1305 with
8//! the security-level byte as authenticated data.
9//!
10//! # Wire layout (91 bytes before bech32)
11//!
12//! ```text
13//! ┌─ 1 ─┬─ 1 ──┬───── 16 ─────┬───── 24 ─────┬─ 1 ──┬───── 48 ─────┐
14//! │ ver │log_n │     salt     │     nonce    │ aad  │  ciphertext  │
15//! └─────┴──────┴──────────────┴──────────────┴──────┴──────────────┘
16//!   0x02   user      random        random      KS      32B+16B tag
17//! ```
18//!
19//! - `version` is fixed at `0x02` (the only one defined by spec).
20//! - `aad` is the [`KeySecurity`] byte; binding it into the AEAD means
21//!   tampering with the security level invalidates the MAC.
22//! - `ciphertext` is the secret key (32 bytes) plus the Poly1305 tag
23//!   (16 bytes).
24//!
25//! # Cost
26//!
27//! `log_n` is a power-of-two scrypt iteration count. The spec table
28//! recommends `16` (≈100 ms / 64 MiB) for client UX and `21` for
29//! cold-storage backups. We expose the dial unmodified.
30//!
31//! [NIP-49]: https://github.com/nostr-protocol/nips/blob/master/49.md
32
33#![allow(
34    clippy::expect_used,
35    clippy::unwrap_in_result,
36    clippy::missing_panics_doc,
37    reason = "every `expect` here guards a length invariant the surrounding \
38              code has just *proved* (e.g. an `if bytes.len() != \
39              PAYLOAD_BYTES { return Err(...) }` directly above a chain \
40              of `split_first_chunk::<N>` calls whose total fixed sizes \
41              add up to `PAYLOAD_BYTES`). The clippy lints are tuned for \
42              application code; cryptographic primitives cannot avoid \
43              `expect` without giving up the spec-mandated `Result`-only \
44              signatures of `XChaCha20Poly1305::encrypt`, \
45              `bech32::Hrp::parse`, and friends. Each call carries a \
46              comment that documents the exact guarantee it relies on."
47)]
48
49use bech32::Bech32;
50use bech32::primitives::decode::{CheckedHrpstring, CheckedHrpstringError};
51use chacha20poly1305::XChaCha20Poly1305;
52use chacha20poly1305::aead::{Aead, KeyInit, Payload};
53use scrypt::{Params as ScryptParams, scrypt};
54use thiserror::Error;
55use unicode_normalization::UnicodeNormalization;
56use zeroize::Zeroize;
57
58use crate::key::{SecretKey, SecretKeyError};
59use crate::util::rng::{self, RngError};
60
61/// Wire HRP for the bech32 encoding.
62pub const HRP: &str = "ncryptsec";
63/// NIP-49 version byte (the only one defined by spec).
64pub const VERSION_BYTE: u8 = 0x02;
65/// scrypt salt length.
66pub const SALT_BYTES: usize = 16;
67/// XChaCha20-Poly1305 nonce length.
68pub const NONCE_BYTES: usize = 24;
69/// Symmetric key length (scrypt output).
70const SYM_KEY_BYTES: usize = 32;
71/// Plaintext (secret key) length.
72const SECRET_BYTES: usize = 32;
73/// Poly1305 tag length.
74const TAG_BYTES: usize = 16;
75/// Sealed ciphertext length: secret + tag.
76const CIPHERTEXT_BYTES: usize = SECRET_BYTES + TAG_BYTES;
77/// Total wire length (before bech32 encoding).
78pub const PAYLOAD_BYTES: usize =
79    1 /* version */ + 1 /* log_n */ + SALT_BYTES + NONCE_BYTES + 1 /* aad */ + CIPHERTEXT_BYTES;
80
81/// scrypt's published soft maximum for `log_n` on a 64-bit host. Going
82/// past this risks `Params::new` returning `Err(InvalidParams)`.
83///
84/// Exposed so the [`Nip49Error::LogNTooLarge`] doc-link resolves in
85/// the public rustdoc tree, and so callers wiring a UI cost slider
86/// have the value to bound it against without having to redefine it
87/// downstream.
88pub const MAX_LOG_N: u8 = 30;
89
90/// scrypt parallelism factor (`p`). Spec § Symmetric Encryption Key
91/// derivation says `p = 1`.
92const SCRYPT_P: u32 = 1;
93/// scrypt block size (`r`). Spec says `r = 8`.
94const SCRYPT_R: u32 = 8;
95
96/// Author-declared key-security level, baked into the ciphertext as
97/// AEAD additional-authenticated-data so it cannot be forged after the
98/// fact.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
100#[repr(u8)]
101#[non_exhaustive]
102pub enum KeySecurity {
103    /// `0x00` — author admits the key has been handled insecurely
104    /// (cut-and-pasted, kept in plaintext, etc.).
105    Weak = 0x00,
106    /// `0x01` — author asserts the key has only ever lived inside an
107    /// encrypted container.
108    Strong = 0x01,
109    /// `0x02` — author does not track this signal.
110    Untracked = 0x02,
111}
112
113impl KeySecurity {
114    /// Round-trip from the `u8` byte found on the wire.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`Nip49Error::InvalidKeySecurity`] for any byte outside
119    /// `0x00..=0x02`.
120    pub const fn from_byte(byte: u8) -> Result<Self, Nip49Error> {
121        match byte {
122            0x00 => Ok(Self::Weak),
123            0x01 => Ok(Self::Strong),
124            0x02 => Ok(Self::Untracked),
125            _ => Err(Nip49Error::InvalidKeySecurity(byte)),
126        }
127    }
128}
129
130/// Errors raised by NIP-49 helpers.
131#[derive(Debug, Error)]
132#[non_exhaustive]
133pub enum Nip49Error {
134    /// scrypt rejected the requested parameters (typically `log_n` too
135    /// large for the running architecture, or memory exhaustion on the
136    /// host).
137    #[error("invalid scrypt parameters (log_n={log_n}): {message}")]
138    InvalidParams {
139        /// Caller-supplied `log_n`.
140        log_n: u8,
141        /// Backend message.
142        message: String,
143    },
144    /// `log_n` exceeded [`MAX_LOG_N`]. Picking values above this bound
145    /// makes the on-host derivation either impossibly slow or rejected
146    /// by the scrypt crate; we cap proactively so callers get a clear
147    /// diagnostic.
148    #[error("log_n {0} exceeds the supported cap of {MAX_LOG_N}")]
149    LogNTooLarge(u8),
150    /// scrypt failed during derivation (output buffer length mismatch).
151    #[error("scrypt key derivation failed: {0}")]
152    Scrypt(String),
153    /// XChaCha20-Poly1305 encryption / decryption failed.
154    ///
155    /// On encrypt this is unreachable in practice (ChaCha20-Poly1305
156    /// only fails when the message exceeds `2^32 - 1` blocks, which a
157    /// 32-byte secret cannot). On decrypt this fires when the password
158    /// is wrong, the ciphertext was tampered with, or the
159    /// [`KeySecurity`] byte was rewritten — the AEAD tag catches all
160    /// three.
161    #[error("XChaCha20-Poly1305 operation failed (wrong password or tampered ciphertext)")]
162    Aead,
163    /// The `ncryptsec1...` payload was the wrong length.
164    #[error("ncryptsec payload is {got} bytes, expected {PAYLOAD_BYTES}")]
165    InvalidLength {
166        /// Actual length on the wire.
167        got: usize,
168    },
169    /// The version byte was not `0x02`.
170    #[error("unsupported NIP-49 version byte: {0:#04x}")]
171    UnsupportedVersion(u8),
172    /// The key-security byte was outside `0x00..=0x02`.
173    #[error("invalid key-security byte: {0:#04x}")]
174    InvalidKeySecurity(u8),
175    /// bech32 decoding failed.
176    #[error("bech32 decoding failed: {0}")]
177    Decode(#[from] CheckedHrpstringError),
178    /// bech32 encoding failed.
179    #[error("bech32 encoding failed: {0}")]
180    Encode(#[from] bech32::EncodeError),
181    /// The bech32 HRP was not `"ncryptsec"`.
182    #[error("expected HRP `ncryptsec`, got `{0}`")]
183    UnexpectedHrp(String),
184    /// The decrypted bytes did not parse as a valid secp256k1 secret.
185    #[error(transparent)]
186    SecretKey(#[from] SecretKeyError),
187    /// OS RNG failed.
188    #[error(transparent)]
189    Rng(#[from] RngError),
190}
191
192/// An encrypted secret key, ready to be bech32-encoded as `ncryptsec1...`.
193///
194/// `Debug` redacts every byte so the value can be safely logged.
195///
196/// We deliberately do **not** implement `Copy`: even though every
197/// field is `Copy`-eligible, silently duplicating an encrypted secret
198/// across the stack would violate the principle that callers must
199/// reason explicitly about every place the ciphertext lives. Use
200/// [`Clone`] when you really need a second owned copy.
201#[allow(
202    missing_copy_implementations,
203    reason = "see doc comment: explicit Clone keeps callers honest about the secret's lifetime"
204)]
205#[derive(Clone, PartialEq, Eq)]
206pub struct EncryptedSecretKey {
207    log_n: u8,
208    salt: [u8; SALT_BYTES],
209    nonce: [u8; NONCE_BYTES],
210    security: KeySecurity,
211    ciphertext: [u8; CIPHERTEXT_BYTES],
212}
213
214impl std::fmt::Debug for EncryptedSecretKey {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        f.debug_struct("EncryptedSecretKey")
217            .field("log_n", &self.log_n)
218            .field("security", &self.security)
219            .field("salt", &"<redacted>")
220            .field("nonce", &"<redacted>")
221            .field("ciphertext", &"<redacted>")
222            .finish()
223    }
224}
225
226impl Drop for EncryptedSecretKey {
227    /// Best-effort zeroize on drop.
228    ///
229    /// `ciphertext` is encrypted, but `salt` and `nonce` are
230    /// privacy-relevant inputs to the scrypt KDF — wiping them
231    /// reduces the chance that a freed allocation hands them to
232    /// the next allocator caller. The compiler may still elide
233    /// some writes under aggressive optimisation; the
234    /// [`zeroize`](https://docs.rs/zeroize) crate's volatile-write
235    /// implementation is the best portable mitigation we have.
236    fn drop(&mut self) {
237        self.salt.zeroize();
238        self.nonce.zeroize();
239        self.ciphertext.zeroize();
240        // `log_n` and `security` are `Copy` enums/integers; nothing
241        // we can do about them, and they're not sensitive on their own.
242    }
243}
244
245impl EncryptedSecretKey {
246    /// Encrypt `secret` under `password` with a random salt and nonce.
247    ///
248    /// `log_n` is the scrypt cost parameter; spec recommends `16` for
249    /// client UX and `21+` for cold-storage. `security` is recorded as
250    /// AAD so any later tamper is caught by the AEAD.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`Nip49Error::LogNTooLarge`] / [`Nip49Error::InvalidParams`] when
255    /// scrypt rejects the cost, or [`Nip49Error::Rng`] when the OS RNG is
256    /// unavailable.
257    pub fn encrypt(
258        secret: &SecretKey,
259        password: &str,
260        log_n: u8,
261        security: KeySecurity,
262    ) -> Result<Self, Nip49Error> {
263        let mut salt = [0u8; SALT_BYTES];
264        let mut nonce = [0u8; NONCE_BYTES];
265        rng::fill_bytes(&mut salt)?;
266        rng::fill_bytes(&mut nonce)?;
267        Self::encrypt_with(secret, password, log_n, security, salt, nonce)
268    }
269
270    /// Encrypt with a caller-supplied salt and nonce.
271    ///
272    /// **Use with care**: reusing a `(password, salt, nonce)` triple
273    /// across two encryptions defeats the AEAD. Reserved for
274    /// known-answer test vectors and deterministic fixtures.
275    ///
276    /// # Errors
277    ///
278    /// See [`Self::encrypt`].
279    pub fn encrypt_with(
280        secret: &SecretKey,
281        password: &str,
282        log_n: u8,
283        security: KeySecurity,
284        salt: [u8; SALT_BYTES],
285        nonce: [u8; NONCE_BYTES],
286    ) -> Result<Self, Nip49Error> {
287        if log_n > MAX_LOG_N {
288            return Err(Nip49Error::LogNTooLarge(log_n));
289        }
290        let sym_key = derive_symmetric_key(password, &salt, log_n)?;
291        let cipher = XChaCha20Poly1305::new(&sym_key.into());
292
293        let aad_byte = [security as u8];
294        let secret_bytes = secret.to_byte_array();
295        let payload = Payload {
296            msg: &secret_bytes,
297            aad: &aad_byte,
298        };
299        let ct = cipher
300            .encrypt(&nonce.into(), payload)
301            .map_err(|_| Nip49Error::Aead)?;
302        // Length is statically `SECRET_BYTES + TAG_BYTES`; convert to
303        // the fixed-size array form.
304        let ciphertext: [u8; CIPHERTEXT_BYTES] = ct
305            .as_slice()
306            .try_into()
307            .expect("XChaCha20-Poly1305 always emits plaintext+16 bytes");
308
309        Ok(Self {
310            log_n,
311            salt,
312            nonce,
313            security,
314            ciphertext,
315        })
316    }
317
318    /// Recover the secret key with the same password used to encrypt.
319    ///
320    /// # Errors
321    ///
322    /// Returns [`Nip49Error::Aead`] when the password is wrong or the
323    /// ciphertext / security byte was tampered with, [`Nip49Error::SecretKey`]
324    /// when the decrypted bytes do not encode a valid secp256k1 scalar,
325    /// or [`Nip49Error::InvalidParams`] / [`Nip49Error::Scrypt`] for derivation
326    /// failures.
327    pub fn decrypt(&self, password: &str) -> Result<SecretKey, Nip49Error> {
328        let sym_key = derive_symmetric_key(password, &self.salt, self.log_n)?;
329        let cipher = XChaCha20Poly1305::new(&sym_key.into());
330        let aad_byte = [self.security as u8];
331        let payload = Payload {
332            msg: &self.ciphertext,
333            aad: &aad_byte,
334        };
335        let plaintext = cipher
336            .decrypt(&self.nonce.into(), payload)
337            .map_err(|_| Nip49Error::Aead)?;
338        let secret_array: [u8; SECRET_BYTES] =
339            plaintext
340                .as_slice()
341                .try_into()
342                .map_err(|_| Nip49Error::InvalidLength {
343                    got: plaintext.len(),
344                })?;
345        SecretKey::from_byte_array(secret_array).map_err(Nip49Error::from)
346    }
347
348    /// Cost parameter the secret was encrypted under.
349    #[must_use]
350    pub const fn log_n(&self) -> u8 {
351        self.log_n
352    }
353
354    /// Security level the author declared at encryption time.
355    #[must_use]
356    pub const fn security(&self) -> KeySecurity {
357        self.security
358    }
359
360    /// Encode as the spec-mandated `ncryptsec1...` bech32 string.
361    ///
362    /// # Errors
363    ///
364    /// Returns [`Nip49Error::Encode`] only if the underlying `bech32` crate
365    /// rejects the payload, which on a 91-byte buffer is statically
366    /// impossible.
367    pub fn to_bech32(&self) -> Result<String, Nip49Error> {
368        let bytes = self.to_payload_bytes();
369        let hrp = bech32::Hrp::parse(HRP).expect("HRP is statically valid");
370        Ok(bech32::encode::<Bech32>(hrp, &bytes)?)
371    }
372
373    /// Decode from the `ncryptsec1...` bech32 string.
374    ///
375    /// # Errors
376    ///
377    /// Returns [`Nip49Error::Decode`] for malformed bech32, [`Nip49Error::UnexpectedHrp`]
378    /// when the HRP is not `ncryptsec`, [`Nip49Error::InvalidLength`] when
379    /// the decoded payload is not exactly [`PAYLOAD_BYTES`] long, and
380    /// [`Nip49Error::UnsupportedVersion`] / [`Nip49Error::InvalidKeySecurity`]
381    /// when the payload header is malformed.
382    pub fn from_bech32(input: &str) -> Result<Self, Nip49Error> {
383        let parsed = CheckedHrpstring::new::<Bech32>(input)?;
384        let hrp = parsed.hrp().to_lowercase();
385        if hrp != HRP {
386            return Err(Nip49Error::UnexpectedHrp(hrp));
387        }
388        let bytes: Vec<u8> = parsed.byte_iter().collect();
389        Self::from_payload_bytes(&bytes)
390    }
391
392    fn to_payload_bytes(&self) -> [u8; PAYLOAD_BYTES] {
393        // Build the 91-byte payload by concatenation. We accumulate
394        // into a `Vec` and convert at the end so the layout reads as
395        // a list of fields (no offset arithmetic), avoiding the
396        // `clippy::indexing_slicing` lint while keeping the wire
397        // layout obvious.
398        let mut buf: Vec<u8> = Vec::with_capacity(PAYLOAD_BYTES);
399        buf.push(VERSION_BYTE);
400        buf.push(self.log_n);
401        buf.extend_from_slice(&self.salt);
402        buf.extend_from_slice(&self.nonce);
403        buf.push(self.security as u8);
404        buf.extend_from_slice(&self.ciphertext);
405        // The pushes above sum to exactly `PAYLOAD_BYTES`; the
406        // `try_into` cannot fail.
407        buf.try_into()
408            .expect("PAYLOAD_BYTES = 1 + 1 + SALT_BYTES + NONCE_BYTES + 1 + CIPHERTEXT_BYTES")
409    }
410
411    fn from_payload_bytes(bytes: &[u8]) -> Result<Self, Nip49Error> {
412        if bytes.len() != PAYLOAD_BYTES {
413            return Err(Nip49Error::InvalidLength { got: bytes.len() });
414        }
415        // Walk the buffer with `split_first_chunk::<N>` so every slice
416        // is a fixed-size array reference. The length check above
417        // proves each split below has enough bytes left, so the
418        // subsequent `expect`s are statically unreachable.
419        let (head, rest) = bytes
420            .split_first_chunk::<2>()
421            .expect("PAYLOAD_BYTES >= 2 (version + log_n)");
422        let &[version, log_n] = head;
423        if version != VERSION_BYTE {
424            return Err(Nip49Error::UnsupportedVersion(version));
425        }
426        let (salt, rest) = rest
427            .split_first_chunk::<SALT_BYTES>()
428            .expect("PAYLOAD_BYTES leaves SALT_BYTES after the 2-byte header");
429        let (nonce, rest) = rest
430            .split_first_chunk::<NONCE_BYTES>()
431            .expect("PAYLOAD_BYTES leaves NONCE_BYTES after the salt");
432        let (aad_chunk, ciphertext_slice) = rest
433            .split_first_chunk::<1>()
434            .expect("PAYLOAD_BYTES leaves >= 1 byte after the nonce");
435        let &[aad_byte] = aad_chunk;
436        let security = KeySecurity::from_byte(aad_byte)?;
437        let ciphertext = ciphertext_slice
438            .first_chunk::<CIPHERTEXT_BYTES>()
439            .copied()
440            .expect("PAYLOAD_BYTES leaves CIPHERTEXT_BYTES after the AAD byte");
441        Ok(Self {
442            log_n,
443            salt: *salt,
444            nonce: *nonce,
445            security,
446            ciphertext,
447        })
448    }
449}
450
451fn derive_symmetric_key(
452    password: &str,
453    salt: &[u8; SALT_BYTES],
454    log_n: u8,
455) -> Result<[u8; SYM_KEY_BYTES], Nip49Error> {
456    // Spec § Symmetric Encryption Key derivation: NFKC-normalise the
457    // password before scrypt. This guarantees that visually-identical
458    // strings entered on different OS / IME stacks produce the same
459    // symmetric key.
460    let normalized: String = password.nfkc().collect();
461    // `scrypt 0.12` dropped the output-length argument from `Params::new`
462    // (the length is fixed at the caller's `&mut` buffer in
463    // `scrypt::scrypt`). The previous (log_n, r, p, len) signature is
464    // gone.
465    let params =
466        ScryptParams::new(log_n, SCRYPT_R, SCRYPT_P).map_err(|err| Nip49Error::InvalidParams {
467            log_n,
468            message: err.to_string(),
469        })?;
470    let mut key = [0u8; SYM_KEY_BYTES];
471    scrypt(normalized.as_bytes(), salt, &params, &mut key)
472        .map_err(|err| Nip49Error::Scrypt(err.to_string()))?;
473    Ok(key)
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::Keys;
480
481    fn fixture_secret() -> SecretKey {
482        let bytes = [
483            0x35, 0x01, 0x45, 0x41, 0x35, 0x01, 0x45, 0x41, 0x35, 0x01, 0x45, 0x41, 0x35, 0x01,
484            0x45, 0x41, 0x35, 0x01, 0x45, 0x41, 0x3f, 0xef, 0xb0, 0x22, 0x27, 0xe4, 0x49, 0xe5,
485            0x7c, 0xf4, 0xd3, 0xa3,
486        ];
487        SecretKey::from_byte_array(bytes).expect("32-byte fixture is a valid scalar")
488    }
489
490    #[test]
491    fn round_trip_default_log_n() {
492        let secret = fixture_secret();
493        // Use log_n=4 (16 iterations) — fastest possible — so tests run
494        // in milliseconds instead of seconds.
495        let encrypted =
496            EncryptedSecretKey::encrypt(&secret, "correct horse", 4, KeySecurity::Weak).unwrap();
497        let recovered = encrypted.decrypt("correct horse").unwrap();
498        assert_eq!(recovered.to_byte_array(), secret.to_byte_array());
499    }
500
501    #[test]
502    fn wrong_password_is_rejected() {
503        let secret = fixture_secret();
504        let encrypted =
505            EncryptedSecretKey::encrypt(&secret, "right password", 4, KeySecurity::Strong).unwrap();
506        let err = encrypted.decrypt("WRONG password").unwrap_err();
507        assert!(matches!(err, Nip49Error::Aead));
508    }
509
510    #[test]
511    fn bech32_round_trip() {
512        let secret = fixture_secret();
513        let encrypted =
514            EncryptedSecretKey::encrypt(&secret, "p", 4, KeySecurity::Untracked).unwrap();
515        let s = encrypted.to_bech32().unwrap();
516        assert!(s.starts_with("ncryptsec1"));
517        let parsed = EncryptedSecretKey::from_bech32(&s).unwrap();
518        assert_eq!(parsed, encrypted);
519        assert_eq!(
520            parsed.decrypt("p").unwrap().to_byte_array(),
521            secret.to_byte_array()
522        );
523    }
524
525    #[test]
526    fn rejects_wrong_hrp() {
527        // Generate a bech32 string with a different HRP.
528        let hrp = bech32::Hrp::parse("nsec").unwrap();
529        let bogus = bech32::encode::<Bech32>(hrp, &[0u8; PAYLOAD_BYTES]).unwrap();
530        let err = EncryptedSecretKey::from_bech32(&bogus).unwrap_err();
531        assert!(matches!(err, Nip49Error::UnexpectedHrp(s) if s == "nsec"));
532    }
533
534    #[test]
535    fn rejects_unsupported_version() {
536        // Manually craft a payload with version byte = 0x01 (reserved).
537        let mut payload = [0u8; PAYLOAD_BYTES];
538        payload[0] = 0x01;
539        // log_n + everything else can stay at zero; `from_payload_bytes`
540        // surfaces the version mismatch first.
541        let hrp = bech32::Hrp::parse(HRP).unwrap();
542        let bogus = bech32::encode::<Bech32>(hrp, &payload).unwrap();
543        let err = EncryptedSecretKey::from_bech32(&bogus).unwrap_err();
544        assert!(matches!(err, Nip49Error::UnsupportedVersion(0x01)));
545    }
546
547    #[test]
548    fn rejects_invalid_key_security() {
549        let mut payload = [0u8; PAYLOAD_BYTES];
550        payload[0] = VERSION_BYTE;
551        // Salt + nonce stay zeros; the AAD byte (right after version,
552        // log_n, salt, nonce) is at offset `2 + 16 + 24 = 42`.
553        payload[42] = 0x09;
554        let hrp = bech32::Hrp::parse(HRP).unwrap();
555        let bogus = bech32::encode::<Bech32>(hrp, &payload).unwrap();
556        let err = EncryptedSecretKey::from_bech32(&bogus).unwrap_err();
557        assert!(matches!(err, Nip49Error::InvalidKeySecurity(0x09)));
558    }
559
560    #[test]
561    fn nfkc_normalization_makes_passwords_equivalent() {
562        let secret = fixture_secret();
563        // "ÅΩẛ̣" composed two different ways. Spec § Test Data § Password
564        // Unicode Normalization gives both forms; they must produce the
565        // same symmetric key.
566        let composed = "\u{00C5}\u{03A9}\u{1E69}";
567        let decomposed = "\u{212B}\u{2126}\u{1E9B}\u{0323}";
568        let salt = [0xab; SALT_BYTES];
569        let nonce = [0xcd; NONCE_BYTES];
570
571        let from_composed =
572            EncryptedSecretKey::encrypt_with(&secret, composed, 4, KeySecurity::Weak, salt, nonce)
573                .unwrap();
574        let from_decomposed = EncryptedSecretKey::encrypt_with(
575            &secret,
576            decomposed,
577            4,
578            KeySecurity::Weak,
579            salt,
580            nonce,
581        )
582        .unwrap();
583
584        assert_eq!(from_composed.ciphertext, from_decomposed.ciphertext);
585        assert_eq!(
586            from_decomposed.decrypt(composed).unwrap().to_byte_array(),
587            secret.to_byte_array(),
588        );
589    }
590
591    #[test]
592    fn log_n_above_cap_is_rejected() {
593        let secret = fixture_secret();
594        let err = EncryptedSecretKey::encrypt(&secret, "p", MAX_LOG_N + 1, KeySecurity::Weak)
595            .unwrap_err();
596        assert!(matches!(err, Nip49Error::LogNTooLarge(_)));
597    }
598
599    /// NIP-49 spec § Test Data fixture.
600    ///
601    /// `ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p`
602    /// password=`nostr`, `log_n=16` → secret hex
603    /// `3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683`.
604    ///
605    /// We pin this one as a regression because every other implementation
606    /// (rust-nostr, nostr-tools, nak) uses the same fixture, and a
607    /// silent drift here would cripple ncryptsec interop. The slow
608    /// scrypt cost makes this test ~100ms — leave it gated under
609    /// `--release` runs only? No: even at debug it's fast enough on
610    /// modern hardware (<1 s) and the safety guarantee is worth it.
611    #[test]
612    fn spec_vector_decrypt() {
613        let ncryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
614        let parsed = EncryptedSecretKey::from_bech32(ncryptsec).unwrap();
615        assert_eq!(parsed.log_n(), 16);
616        let secret = parsed.decrypt("nostr").unwrap();
617        let expected_hex = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683";
618        let actual_hex = secret.to_hex();
619        assert_eq!(actual_hex, expected_hex);
620
621        // And the recovered key is a usable Nostr identity.
622        let _keys = Keys::from_secret_key(secret);
623    }
624}