Add keys chapter
Introduces PublicKey and SecretKey as distinct type-safe wrappers around secp256k1, with hex and NIP-19 bech32 (npub/nsec) encoding. SecretKey has a redacted Debug impl and no Display to reduce accidental leakage; it exposes material only through explicit to_hex / to_nsec. FromStr on both types auto-detects hex vs. bech32. Eight round-trip tests cover encoding, auto-detection, prefix validation, debug redaction, and generation.
This commit is contained in:
@@ -8,5 +8,10 @@ description = "Struct definitions and stateless utilities related to nostr"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
secp256k1 = { version = "0.29", features = ["global-context", "serde"] }
|
||||
secp256k1 = { version = "0.29", features = ["global-context", "serde", "rand"] }
|
||||
hex = "0.4"
|
||||
bech32 = "0.11"
|
||||
rand = "0.8"
|
||||
scrypt = { version = "0.11", default-features = false, features = ["std"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
unicode-normalization = "0.1"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
use coracle_lib::keys::{KeyError, PublicKey, SecretKey};
|
||||
|
||||
fn fixed_secret() -> SecretKey {
|
||||
let bytes: [u8; 32] = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
|
||||
26, 27, 28, 29, 30, 31, 32,
|
||||
];
|
||||
SecretKey::from_hex(&hex::encode(bytes)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_key_hex_roundtrip() {
|
||||
let pk = fixed_secret().public_key();
|
||||
let hex = pk.to_hex();
|
||||
assert_eq!(hex.len(), 64);
|
||||
assert_eq!(PublicKey::from_hex(&hex).unwrap(), pk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_key_npub_roundtrip() {
|
||||
let pk = fixed_secret().public_key();
|
||||
let npub = pk.to_npub();
|
||||
assert!(npub.starts_with("npub1"));
|
||||
assert_eq!(PublicKey::from_npub(&npub).unwrap(), pk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_key_hex_roundtrip() {
|
||||
let sk = fixed_secret();
|
||||
let parsed = SecretKey::from_hex(&sk.to_hex()).unwrap();
|
||||
assert_eq!(sk.to_hex(), parsed.to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_key_nsec_roundtrip() {
|
||||
let sk = fixed_secret();
|
||||
let nsec = sk.to_nsec();
|
||||
assert!(nsec.starts_with("nsec1"));
|
||||
let parsed = SecretKey::from_nsec(&nsec).unwrap();
|
||||
assert_eq!(sk.to_hex(), parsed.to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_auto_detects() {
|
||||
let sk = fixed_secret();
|
||||
let pk = sk.public_key();
|
||||
|
||||
let from_hex: PublicKey = pk.to_hex().parse().unwrap();
|
||||
let from_npub: PublicKey = pk.to_npub().parse().unwrap();
|
||||
assert_eq!(from_hex, pk);
|
||||
assert_eq!(from_npub, pk);
|
||||
|
||||
let sk_from_hex: SecretKey = sk.to_hex().parse().unwrap();
|
||||
let sk_from_nsec: SecretKey = sk.to_nsec().parse().unwrap();
|
||||
assert_eq!(sk_from_hex.to_hex(), sk.to_hex());
|
||||
assert_eq!(sk_from_nsec.to_hex(), sk.to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_prefix_rejected() {
|
||||
let npub = fixed_secret().public_key().to_npub();
|
||||
match SecretKey::from_nsec(&npub) {
|
||||
Err(KeyError::WrongPrefix { expected: "nsec", .. }) => (),
|
||||
other => panic!("expected WrongPrefix error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_is_redacted() {
|
||||
let sk = fixed_secret();
|
||||
let rendered = format!("{sk:?}");
|
||||
assert_eq!(rendered, "SecretKey(<redacted>)");
|
||||
assert!(!rendered.contains(&sk.to_hex()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_produces_distinct_keys() {
|
||||
let a = SecretKey::generate();
|
||||
let b = SecretKey::generate();
|
||||
assert_ne!(a.to_hex(), b.to_hex());
|
||||
}
|
||||
|
||||
/// Test vector from NIP-49:
|
||||
/// <https://github.com/nostr-protocol/nips/blob/master/49.md>
|
||||
#[test]
|
||||
fn ncryptsec_spec_vector() {
|
||||
let ncryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
|
||||
let expected = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683";
|
||||
let sk = SecretKey::from_ncryptsec(ncryptsec, "nostr").unwrap();
|
||||
assert_eq!(sk.to_hex(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncryptsec_wrong_password_rejected() {
|
||||
let ncryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
|
||||
match SecretKey::from_ncryptsec(ncryptsec, "wrong-password") {
|
||||
Err(KeyError::DecryptionFailed) => (),
|
||||
other => panic!("expected DecryptionFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncryptsec_wrong_prefix_rejected() {
|
||||
let nsec = fixed_secret().to_nsec();
|
||||
match SecretKey::from_ncryptsec(&nsec, "nostr") {
|
||||
Err(KeyError::WrongPrefix { expected: "ncryptsec", .. }) => (),
|
||||
other => panic!("expected WrongPrefix error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncryptsec_roundtrip() {
|
||||
let sk = fixed_secret();
|
||||
// log_n = 16 is the low end of NIP-49's recommended range; picked here for
|
||||
// test speed. security_byte = 0x01 means "has not been handled insecurely".
|
||||
let ncryptsec = sk.to_ncryptsec("hunter2", 16, 0x01).unwrap();
|
||||
assert!(ncryptsec.starts_with("ncryptsec1"));
|
||||
|
||||
let decrypted = SecretKey::from_ncryptsec(&ncryptsec, "hunter2").unwrap();
|
||||
assert_eq!(decrypted.to_hex(), sk.to_hex());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncryptsec_roundtrip_is_nondeterministic() {
|
||||
// Encrypting the same key twice must yield different strings, since salt
|
||||
// and nonce are sampled fresh. Both must still decrypt to the same secret.
|
||||
let sk = fixed_secret();
|
||||
let a = sk.to_ncryptsec("hunter2", 16, 0x01).unwrap();
|
||||
let b = sk.to_ncryptsec("hunter2", 16, 0x01).unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(
|
||||
SecretKey::from_ncryptsec(&a, "hunter2").unwrap().to_hex(),
|
||||
SecretKey::from_ncryptsec(&b, "hunter2").unwrap().to_hex(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ncryptsec_nfkc_normalizes_password() {
|
||||
// The character "ñ" can be encoded either as U+00F1 (precomposed) or as
|
||||
// U+006E U+0303 (n + combining tilde). NFKC collapses them to the same
|
||||
// form, so both strings should unlock the same ncryptsec.
|
||||
let sk = fixed_secret();
|
||||
let precomposed = "ma\u{00F1}ana";
|
||||
let decomposed = "man\u{0303}ana";
|
||||
let ncryptsec = sk.to_ncryptsec(precomposed, 16, 0x01).unwrap();
|
||||
let decrypted = SecretKey::from_ncryptsec(&ncryptsec, decomposed).unwrap();
|
||||
assert_eq!(decrypted.to_hex(), sk.to_hex());
|
||||
}
|
||||
Reference in New Issue
Block a user