1#![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
61pub const HRP: &str = "ncryptsec";
63pub const VERSION_BYTE: u8 = 0x02;
65pub const SALT_BYTES: usize = 16;
67pub const NONCE_BYTES: usize = 24;
69const SYM_KEY_BYTES: usize = 32;
71const SECRET_BYTES: usize = 32;
73const TAG_BYTES: usize = 16;
75const CIPHERTEXT_BYTES: usize = SECRET_BYTES + TAG_BYTES;
77pub const PAYLOAD_BYTES: usize =
79 1 + 1 + SALT_BYTES + NONCE_BYTES + 1 + CIPHERTEXT_BYTES;
80
81pub const MAX_LOG_N: u8 = 30;
89
90const SCRYPT_P: u32 = 1;
93const SCRYPT_R: u32 = 8;
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
100#[repr(u8)]
101#[non_exhaustive]
102pub enum KeySecurity {
103 Weak = 0x00,
106 Strong = 0x01,
109 Untracked = 0x02,
111}
112
113impl KeySecurity {
114 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#[derive(Debug, Error)]
132#[non_exhaustive]
133pub enum Nip49Error {
134 #[error("invalid scrypt parameters (log_n={log_n}): {message}")]
138 InvalidParams {
139 log_n: u8,
141 message: String,
143 },
144 #[error("log_n {0} exceeds the supported cap of {MAX_LOG_N}")]
149 LogNTooLarge(u8),
150 #[error("scrypt key derivation failed: {0}")]
152 Scrypt(String),
153 #[error("XChaCha20-Poly1305 operation failed (wrong password or tampered ciphertext)")]
162 Aead,
163 #[error("ncryptsec payload is {got} bytes, expected {PAYLOAD_BYTES}")]
165 InvalidLength {
166 got: usize,
168 },
169 #[error("unsupported NIP-49 version byte: {0:#04x}")]
171 UnsupportedVersion(u8),
172 #[error("invalid key-security byte: {0:#04x}")]
174 InvalidKeySecurity(u8),
175 #[error("bech32 decoding failed: {0}")]
177 Decode(#[from] CheckedHrpstringError),
178 #[error("bech32 encoding failed: {0}")]
180 Encode(#[from] bech32::EncodeError),
181 #[error("expected HRP `ncryptsec`, got `{0}`")]
183 UnexpectedHrp(String),
184 #[error(transparent)]
186 SecretKey(#[from] SecretKeyError),
187 #[error(transparent)]
189 Rng(#[from] RngError),
190}
191
192#[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 fn drop(&mut self) {
237 self.salt.zeroize();
238 self.nonce.zeroize();
239 self.ciphertext.zeroize();
240 }
243}
244
245impl EncryptedSecretKey {
246 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 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 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 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 #[must_use]
350 pub const fn log_n(&self) -> u8 {
351 self.log_n
352 }
353
354 #[must_use]
356 pub const fn security(&self) -> KeySecurity {
357 self.security
358 }
359
360 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 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 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 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 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 let normalized: String = password.nfkc().collect();
461 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, ¶ms, &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 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 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 let mut payload = [0u8; PAYLOAD_BYTES];
538 payload[0] = 0x01;
539 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 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 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 #[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 let _keys = Keys::from_secret_key(secret);
623 }
624}