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:
@@ -13,7 +13,7 @@ which are community-authored specifications.
|
||||
|
||||
## What this book covers
|
||||
|
||||
This book is both a tutorial and the source code for the `coracle` family of Rust crates:
|
||||
This book is the source code for the `coracle` family of Rust crates:
|
||||
|
||||
- **coracle-lib** — Core types and stateless utilities: events, keys, tags, filters, and
|
||||
serialization. Everything you need to understand and manipulate nostr data.
|
||||
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
# Keys
|
||||
|
||||
In most of the internet, your identity is something you apply for. You fill out a form,
|
||||
a server writes a row in a database, and from then on that server decides what your
|
||||
identity is — whether you can still log in, what you're allowed to say, and whether you
|
||||
still exist at all. Nostr inverts this. Your identity is a **secp256k1 keypair** that you
|
||||
generate on your own computer. No server issues it, no server can revoke it, and no
|
||||
server has to be consulted to verify that a message came from you.
|
||||
|
||||
The public half of that keypair is your name. Anywhere a nostr event says "this came
|
||||
from `pubkey`," it means "this message has a valid Schnorr signature under that 32-byte
|
||||
public key." The secret half is your ability to speak as that name. Whoever holds it can
|
||||
publish events that the whole network will attribute to you — and whoever loses it
|
||||
loses the identity, permanently.
|
||||
|
||||
This chapter introduces two Rust types, `PublicKey` and `SecretKey`, that make this
|
||||
primitive safe to work with. We'll cover how to generate keys, how to encode them as
|
||||
hex and as the user-facing `npub` / `nsec` bech32 strings defined by
|
||||
[NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md), and the handful of
|
||||
small design decisions that reduce the chances of accidentally leaking secret material.
|
||||
|
||||
## Why secp256k1
|
||||
|
||||
Nostr uses the same elliptic curve as Bitcoin: `secp256k1`. Public keys are **x-only**,
|
||||
following [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) — we
|
||||
store only the 32-byte x-coordinate of the curve point and drop the parity bit, which
|
||||
gives us fixed-width public keys and lets signatures be Schnorr rather than ECDSA. The
|
||||
upshot for this chapter is simple: both a public key and a secret key fit in exactly
|
||||
32 bytes, and every nostr library in the world agrees on that.
|
||||
|
||||
We already pulled the `secp256k1` crate into `coracle-lib` in the previous chapter for
|
||||
event signature verification. In this chapter we'll wrap its key types in our own so
|
||||
that users of the library never have to think about the raw primitives.
|
||||
|
||||
## The module
|
||||
|
||||
Let's register the module in the crate root and start a new file for the key types:
|
||||
|
||||
```rust {file=coracle-lib/src/lib.rs}
|
||||
pub mod keys;
|
||||
```
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
//! Nostr cryptographic identity: `PublicKey` and `SecretKey`.
|
||||
//!
|
||||
//! Both types wrap the corresponding `secp256k1` primitive and add support for
|
||||
//! hex and NIP-19 bech32 (`npub` / `nsec`) encoding. `SecretKey` is deliberately
|
||||
//! awkward to print: it has no `Display` impl and its `Debug` is redacted, so
|
||||
//! the material can only escape through an explicit `to_hex` or `to_nsec` call.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use bech32::{Bech32, Hrp};
|
||||
use secp256k1::{rand, SECP256K1};
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
We'll use a single error type for everything in the module. One enum makes every way
|
||||
a key operation can go wrong visible in one place, and lets callers match on a single
|
||||
type instead of juggling a hierarchy.
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
/// Errors that can occur when parsing or validating a nostr key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum KeyError {
|
||||
/// The input was not valid hex, or not the right length.
|
||||
InvalidHex,
|
||||
/// The input was not a valid bech32 string.
|
||||
InvalidBech32,
|
||||
/// The bech32 human-readable prefix was not what we expected
|
||||
/// (e.g. an `npub` passed where an `nsec` was required).
|
||||
WrongPrefix { expected: &'static str, found: String },
|
||||
/// The bytes decoded successfully but are not a valid key on the curve.
|
||||
InvalidKey,
|
||||
/// A NIP-49 ciphertext failed to authenticate. Either the password is
|
||||
/// wrong or the payload has been corrupted — the two are indistinguishable.
|
||||
DecryptionFailed,
|
||||
}
|
||||
|
||||
impl fmt::Display for KeyError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
KeyError::InvalidHex => write!(f, "invalid hex encoding"),
|
||||
KeyError::InvalidBech32 => write!(f, "invalid bech32 encoding"),
|
||||
KeyError::WrongPrefix { expected, found } => {
|
||||
write!(f, "wrong bech32 prefix: expected {expected}, found {found}")
|
||||
}
|
||||
KeyError::InvalidKey => write!(f, "invalid secp256k1 key"),
|
||||
KeyError::DecryptionFailed => write!(f, "ncryptsec decryption failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for KeyError {}
|
||||
```
|
||||
|
||||
## Public keys
|
||||
|
||||
A public key is a point on the secp256k1 curve, and because nostr uses x-only
|
||||
representation it fits in 32 bytes. We wrap `secp256k1::XOnlyPublicKey` in a newtype so
|
||||
that our own methods — hex encoding, bech32 encoding, parsing — hang off a type that
|
||||
belongs to us rather than a foreign crate.
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
/// A nostr public key: the x-coordinate of a secp256k1 point, 32 bytes.
|
||||
///
|
||||
/// This is the "name" half of a nostr identity. It's safe to log, share, and
|
||||
/// store — it identifies an author but grants no ability to speak as them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct PublicKey(secp256k1::XOnlyPublicKey);
|
||||
|
||||
impl PublicKey {
|
||||
/// The raw 32 bytes of the x-only public key.
|
||||
pub fn as_bytes(&self) -> [u8; 32] {
|
||||
self.0.serialize()
|
||||
}
|
||||
|
||||
/// Encode as a lowercase 64-character hex string.
|
||||
/// This is the form used inside event JSON.
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.as_bytes())
|
||||
}
|
||||
|
||||
/// Parse from a 64-character hex string.
|
||||
pub fn from_hex(s: &str) -> Result<Self, KeyError> {
|
||||
let bytes = hex::decode(s).map_err(|_| KeyError::InvalidHex)?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(KeyError::InvalidHex);
|
||||
}
|
||||
let inner = secp256k1::XOnlyPublicKey::from_slice(&bytes)
|
||||
.map_err(|_| KeyError::InvalidKey)?;
|
||||
Ok(PublicKey(inner))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The hex form is what lives inside event JSON — the `"pubkey"` field we saw in the
|
||||
previous chapter. It's machine-friendly but visually indistinguishable from any other
|
||||
64 hex characters, which is why nostr also defines a user-facing encoding.
|
||||
|
||||
### NIP-19 and `npub`
|
||||
|
||||
NIP-19 wraps the raw 32 bytes in bech32, the same encoding Bitcoin uses for Segwit
|
||||
addresses. Bech32 gives us two things: a **human-readable prefix** that tells you what
|
||||
kind of data you're looking at (`npub` for a public key, `nsec` for a secret), and a
|
||||
checksum that catches copy-paste errors. An `npub` string is what a user will copy out
|
||||
of their nostr client, paste into a profile page, or share in a bio.
|
||||
|
||||
We use `bech32 = "0.11"`, which takes a prefix, a byte slice, and a variant. NIP-19
|
||||
specifies the original bech32 (not bech32m), so we pass the `Bech32` marker.
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
const NPUB_HRP: Hrp = Hrp::parse_unchecked("npub");
|
||||
const NSEC_HRP: Hrp = Hrp::parse_unchecked("nsec");
|
||||
|
||||
impl PublicKey {
|
||||
/// Encode as an `npub1…` bech32 string per NIP-19.
|
||||
pub fn to_npub(&self) -> String {
|
||||
bech32::encode::<Bech32>(NPUB_HRP, &self.as_bytes())
|
||||
.expect("npub encoding cannot fail for 32 bytes")
|
||||
}
|
||||
|
||||
/// Parse an `npub1…` bech32 string.
|
||||
pub fn from_npub(s: &str) -> Result<Self, KeyError> {
|
||||
let (hrp, data) = bech32::decode(s).map_err(|_| KeyError::InvalidBech32)?;
|
||||
if hrp != NPUB_HRP {
|
||||
return Err(KeyError::WrongPrefix {
|
||||
expected: "npub",
|
||||
found: hrp.to_string(),
|
||||
});
|
||||
}
|
||||
if data.len() != 32 {
|
||||
return Err(KeyError::InvalidBech32);
|
||||
}
|
||||
let inner = secp256k1::XOnlyPublicKey::from_slice(&data)
|
||||
.map_err(|_| KeyError::InvalidKey)?;
|
||||
Ok(PublicKey(inner))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The HRP constants are parsed once at compile time via `Hrp::parse_unchecked`, which is
|
||||
safe here because `"npub"` and `"nsec"` are valid bech32 prefixes by construction.
|
||||
|
||||
### `Display` and `FromStr`
|
||||
|
||||
It's natural to want to print a `PublicKey` and to parse one back. The question is:
|
||||
which form? We pick hex for `Display` because that's what lives on the wire, and we
|
||||
make `FromStr` accept either encoding by sniffing the prefix first.
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
impl fmt::Display for PublicKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for PublicKey {
|
||||
type Err = KeyError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if s.starts_with("npub1") {
|
||||
Self::from_npub(s)
|
||||
} else {
|
||||
Self::from_hex(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This small bit of polymorphism is worth the handful of lines. Every time a user pastes
|
||||
a key into a config file or a CLI argument, you can call `.parse()` and get the right
|
||||
thing back without asking them which encoding they used.
|
||||
|
||||
## Secret keys
|
||||
|
||||
Secret keys are where the safety posture matters. A `secp256k1::SecretKey` is a 32-byte
|
||||
scalar that, together with the generator point, produces the corresponding public key.
|
||||
If it leaks — into a log file, a crash report, a debug print — the identity is lost
|
||||
forever. So our `SecretKey` type is designed to make leaking it *harder than keeping it
|
||||
safe*, not the other way around.
|
||||
|
||||
Three rules:
|
||||
|
||||
1. **No `Display`.** If `println!("{}", key)` compiled, someone would do it.
|
||||
2. **Redacted `Debug`.** `#[derive(Debug)]` on any struct that contains a `SecretKey`
|
||||
is a very common thing to write. We don't want that to dump the key.
|
||||
3. **No `Copy`.** Copying a secret key around makes it harder to reason about where
|
||||
the material lives.
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
/// A nostr secret key: the 32-byte scalar that lets you sign as a given identity.
|
||||
///
|
||||
/// Handle with care. This type deliberately has no `Display` impl and its `Debug`
|
||||
/// output is redacted. To extract the raw bytes you must call [`SecretKey::to_hex`]
|
||||
/// or [`SecretKey::to_nsec`] explicitly, which makes the leak points easy to audit.
|
||||
///
|
||||
/// The underlying `secp256k1::SecretKey` zeroes its memory on drop, so dropping a
|
||||
/// `SecretKey` does not leave the scalar sitting around in freed memory.
|
||||
#[derive(Clone)]
|
||||
pub struct SecretKey(secp256k1::SecretKey);
|
||||
|
||||
impl fmt::Debug for SecretKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("SecretKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice what's *not* there: no `impl Display`, no `#[derive(Debug)]`. A struct like
|
||||
`struct Signer { sk: SecretKey, pk: PublicKey }` can still derive `Debug` in calling
|
||||
code — it'll print `Signer { sk: SecretKey(<redacted>), pk: PublicKey(...) }`, which
|
||||
is exactly what we want.
|
||||
|
||||
### Generating keys
|
||||
|
||||
To generate a fresh identity we need a source of randomness. `secp256k1` re-exports
|
||||
`rand` when its `rand` feature is enabled, which gives us a process-wide-seeded
|
||||
`OsRng` without us having to pick a random crate version by hand. The shared
|
||||
`SECP256K1` context (also provided by `secp256k1`'s `global-context` feature) is
|
||||
blinded at first access as a side-channel countermeasure.
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
impl SecretKey {
|
||||
/// Generate a brand-new secret key from the operating system's RNG.
|
||||
pub fn generate() -> Self {
|
||||
let (sk, _pk) = SECP256K1.generate_keypair(&mut rand::thread_rng());
|
||||
SecretKey(sk)
|
||||
}
|
||||
|
||||
/// Derive the matching [`PublicKey`].
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
let keypair = secp256k1::Keypair::from_secret_key(SECP256K1, &self.0);
|
||||
let (xonly, _parity) = keypair.x_only_public_key();
|
||||
PublicKey(xonly)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`public_key()` is the one operation that always makes sense on a secret key: you need
|
||||
to know what identity you're speaking as. Everything else — signing, NIP-44 encryption
|
||||
— belongs in its own module and will consume a `&SecretKey` there.
|
||||
|
||||
### Encoding
|
||||
|
||||
The hex and bech32 methods mirror those on `PublicKey`, but we name them so that
|
||||
writing them is a conscious act.
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
impl SecretKey {
|
||||
/// Export as a 64-character lowercase hex string.
|
||||
///
|
||||
/// This is an explicit opt-in: once the key leaves this method, the caller
|
||||
/// is responsible for keeping it safe.
|
||||
pub fn to_hex(&self) -> String {
|
||||
hex::encode(self.0.secret_bytes())
|
||||
}
|
||||
|
||||
/// Parse from a 64-character hex string.
|
||||
pub fn from_hex(s: &str) -> Result<Self, KeyError> {
|
||||
let bytes = hex::decode(s).map_err(|_| KeyError::InvalidHex)?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(KeyError::InvalidHex);
|
||||
}
|
||||
let inner = secp256k1::SecretKey::from_slice(&bytes)
|
||||
.map_err(|_| KeyError::InvalidKey)?;
|
||||
Ok(SecretKey(inner))
|
||||
}
|
||||
|
||||
/// Encode as an `nsec1…` bech32 string per NIP-19.
|
||||
pub fn to_nsec(&self) -> String {
|
||||
bech32::encode::<Bech32>(NSEC_HRP, &self.0.secret_bytes())
|
||||
.expect("nsec encoding cannot fail for 32 bytes")
|
||||
}
|
||||
|
||||
/// Parse an `nsec1…` bech32 string.
|
||||
pub fn from_nsec(s: &str) -> Result<Self, KeyError> {
|
||||
let (hrp, data) = bech32::decode(s).map_err(|_| KeyError::InvalidBech32)?;
|
||||
if hrp != NSEC_HRP {
|
||||
return Err(KeyError::WrongPrefix {
|
||||
expected: "nsec",
|
||||
found: hrp.to_string(),
|
||||
});
|
||||
}
|
||||
if data.len() != 32 {
|
||||
return Err(KeyError::InvalidBech32);
|
||||
}
|
||||
let inner = secp256k1::SecretKey::from_slice(&data)
|
||||
.map_err(|_| KeyError::InvalidKey)?;
|
||||
Ok(SecretKey(inner))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`from_slice` on `secp256k1::SecretKey` enforces that the scalar is in range, so any
|
||||
bytes that decode successfully correspond to a valid key. That's our free curve
|
||||
validation.
|
||||
|
||||
### Encrypted storage with NIP-49
|
||||
|
||||
A raw `nsec` sitting in a config file is a liability: whoever reads the file has the
|
||||
identity. [NIP-49](https://github.com/nostr-protocol/nips/blob/master/49.md) defines a
|
||||
password-based wrapper for secret keys called `ncryptsec`. The scheme takes the 32
|
||||
secret bytes plus a user password, derives a symmetric key with scrypt, and encrypts
|
||||
the secret with XChaCha20-Poly1305. The result is a single bech32 string that starts
|
||||
with `ncryptsec1…` and is safe to persist to disk or sync between devices as long as
|
||||
the password stays out of the same backup.
|
||||
|
||||
The payload before bech32 encoding is exactly 91 bytes:
|
||||
|
||||
| Offset | Length | Field |
|
||||
| -----: | -----: | ------------------------------------------------------------ |
|
||||
| 0 | 1 | version (always `0x02`) |
|
||||
| 1 | 1 | scrypt `log_n` parameter |
|
||||
| 2 | 16 | salt |
|
||||
| 18 | 24 | XChaCha20-Poly1305 nonce |
|
||||
| 42 | 1 | key-security byte, authenticated as associated data |
|
||||
| 43 | 48 | ciphertext (32 bytes of key + 16-byte Poly1305 tag) |
|
||||
|
||||
Three small crates cover the primitives: `scrypt` for the KDF, `chacha20poly1305` for
|
||||
the AEAD cipher, and `unicode-normalization` because NIP-49 requires the password to
|
||||
be NFKC-normalized before being fed into scrypt (so users who type the same
|
||||
characters on different keyboards derive the same key).
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
use chacha20poly1305::aead::{Aead, Payload};
|
||||
use chacha20poly1305::{Key, KeyInit, XChaCha20Poly1305, XNonce};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
const NCRYPTSEC_HRP: Hrp = Hrp::parse_unchecked("ncryptsec");
|
||||
|
||||
impl SecretKey {
|
||||
/// Decrypt a NIP-49 `ncryptsec1…` string using the given password.
|
||||
///
|
||||
/// The password is NFKC-normalized before being passed to scrypt, per spec.
|
||||
/// An incorrect password is indistinguishable from a corrupted ciphertext —
|
||||
/// both surface as [`KeyError::DecryptionFailed`].
|
||||
pub fn from_ncryptsec(s: &str, password: &str) -> Result<Self, KeyError> {
|
||||
let (hrp, data) = bech32::decode(s).map_err(|_| KeyError::InvalidBech32)?;
|
||||
if hrp != NCRYPTSEC_HRP {
|
||||
return Err(KeyError::WrongPrefix {
|
||||
expected: "ncryptsec",
|
||||
found: hrp.to_string(),
|
||||
});
|
||||
}
|
||||
if data.len() != 91 || data[0] != 0x02 {
|
||||
return Err(KeyError::InvalidBech32);
|
||||
}
|
||||
|
||||
let log_n = data[1];
|
||||
let salt: [u8; 16] = data[2..18].try_into().unwrap();
|
||||
let nonce_bytes: [u8; 24] = data[18..42].try_into().unwrap();
|
||||
let security_byte = data[42];
|
||||
let ciphertext = &data[43..91];
|
||||
|
||||
let password_nfkc: String = password.nfkc().collect();
|
||||
let params = scrypt::Params::new(log_n, 8, 1, 32)
|
||||
.map_err(|_| KeyError::InvalidBech32)?;
|
||||
let mut derived = [0u8; 32];
|
||||
scrypt::scrypt(password_nfkc.as_bytes(), &salt, ¶ms, &mut derived)
|
||||
.map_err(|_| KeyError::DecryptionFailed)?;
|
||||
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(&derived));
|
||||
let nonce = XNonce::from_slice(&nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: ciphertext,
|
||||
aad: &[security_byte],
|
||||
},
|
||||
)
|
||||
.map_err(|_| KeyError::DecryptionFailed)?;
|
||||
|
||||
let inner = secp256k1::SecretKey::from_slice(&plaintext)
|
||||
.map_err(|_| KeyError::InvalidKey)?;
|
||||
Ok(SecretKey(inner))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The security byte is passed as AEAD associated data, so Poly1305 authenticates it
|
||||
alongside the ciphertext. If a storage layer flips that byte to mark a key as, say,
|
||||
"now known to have touched a hot wallet," the authentication tag will reject the
|
||||
change unless the payload is re-sealed.
|
||||
|
||||
The encryption side is the mirror image. The caller picks a `log_n` (NIP-49
|
||||
recommends a value between 16 and 22, with larger values costing proportionally more
|
||||
memory and CPU) and a security byte (`0x00` for "has been handled insecurely", `0x01`
|
||||
for "has not", `0x02` for "unknown"). Salt and nonce are freshly drawn from the OS
|
||||
RNG on every call, so encrypting the same key twice yields two different `ncryptsec`
|
||||
strings — which is the point.
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
impl SecretKey {
|
||||
/// Encrypt this key under the given password, producing a NIP-49 `ncryptsec1…`
|
||||
/// string.
|
||||
///
|
||||
/// `log_n` is the scrypt work factor (the spec recommends 16–22); `security_byte`
|
||||
/// records how carefully the key has been handled (`0x00` insecure, `0x01` secure,
|
||||
/// `0x02` unknown). The salt and nonce are sampled fresh from the OS RNG, so
|
||||
/// calling this twice on the same key returns two different strings — both of
|
||||
/// which decrypt to the same secret.
|
||||
pub fn to_ncryptsec(
|
||||
&self,
|
||||
password: &str,
|
||||
log_n: u8,
|
||||
security_byte: u8,
|
||||
) -> Result<String, KeyError> {
|
||||
use rand::RngCore;
|
||||
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce_bytes = [0u8; 24];
|
||||
let mut rng = rand::thread_rng();
|
||||
rng.fill_bytes(&mut salt);
|
||||
rng.fill_bytes(&mut nonce_bytes);
|
||||
|
||||
let password_nfkc: String = password.nfkc().collect();
|
||||
let params = scrypt::Params::new(log_n, 8, 1, 32)
|
||||
.map_err(|_| KeyError::InvalidKey)?;
|
||||
let mut derived = [0u8; 32];
|
||||
scrypt::scrypt(password_nfkc.as_bytes(), &salt, ¶ms, &mut derived)
|
||||
.map_err(|_| KeyError::InvalidKey)?;
|
||||
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(&derived));
|
||||
let nonce = XNonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: &self.0.secret_bytes(),
|
||||
aad: &[security_byte],
|
||||
},
|
||||
)
|
||||
.expect("XChaCha20-Poly1305 encryption is infallible for fixed-size input");
|
||||
|
||||
let mut payload = Vec::with_capacity(91);
|
||||
payload.push(0x02);
|
||||
payload.push(log_n);
|
||||
payload.extend_from_slice(&salt);
|
||||
payload.extend_from_slice(&nonce_bytes);
|
||||
payload.push(security_byte);
|
||||
payload.extend_from_slice(&ciphertext);
|
||||
|
||||
Ok(bech32::encode::<Bech32>(NCRYPTSEC_HRP, &payload)
|
||||
.expect("ncryptsec payload is always 91 bytes"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `FromStr`
|
||||
|
||||
```rust {file=coracle-lib/src/keys.rs}
|
||||
impl FromStr for SecretKey {
|
||||
type Err = KeyError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if s.starts_with("nsec1") {
|
||||
Self::from_nsec(s)
|
||||
} else {
|
||||
Self::from_hex(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Same shape as `PublicKey::from_str`. A secret-key string pasted into a CLI can be
|
||||
parsed without the caller knowing which encoding it's in.
|
||||
|
||||
## What's next
|
||||
|
||||
We now have a safe, ergonomic representation of nostr identity: public keys that are
|
||||
cheap to copy and print, secret keys that resist accidental exposure, and round-trip
|
||||
encoders for both the machine format and the user-facing one. In the next chapter we'll
|
||||
use these types to actually sign events — replacing the raw `secp256k1::SecretKey` that
|
||||
`Event::new` currently takes with our new `SecretKey`, and introducing the `Signer`
|
||||
abstraction that will later let us plug in remote, browser, and hardware signers.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Plan: Keys
|
||||
|
||||
## Topic Summary
|
||||
|
||||
Introduce nostr cryptographic identity via `PublicKey` and `SecretKey` — two
|
||||
type-safe Rust wrappers around `secp256k1` that support hex and NIP-19
|
||||
bech32 (`npub` / `nsec`) encoding, random generation, validation, and
|
||||
`FromStr` auto-detection. The chapter should make it hard to accidentally
|
||||
leak secret material: `SecretKey` has a redacted `Debug`, no `Display`, and
|
||||
requires an explicit getter to expose its bytes.
|
||||
|
||||
## Chapter Outline
|
||||
|
||||
1. **Identity as a keypair.** One paragraph framing: in nostr, identity *is*
|
||||
a secp256k1 keypair you generate yourself. The public key is your name;
|
||||
the secret key is your ability to speak as that name.
|
||||
2. **secp256k1, briefly.** Two or three sentences on why this curve and why
|
||||
x-only / Schnorr keys (BIP-340). No cryptography lecture.
|
||||
3. **The `PublicKey` type.** Newtype around `secp256k1::XOnlyPublicKey`,
|
||||
with hex and npub encoding. `Display` shows hex. `FromStr` auto-detects.
|
||||
4. **The `SecretKey` type.** Newtype around `secp256k1::SecretKey`. Redacted
|
||||
`Debug`, no `Display`, explicit `.to_hex()` / `.to_nsec()`. `FromStr`
|
||||
auto-detects. Random generation.
|
||||
5. **Deriving a public key from a secret key.** `SecretKey::public_key()`.
|
||||
6. **NIP-19: the `npub` / `nsec` bech32 envelope.** Short explanation of
|
||||
what bech32 is doing for us and why the user-facing encoding is
|
||||
different from the wire format.
|
||||
7. **Tests.** Round-trip coverage for hex, bech32, `FromStr` auto-detection,
|
||||
prefix validation, and debug redaction.
|
||||
|
||||
## API Design
|
||||
|
||||
In `coracle-lib/src/keys.rs`:
|
||||
|
||||
```rust
|
||||
pub struct PublicKey(secp256k1::XOnlyPublicKey);
|
||||
|
||||
impl PublicKey {
|
||||
pub fn from_hex(s: &str) -> Result<Self, KeyError>;
|
||||
pub fn to_hex(&self) -> String;
|
||||
pub fn from_npub(s: &str) -> Result<Self, KeyError>;
|
||||
pub fn to_npub(&self) -> String;
|
||||
pub fn as_bytes(&self) -> [u8; 32];
|
||||
}
|
||||
|
||||
impl fmt::Display for PublicKey { /* hex */ }
|
||||
impl FromStr for PublicKey { /* npub1 → from_npub, else from_hex */ }
|
||||
|
||||
pub struct SecretKey(secp256k1::SecretKey);
|
||||
|
||||
impl SecretKey {
|
||||
pub fn generate() -> Self;
|
||||
pub fn from_hex(s: &str) -> Result<Self, KeyError>;
|
||||
pub fn to_hex(&self) -> String; // explicit opt-in
|
||||
pub fn from_nsec(s: &str) -> Result<Self, KeyError>;
|
||||
pub fn to_nsec(&self) -> String; // explicit opt-in
|
||||
pub fn public_key(&self) -> PublicKey;
|
||||
}
|
||||
|
||||
impl fmt::Debug for SecretKey { /* "SecretKey(<redacted>)" */ }
|
||||
// NO Display impl — callers must choose to_hex() or to_nsec()
|
||||
impl FromStr for SecretKey { /* nsec1 → from_nsec, else from_hex */ }
|
||||
|
||||
pub enum KeyError {
|
||||
InvalidHex,
|
||||
InvalidBech32,
|
||||
WrongBech32Prefix { expected: &'static str, found: String },
|
||||
InvalidKey, // from secp256k1
|
||||
}
|
||||
```
|
||||
|
||||
## Code Organization
|
||||
|
||||
- All code lives in `coracle-lib/src/keys.rs`.
|
||||
- `coracle-lib/src/lib.rs` re-exports the public types: `pub mod keys;` and
|
||||
`pub use keys::{PublicKey, SecretKey, KeyError};`.
|
||||
- No cross-crate coupling. Signing (which consumes `SecretKey`) is the next
|
||||
chapter and will live in `coracle-signer`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
In `coracle-lib/Cargo.toml`:
|
||||
|
||||
- `secp256k1 = { version = "0.29", features = ["global-context", "rand", "serde"] }`
|
||||
— curve operations and a process-wide randomized context.
|
||||
- `hex = "0.4"` — already present; used for hex encoding.
|
||||
- `bech32 = "0.11"` — new; for NIP-19 envelopes. Use the `Bech32` variant
|
||||
(NIP-19 uses the original bech32, not bech32m).
|
||||
- `rand = "0.8"` — for `SecretKey::generate()`. secp256k1's `rand` feature
|
||||
pulls this in.
|
||||
|
||||
## Narrative Notes
|
||||
|
||||
- Open with the philosophy: generating a keypair is the only step required
|
||||
to "sign up" for nostr. No server, no email, no approval.
|
||||
- Explain the split between hex (wire format, inside event JSON) and bech32
|
||||
(human-facing, for display and copy-paste). Both encode the same 32
|
||||
bytes.
|
||||
- Justify why `SecretKey` has no `Display`: it's easy to accidentally
|
||||
`println!("{}", key)` and leak credentials into logs. Making the caller
|
||||
write `.to_hex()` or `.to_nsec()` forces a conscious decision.
|
||||
- Justify the redacted `Debug`: `#[derive(Debug)]` on a struct containing a
|
||||
`SecretKey` is common, and we don't want that to dump the key.
|
||||
- Briefly mention that `secp256k1::SecretKey` zeroes its memory on drop so
|
||||
we get that for free.
|
||||
- The chapter should not discuss HD wallets, mnemonics, or encrypted key
|
||||
storage — keep it about the protocol primitive.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Wrap `secp256k1` directly, don't depend on `bitcoin`.** Matches
|
||||
rust-nostr and keeps the dependency graph small.
|
||||
- **x-only public keys.** BIP-340 / Schnorr is the nostr standard; the
|
||||
underlying type is `XOnlyPublicKey`.
|
||||
- **No `Copy` on `SecretKey`.** Following rust-nostr's safety posture.
|
||||
`PublicKey` is `Copy` (it's just 32 public bytes).
|
||||
- **No `Display` on `SecretKey`, custom `Debug`.** The research flagged
|
||||
that rust-nostr derives `Debug` on `SecretKey` and relies on the
|
||||
composite `Keys` type to redact — which is a footgun. We'll redact at
|
||||
the type where the material lives.
|
||||
- **Errors as one enum.** A teaching resource benefits from a single
|
||||
`KeyError` users can match on. We won't split into per-operation error
|
||||
types.
|
||||
- **Auto-detecting `FromStr`.** Matches every reference implementation and
|
||||
makes round-tripping user input painless.
|
||||
- **`bech32 = 0.11`.** Current version; rust-nostr uses the same.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking. If the `bech32` 0.11 API turns out to be awkward for a
|
||||
teaching context, fall back to writing a small helper and explain it.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Research: Keys
|
||||
|
||||
## Topic Summary
|
||||
|
||||
The Keys chapter covers nostr cryptographic identity: `PublicKey` and `SecretKey` as
|
||||
type-safe Rust wrappers around secp256k1. Includes hex encoding, NIP-19 bech32
|
||||
encoding (`npub` / `nsec`), key generation, validation, `FromStr` auto-detection of
|
||||
encoding variants, and safety measures that reduce accidental leakage of secret
|
||||
material (redacted `Debug`, no `Display`, zeroization on drop).
|
||||
|
||||
## Philosophy
|
||||
|
||||
From `ref/building-nostr`:
|
||||
|
||||
- **Self-sovereignty via cryptography.** Nostr inverts platform-mediated identity:
|
||||
users unilaterally generate a secp256k1 keypair and become the authority over
|
||||
their own identity. No custodian, no issuer.
|
||||
- **Identity is subjective and relational.** Keys enable users to *act*; reputation
|
||||
and context are layered on top via follow graphs, NIP-05, kind-0 profiles.
|
||||
- **Publicity, not privacy.** Nostr is "publicity technology" — signed, verifiable,
|
||||
permanent. Keys enable authenticity, not confidentiality.
|
||||
- **Key handling is a big assumption.** Storage is deferred to signers (NIP-07
|
||||
browser, NIP-46 remote, NIP-55 Android). A library should make it hard to
|
||||
accidentally leak secret material.
|
||||
- **Key rotation is unsolved.** Losing a secret key means losing social identity
|
||||
permanently; this is a philosophical reason to take secret-key handling
|
||||
seriously at the type-system level.
|
||||
- **Zooko's triangle.** Hex/bech32 keys are secure and decentralized but not
|
||||
human-meaningful. The raw-key layer should be correct and minimal; naming is
|
||||
someone else's job.
|
||||
|
||||
Design implication: the `SecretKey` type should nudge users toward safety by
|
||||
default (no `Display`, redacted `Debug`, explicit method to extract material).
|
||||
|
||||
## Reference Implementation Analysis
|
||||
|
||||
### applesauce (TypeScript)
|
||||
|
||||
- Secret keys: raw `Uint8Array` (32 bytes). Public keys: 64-char lowercase hex.
|
||||
- No wrapper types — thin helpers (`normalizeToSecretKey`, `normalizeToPubkey`)
|
||||
accept hex, nsec, or NIP-19 pointer and return primitives.
|
||||
- Depends on `nostr-tools` for NIP-19, `@noble/secp256k1` underneath.
|
||||
- No debug redaction; no memory zeroing. Locking a signer just nulls its
|
||||
private field.
|
||||
- `isHexKey()` validates format (length + charset), not curve membership.
|
||||
|
||||
### ndk (TypeScript)
|
||||
|
||||
- `Hexpubkey` and `Npub` are branded string type aliases; secret keys are
|
||||
`Uint8Array` stored privately inside `NDKPrivateKeySigner`.
|
||||
- Auto-detects `nsec1` prefix vs. 64-char hex on signer construction, throws
|
||||
on invalid input.
|
||||
- Delegates all NIP-19 work to `nostr-tools/nip19`; uses `@noble/hashes` for
|
||||
hex↔bytes and `nostr-tools/nip49` for password-encrypted keys (`ncryptsec`).
|
||||
- Keys live in private fields with controlled accessors; no accidental logging.
|
||||
- Character-code loop for hex validation (avoids regex).
|
||||
|
||||
### nostr-gadgets (TypeScript)
|
||||
|
||||
- No dedicated key types at all. `pubkey` is `string` (hex), secrets are
|
||||
passed as `Uint8Array` at call sites and not stored.
|
||||
- `isHex32()` validates via `charCodeAt` against ASCII ranges.
|
||||
- `bareNostrUser()` normalizes npub / nprofile / hex into both hex and npub
|
||||
fields on demand.
|
||||
- Zero key material coupling — cryptography is entirely outsourced to
|
||||
`@nostr/tools`.
|
||||
|
||||
### nostr-tools (TypeScript)
|
||||
|
||||
- Minimal, explicit: `generateSecretKey() -> Uint8Array`, `getPublicKey(sk) -> hex`.
|
||||
- `NPub`/`NSec` branded string types for bech32 forms.
|
||||
- NIP-19 TLV encoding/decoding via `@scure/base` for nprofile/nevent/naddr.
|
||||
- Dependencies: `@noble/curves` (schnorr), `@noble/hashes`, `@scure/base`.
|
||||
Pure-JS, auditable, no OS bindings.
|
||||
- Type guards via regex. `validateEvent()` rejects pubkeys that don't match
|
||||
`^[a-f0-9]{64}$`.
|
||||
- No zeroization — JS has no safe wipe primitive.
|
||||
|
||||
### rust-nostr (Rust — most directly relevant)
|
||||
|
||||
- `PublicKey`: 32-byte wrapper around `secp256k1::XOnlyPublicKey`. Implements
|
||||
`Copy`.
|
||||
- `SecretKey`: newtype around `secp256k1::SecretKey`, **not** `Copy`,
|
||||
implements `Drop` via `non_secure_erase()` from secp256k1-rs.
|
||||
- `Keys`: composite holding both plus `secp256k1::Keypair`. Custom `Debug`
|
||||
impl that only shows the public key.
|
||||
- `SecretKey` derives `Debug` directly, which the authors note is a footgun —
|
||||
`Keys` compensates by overriding.
|
||||
- `parse()` method on both types auto-detects hex vs. bech32 (and NIP-21
|
||||
`nostr:` URIs on `PublicKey`). `FromStr` delegates to `parse()`.
|
||||
- Dependencies: `secp256k1 = "0.29"`, `bech32 = "0.11"`, `faster-hex = "0.10"`,
|
||||
`bitcoin_hashes`.
|
||||
- Global `SECP256K1: LazyLock<Secp256k1<All>>` context, randomized via OsRng
|
||||
at first access (blinding countermeasure).
|
||||
- NIP-19 handled in a separate `nip19.rs` module; serde serializes
|
||||
`PublicKey` as hex string.
|
||||
|
||||
### welshman (TypeScript)
|
||||
|
||||
- No wrapper types; keys are hex strings. `makeSecret()` / `getPubkey()` as
|
||||
pure functions.
|
||||
- `Pubkey` class wraps a pubkey hex with `.toNpub()` and `Pubkey.from(entity)`
|
||||
that decodes npub/nprofile and validates with regex.
|
||||
- `Nip01Signer` stores the secret in a private `#secret` field, exposing only
|
||||
async signing operations.
|
||||
- Depends on `nostr-tools` + `@noble/curves`.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- **secp256k1 everywhere.** All references use secp256k1 with Schnorr-style
|
||||
x-only public keys (BIP-340). This is fixed by the protocol.
|
||||
- **Dual encoding.** Hex for internal storage and wire format (event JSON);
|
||||
bech32 (NIP-19 `npub`/`nsec`) for user-facing display and copy-paste.
|
||||
- **Auto-detect on parse.** Most libraries accept either form and figure out
|
||||
which is which by prefix or length.
|
||||
- **Secret material isolation.** The TypeScript libraries hide secrets in
|
||||
private fields of a signer class. rust-nostr uses the type system: no
|
||||
`Copy`, explicit `to_secret_hex()` getter, redacted `Debug` on the
|
||||
composite, memory erase on `Drop`.
|
||||
- **No curve-membership validation up front.** Most validate format only and
|
||||
let the underlying crypto library reject invalid points lazily. rust-nostr
|
||||
is stricter because `secp256k1::SecretKey::from_slice` rejects out-of-range
|
||||
values on construction.
|
||||
- **NIP-19 is a separate concern.** Every library has a distinct NIP-19
|
||||
module; the key types depend on it for bech32 but the bech32 logic is not
|
||||
inside the key type itself.
|
||||
|
||||
## Considerations for Our Implementation
|
||||
|
||||
**Crate choice.** The `secp256k1` crate (already in `coracle-lib/Cargo.toml`
|
||||
per commit history) is the natural fit — widely audited, used by Bitcoin
|
||||
Core bindings, same choice as rust-nostr. Its `SecretKey::from_slice`
|
||||
enforces curve constraints. For bech32 we'll pull in `bech32 = "0.11"`. Hex
|
||||
can come from the existing `hex` crate (already a dependency).
|
||||
|
||||
**Types.**
|
||||
- `PublicKey` — newtype around `secp256k1::XOnlyPublicKey`. Implements
|
||||
`Copy`, `Debug`, `Display` (hex), `FromStr`, `Serialize`, `Deserialize`.
|
||||
- `SecretKey` — newtype around `secp256k1::SecretKey`. Does *not* implement
|
||||
`Copy` or `Display`. Custom `Debug` that prints `SecretKey(<redacted>)`.
|
||||
`FromStr` that auto-detects hex vs. `nsec1…`. Explicit `to_hex()` and
|
||||
`to_nsec()` methods that opt into exposing the material.
|
||||
|
||||
**Encoding.**
|
||||
- `PublicKey::to_hex()` / `to_npub()` and their `from_` counterparts.
|
||||
- `SecretKey::to_hex()` / `to_nsec()` / `from_*`.
|
||||
- `FromStr` auto-detects by checking `npub1`/`nsec1` prefix first, then
|
||||
falling back to 64-char hex.
|
||||
|
||||
**Safety posture.** Match rust-nostr's conventions where they're sensible in
|
||||
a teaching context, but don't hide secret-material extraction behind unsafe
|
||||
or complicated APIs — the chapter is pedagogical. Redact `Debug`, omit
|
||||
`Display`, require an explicit `.to_hex()` / `.to_nsec()` call.
|
||||
|
||||
**Out of scope for this chapter.**
|
||||
- Signing and verification (next chapter).
|
||||
- Key derivation, BIP-32, mnemonics (not part of the nostr protocol).
|
||||
- NIP-49 encrypted key storage (`ncryptsec`).
|
||||
- Remote / browser / Android signers (later chapters).
|
||||
- `nprofile`, `nevent`, `naddr` TLV pointers (chapter on entities/content).
|
||||
|
||||
Keep the chapter narrow: identity as a keypair, how to encode it, how to
|
||||
avoid leaking it.
|
||||
Reference in New Issue
Block a user