Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "bip85_extended"
version = "1.1.0"
version = "1.2.0"
authors = [
"Rita Kitic <rikitau@protonmail.com>",
"Jules Azad EMERY <ethicnology@pm.me>",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ This work is sponsored by [Bull Bitcoin](https://bullbitcoin.com) [<img
- [x] [PWD base64](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#user-content-PWD_BASE64)
- [x] [PWD base85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#user-content-PWD_BASE85)
- [ ] [DICE](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#user-content-DICE)
- [x] [Nostr](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#nostr)


## Flutter bindings
Expand Down
3 changes: 3 additions & 0 deletions examples/simple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ fn main() {

let xprv = bip85_extended::to_xprv(&secp, &root, 0).unwrap();
println!("Derived extended private key:\n{}", xprv);

let nsec = bip85_extended::to_nostr(&secp, &root, 1, 1).unwrap();
println!("Nostr nsec (identity=1, account=1):\n{}", nsec);
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub mod drng;
pub mod error;
pub mod hex;
pub mod mnemonic;
pub mod nostr;
pub mod pwd_base64;
pub mod pwd_base85;
pub mod wif;
Expand All @@ -37,6 +38,7 @@ pub use drng::*;
pub use error::Error;
pub use hex::*;
pub use mnemonic::*;
pub use nostr::*;
pub use pwd_base64::*;
pub use pwd_base85::*;
pub use wif::*;
Expand Down
86 changes: 86 additions & 0 deletions src/nostr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use super::Error;
use bitcoin::bech32::{self, Bech32, Hrp};
use bitcoin::bip32::{ChildNumber, DerivationPath};
use bitcoin::{bip32::Xpriv, key::Secp256k1, secp256k1};

const NOSTR_APPLICATION_NUMBER: ChildNumber = ChildNumber::Hardened { index: 9000 };

/// Derive a Nostr private key as an `nsec` Bech32 string (NIP19) using BIP85.
///
/// See [specs](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#nostr)
/// for more info.
///
/// Path format: `m/83696968'/9000'/{identity}'/{account}'`.
///
/// Per the spec, `identity = 0'` is reserved for future protocol use and
/// `account = 0'` is reserved across all identities for key management
/// operations; usable keys start at `identity >= 1'` and `account >= 1'`.
/// This function will still derive zero-indexed children — callers that want
/// to enforce the "usable" convention must check the arguments themselves.
///
/// Both `identity` and `account` must be lower than `0x80000000`
/// (hardened-index range).
///
/// ### Example
/// ```rust
/// use bip85_extended::*;
/// use bitcoin::{bip32::Xpriv, key::Secp256k1};
/// use std::str::FromStr;
///
/// let root = Xpriv::from_str("xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb").unwrap();
/// let secp = Secp256k1::new();
/// let nsec = to_nostr(&secp, &root, 1, 1).unwrap();
/// assert_eq!(nsec, "nsec1254dr4tclcdujf7we9sjv5t99vru2tw7gqtezx7z80y4x45qwhlsmxapst");
/// ```
pub fn to_nostr<C: secp256k1::Signing>(
secp: &Secp256k1<C>,
root: &Xpriv,
identity: u32,
account: u32,
) -> Result<String, Error> {
let key = to_nostr_bytes(secp, root, identity, account)?;
let hrp = Hrp::parse("nsec").expect("nsec is a valid HRP");
Ok(bech32::encode::<Bech32>(hrp, &key).expect("32-byte payload fits in Bech32 limits"))
}

/// Derive the 32-byte Nostr private key bytes using BIP85, without Bech32 encoding.
///
/// This is the most significant 32 bytes of the BIP85 HMAC output for path
/// `m/83696968'/9000'/{identity}'/{account}'`.
///
/// ### Example
/// ```rust
/// use bip85_extended::*;
/// use bitcoin::{bip32::Xpriv, key::Secp256k1, hex::DisplayHex};
/// use std::str::FromStr;
///
/// let root = Xpriv::from_str("xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb").unwrap();
/// let secp = Secp256k1::new();
/// let key = to_nostr_bytes(&secp, &root, 1, 1).unwrap();
/// assert_eq!(
/// key.to_lower_hex_string(),
/// "552ad1d578fe1bc927cec9612651652b07c52dde4017911bc23bc953568075ff",
/// );
/// ```
pub fn to_nostr_bytes<C: secp256k1::Signing>(
secp: &Secp256k1<C>,
root: &Xpriv,
identity: u32,
account: u32,
) -> Result<[u8; 32], Error> {
if identity >= 0x80000000 {
return Err(Error::InvalidIndex(identity));
}
if account >= 0x80000000 {
return Err(Error::InvalidIndex(account));
}
let path = DerivationPath::from(vec![
NOSTR_APPLICATION_NUMBER,
ChildNumber::from_hardened_idx(identity).unwrap(),
ChildNumber::from_hardened_idx(account).unwrap(),
]);
let data = crate::derive(secp, root, &path)?;
let mut out = [0u8; 32];
out.copy_from_slice(&data[..32]);
Ok(out)
}
171 changes: 171 additions & 0 deletions tests/nostr_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
use bip85_extended::*;
use bitcoin::bech32::{self, Bech32, Hrp};
use bitcoin::bip32::Xpriv;
use bitcoin::hex::DisplayHex;
use bitcoin::secp256k1::Secp256k1;
use std::str::FromStr;

// Official BIP85 Nostr (application 9000') test vectors from
// https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki#nostr
const MASTER_XPRV: &str =
"xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb";

struct SpecVector {
identity: u32,
account: u32,
entropy_hex: &'static str,
nsec: &'static str,
}

const SPEC_VECTORS: &[SpecVector] = &[
SpecVector {
identity: 1,
account: 1,
entropy_hex: "552ad1d578fe1bc927cec9612651652b07c52dde4017911bc23bc953568075ff",
nsec: "nsec1254dr4tclcdujf7we9sjv5t99vru2tw7gqtezx7z80y4x45qwhlsmxapst",
},
SpecVector {
identity: 1,
account: 2,
entropy_hex: "4fd36c0061a65db375b4350f44bb62a6d7f716ee93bd0f59887ac50b35fa8b96",
nsec: "nsec1flfkcqrp5ewmxad5x585fwmz5mtlw9hwjw7s7kvg0tzskd063wtq34wlgr",
},
SpecVector {
identity: 2,
account: 1,
entropy_hex: "b2d3b48992d46f98beac0196c4e258417087e467dbec1503342785368f4402c2",
nsec: "nsec1ktfmfzvj63he304vqxtvfcjcg9cg0er8m0kp2qe5y7zndr6yqtpq7q5y44",
},
];

fn root() -> Xpriv {
Xpriv::from_str(MASTER_XPRV).unwrap()
}

#[test]
fn test_nostr_spec_vectors() {
let secp = Secp256k1::new();
let root = root();
for v in SPEC_VECTORS {
let bytes = to_nostr_bytes(&secp, &root, v.identity, v.account).unwrap();
assert_eq!(
bytes.to_lower_hex_string(),
v.entropy_hex,
"entropy mismatch for identity={}, account={}",
v.identity,
v.account,
);

let nsec = to_nostr(&secp, &root, v.identity, v.account).unwrap();
assert_eq!(
nsec, v.nsec,
"nsec mismatch for identity={}, account={}",
v.identity, v.account,
);
}
}

#[test]
fn test_nostr_bytes_and_nsec_are_consistent() {
// The string returned by to_nostr() must be a Bech32 encoding (HRP "nsec")
// of exactly the 32 bytes returned by to_nostr_bytes().
let secp = Secp256k1::new();
let root = root();
for v in SPEC_VECTORS {
let bytes = to_nostr_bytes(&secp, &root, v.identity, v.account).unwrap();
let nsec = to_nostr(&secp, &root, v.identity, v.account).unwrap();

let (hrp, payload) = bech32::decode(&nsec).unwrap();
assert_eq!(hrp, Hrp::parse("nsec").unwrap());
assert_eq!(&payload[..], &bytes[..]);

// Re-encoding the bytes ourselves must yield the same string.
let reencoded = bech32::encode::<Bech32>(Hrp::parse("nsec").unwrap(), &bytes).unwrap();
assert_eq!(reencoded, nsec);
}
}

#[test]
fn test_nostr_nsec_format() {
// A NIP19 nsec is always: HRP "nsec" + separator '1' + 52 data chars +
// 6 checksum chars = 63 characters total, all lower-case.
let secp = Secp256k1::new();
let root = root();
let nsec = to_nostr(&secp, &root, 1, 1).unwrap();
assert_eq!(nsec.len(), 63);
assert!(nsec.starts_with("nsec1"));
assert_eq!(nsec, nsec.to_lowercase());
}

#[test]
fn test_nostr_rejects_non_hardened_indices() {
let secp = Secp256k1::new();
let root = root();
let bad = 0x80000000u32;
assert_eq!(
to_nostr(&secp, &root, bad, 1).unwrap_err(),
Error::InvalidIndex(bad),
);
assert_eq!(
to_nostr(&secp, &root, 1, bad).unwrap_err(),
Error::InvalidIndex(bad),
);
assert_eq!(
to_nostr_bytes(&secp, &root, bad, 1).unwrap_err(),
Error::InvalidIndex(bad),
);
assert_eq!(
to_nostr_bytes(&secp, &root, 1, bad).unwrap_err(),
Error::InvalidIndex(bad),
);
// u32::MAX is also out of range.
assert!(to_nostr(&secp, &root, u32::MAX, 1).is_err());
}

#[test]
fn test_nostr_accepts_boundary_hardened_index() {
// 0x7FFFFFFF is the largest valid hardened-child input (it derives the
// m/.../2147483647' child). It must succeed and produce a 63-char nsec.
let secp = Secp256k1::new();
let root = root();
let nsec = to_nostr(&secp, &root, 0x7FFFFFFF, 0x7FFFFFFF).unwrap();
assert_eq!(nsec.len(), 63);
assert!(nsec.starts_with("nsec1"));
}

#[test]
fn test_nostr_reserved_zero_indices_still_derive() {
// The spec marks identity/account 0 as "reserved" but doesn't make them
// invalid; the reference (bipsea) lets them through. Mirror that contract.
let secp = Secp256k1::new();
let root = root();
let nsec_00 = to_nostr(&secp, &root, 0, 0).unwrap();
let nsec_01 = to_nostr(&secp, &root, 0, 1).unwrap();
let nsec_10 = to_nostr(&secp, &root, 1, 0).unwrap();
let nsec_11 = to_nostr(&secp, &root, 1, 1).unwrap();
assert!(nsec_00.starts_with("nsec1"));
// Each (identity, account) pair must produce a distinct key.
let unique = [&nsec_00, &nsec_01, &nsec_10, &nsec_11];
for i in 0..unique.len() {
for j in (i + 1)..unique.len() {
assert_ne!(unique[i], unique[j]);
}
}
}

#[test]
fn test_nostr_distinct_masters_produce_distinct_keys() {
// Different master xprvs must produce different Nostr keys for the same
// (identity, account). This catches accidental dropping of the master.
let secp = Secp256k1::new();
let a = Xpriv::from_str(MASTER_XPRV).unwrap();
// A different mainnet xprv, BIP-32 test vector 1 chain m.
let b = Xpriv::from_str(
"xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi",
)
.unwrap();
assert_ne!(
to_nostr(&secp, &a, 1, 1).unwrap(),
to_nostr(&secp, &b, 1, 1).unwrap(),
);
}
Loading