525 lines
21 KiB
Markdown
525 lines
21 KiB
Markdown
# 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, PartialOrd, Ord, 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))
|
||
}
|
||
|
||
/// Parse from raw bytes (exactly 32).
|
||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, KeyError> {
|
||
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.
|