Files
GoblinPay/crates/gp-nostr/src/identity.rs
T
2ro bd67bfc92e GoblinPay: receive-only Grin payment server
A self-hostable Grin payment server for shops, creators, and sites: show a
code, Grin lands in your wallet, with a verifiable Grin payment proof on
receive. Workspace crates (gp-core / gp-nostr / gp-server / gp-wallet /
gp-goblin-sender), a WooCommerce connector, a hosted /pay/<token> checkout,
and NIP-44 v3 gift-wrapped payment DMs carried over the Nym mixnet. All
secrets are read from the environment; none are committed.
2026-07-02 04:29:54 -04:00

310 lines
11 KiB
Rust

//! The server's Nostr payment identity: a random standalone nsec or an
//! imported one, NEVER derived from the Grin mnemonic (the two-secrets rule:
//! the mnemonic is the money secret, the nsec is the payment identity; losing
//! one must never compromise or resurrect the other). Mirrors Goblin's
//! `nostr/identity.rs`, trimmed to what a headless daemon needs.
//!
//! Resolution order (see [`load_or_create`]):
//! 1. `GP_NSEC` — plaintext key from the environment (mounted-file variant
//! supported by gp-core). Used as-is, never persisted.
//! 2. `GP_NCRYPTSEC` — NIP-49 encrypted key, unlocked with the wallet
//! password. Never persisted.
//! 3. Neither set — load `<data_dir>/nostr/identity.json`, or generate a
//! fresh RANDOM key and persist it NIP-49 encrypted (wallet password),
//! file mode 0600.
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use gp_core::config::Config;
use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity};
use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32};
use serde::{Deserialize, Serialize};
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade; same as Goblin).
const NCRYPTSEC_LOG_N: u8 = 16;
/// Identity file stored at `<data_dir>/nostr/identity.json`. Only the
/// encrypted key and the public key: a headless till has no NIP-05 name, no
/// contact metadata.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ServerIdentity {
pub ver: u8,
/// NIP-49 encrypted secret key (bech32 ncryptsec).
pub ncryptsec: String,
/// Public key, bech32 npub (plaintext for logs and the QR).
pub npub: String,
}
#[derive(Debug)]
pub enum IdentityError {
/// Missing or inconsistent configuration (fail fast at startup).
Config(String),
/// Key parse/encrypt/decrypt failure (includes wrong password).
Key(String),
/// Filesystem failure persisting or reading the identity file.
Io(String),
}
impl fmt::Display for IdentityError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IdentityError::Config(m) => write!(f, "identity config error: {m}"),
IdentityError::Key(m) => write!(f, "identity key error: {m}"),
IdentityError::Io(m) => write!(f, "identity io error: {m}"),
}
}
}
impl std::error::Error for IdentityError {}
impl ServerIdentity {
pub const FILE_NAME: &'static str = "identity.json";
/// Identity file path for a data dir.
pub fn path(data_dir: &Path) -> PathBuf {
data_dir.join("nostr").join(Self::FILE_NAME)
}
/// Load the identity file if it exists and parses.
pub fn load(data_dir: &Path) -> Option<ServerIdentity> {
let raw = fs::read_to_string(Self::path(data_dir)).ok()?;
serde_json::from_str(&raw).ok()
}
/// Persist with owner-only permissions (the ncryptsec blob must not be
/// world readable: a local attacker could grind the password offline).
pub fn save(&self, data_dir: &Path) -> Result<(), IdentityError> {
let dir = data_dir.join("nostr");
fs::create_dir_all(&dir).map_err(|e| IdentityError::Io(format!("create {dir:?}: {e}")))?;
restrict(&dir, 0o700)?;
let raw = serde_json::to_string_pretty(self)
.map_err(|e| IdentityError::Io(format!("serialize identity: {e}")))?;
let path = Self::path(data_dir);
fs::write(&path, raw).map_err(|e| IdentityError::Io(format!("write {path:?}: {e}")))?;
restrict(&path, 0o600)?;
Ok(())
}
/// Unlock the stored key with the wallet password.
pub fn unlock(&self, password: &str) -> Result<Keys, IdentityError> {
decrypt_ncryptsec(&self.ncryptsec, password)
}
fn from_keys(keys: &Keys, password: &str) -> Result<ServerIdentity, IdentityError> {
let encrypted = EncryptedSecretKey::new(
keys.secret_key(),
password,
NCRYPTSEC_LOG_N,
KeySecurity::Medium,
)
.map_err(|e| IdentityError::Key(format!("encrypt failed: {e}")))?;
Ok(ServerIdentity {
ver: 1,
ncryptsec: encrypted
.to_bech32()
.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?,
npub: keys
.public_key()
.to_bech32()
.map_err(|e| IdentityError::Key(format!("bech32 failed: {e}")))?,
})
}
}
/// Resolve the identity keys from the configuration (see the module doc for
/// the order). Fails fast on a missing wallet password whenever the at-rest
/// encryption needs one.
pub fn load_or_create(cfg: &Config) -> Result<Keys, IdentityError> {
// 1. Plaintext nsec from the environment: authoritative, not persisted.
if let Some(nsec) = &cfg.nsec {
let secret = SecretKey::parse(nsec.reveal().trim())
.map_err(|e| IdentityError::Key(format!("invalid GP_NSEC: {e}")))?;
return Ok(Keys::new(secret));
}
let password = cfg
.wallet_password
.as_ref()
.ok_or_else(|| {
IdentityError::Config(
"GP_WALLET_PASSWORD (or _FILE) is required to unlock or persist the \
Nostr identity (set GP_NSEC to bypass at-rest encryption)"
.into(),
)
})?
.reveal()
.to_string();
// 2. NIP-49 encrypted key from the environment: unlocked, not persisted.
if let Some(ncryptsec) = &cfg.ncryptsec {
return decrypt_ncryptsec(ncryptsec.reveal().trim(), &password);
}
// 3. Persisted identity, or a fresh RANDOM key (never seed-derived).
let data_dir = Path::new(&cfg.data_dir);
if let Some(identity) = ServerIdentity::load(data_dir) {
return identity.unlock(&password);
}
let keys = Keys::generate();
ServerIdentity::from_keys(&keys, &password)?.save(data_dir)?;
Ok(keys)
}
fn decrypt_ncryptsec(ncryptsec: &str, password: &str) -> Result<Keys, IdentityError> {
let encrypted = EncryptedSecretKey::from_bech32(ncryptsec)
.map_err(|e| IdentityError::Key(format!("invalid ncryptsec: {e}")))?;
let secret = encrypted
.decrypt(password)
.map_err(|_| IdentityError::Key("wrong password for ncryptsec".into()))?;
Ok(Keys::new(secret))
}
/// chmod, failing fast (Unix only; the daemon targets Linux servers).
fn restrict(path: &Path, mode: u32) -> Result<(), IdentityError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode))
.map_err(|e| IdentityError::Io(format!("chmod {path:?}: {e}")))
}
#[cfg(not(unix))]
{
let _ = (path, mode);
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
use gp_core::config::Secret;
use super::*;
/// Self-cleaning unique temp dir (no extra dev-deps).
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> TempDir {
static N: AtomicU32 = AtomicU32::new(0);
let path = std::env::temp_dir().join(format!(
"gp-nostr-id-{tag}-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
TempDir(path)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn cfg(dir: &TempDir) -> Config {
Config {
data_dir: dir.0.to_str().unwrap().to_string(),
wallet_password: Some(Secret::new("hunter2".into())),
..Config::default()
}
}
#[test]
fn generates_persists_and_reloads_the_same_key() {
let dir = TempDir::new("gen");
let cfg = cfg(&dir);
let first = load_or_create(&cfg).unwrap();
let second = load_or_create(&cfg).unwrap();
assert_eq!(first.public_key(), second.public_key());
// Encrypted at rest: no bech32 nsec in the file.
let raw = fs::read_to_string(ServerIdentity::path(&dir.0)).unwrap();
let nsec = first.secret_key().to_bech32().unwrap();
assert!(!raw.contains(&nsec), "identity file leaks the nsec");
assert!(raw.contains("ncryptsec1"), "key must be NIP-49 encrypted");
}
#[cfg(unix)]
#[test]
fn identity_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("perm");
load_or_create(&cfg(&dir)).unwrap();
let meta = fs::metadata(ServerIdentity::path(&dir.0)).unwrap();
assert_eq!(
meta.permissions().mode() & 0o077,
0,
"identity.json must be 0600"
);
}
#[test]
fn wrong_password_fails_and_never_regenerates() {
let dir = TempDir::new("wrongpw");
let mut c = cfg(&dir);
let keys = load_or_create(&c).unwrap();
c.wallet_password = Some(Secret::new("not-it".into()));
// A wrong password must be a hard error, not a silent fresh identity
// (payers hold the old npub; regenerating would strand their sends).
assert!(load_or_create(&c).is_err());
c.wallet_password = Some(Secret::new("hunter2".into()));
assert_eq!(load_or_create(&c).unwrap().public_key(), keys.public_key());
}
#[test]
fn imports_nsec_without_persisting() {
let dir = TempDir::new("nsec");
let external = Keys::generate();
let mut c = cfg(&dir);
c.nsec = Some(Secret::new(external.secret_key().to_bech32().unwrap()));
c.wallet_password = None; // not needed on this path
let keys = load_or_create(&c).unwrap();
assert_eq!(keys.public_key(), external.public_key());
assert!(
!ServerIdentity::path(&dir.0).exists(),
"env-provided keys must not be written to disk"
);
}
#[test]
fn imports_ncryptsec_from_env() {
let dir = TempDir::new("ncryptsec");
let external = Keys::generate();
let encrypted = EncryptedSecretKey::new(
external.secret_key(),
"hunter2",
NCRYPTSEC_LOG_N,
KeySecurity::Medium,
)
.unwrap();
let mut c = cfg(&dir);
c.ncryptsec = Some(Secret::new(encrypted.to_bech32().unwrap()));
let keys = load_or_create(&c).unwrap();
assert_eq!(keys.public_key(), external.public_key());
assert!(!ServerIdentity::path(&dir.0).exists());
}
#[test]
fn missing_password_fails_fast() {
let dir = TempDir::new("nopw");
let mut c = cfg(&dir);
c.wallet_password = None;
let err = load_or_create(&c).unwrap_err();
assert!(err.to_string().contains("GP_WALLET_PASSWORD"), "{err}");
}
#[test]
fn random_identities_are_independent() {
// Fresh entropy every time — nothing chains identities to each other
// (or to any wallet seed; there is no derivation path at all).
let a = load_or_create(&cfg(&TempDir::new("ind-a"))).unwrap();
let b = load_or_create(&cfg(&TempDir::new("ind-b"))).unwrap();
assert_ne!(a.public_key(), b.public_key());
}
}