Jason Tudisco 0058d9b421 feat(rust,nodejs): BIP-39 mnemonic phrases for Ed25519 identities
Adds the canonical wallet-style backup form (12 or 24 BIP-39 English
words) to both implementations. Wire-compatible — bit-identical seed
derivation across Rust and Node.

Semantics:
  • 24 words ↔ 32 bytes of entropy ↔ Ed25519 seed (bijection).
    Phrase ↔ seed round-trips exactly.
  • 12 words → 16 bytes of entropy → seed via
    SHA-256("kez-bip39-12-v1" || entropy). Deterministic but one-way;
    you can't recover a 12-word phrase from a seed.

The 12-word case is KEZ-specific (not interoperable with hardware-
wallet BIP-32 derivations). The 24-word case is. Both use the BIP-39
English wordlist so users can paper-back-up alongside other wallets.

We deliberately do NOT use BIP-39's PBKDF2 to_seed(passphrase) — that
produces a 64-byte seed for BIP-32 hierarchical derivation, which is
the wrong primitive for KEZ's single-identity-per-phrase model.

Rust (kez-core):
  • New mod mnemonic with MnemonicWords, generate_mnemonic,
    seed_from_mnemonic, mnemonic_from_seed_24.
  • Ed25519Secret::{from_mnemonic, generate_with_mnemonic}.
  • Dep: bip39 v2.0 with the `rand` feature for OS-RNG generation.
  • 9 unit tests, all green.

Rust (kez-cli):
  • `identity new --key-type ed25519` now also prints a 24-word phrase
    (default), with --mnemonic-words 12 to use 12 instead.
  • `identity mnemonic [--words 12|24]` — print a fresh phrase only.
  • `identity from-mnemonic "<phrase>"` — derive the key from a phrase.
  • `--mnemonic <phrase>` is now accepted everywhere `--ed25519-seed
    <hex>` was (claim create/dns, sigchain add/revoke/show/export/
    publish), mutually exclusive with --ed25519-seed and --nsec via
    clap conflicts_with_all.

Node (@kez/core):
  • New mnemonic.ts with the parallel API:
    generateMnemonic, seedFromMnemonic, mnemonicFromSeed24,
    ed25519FromMnemonic, generateEd25519WithMnemonic.
  • Dep: @scure/bip39 v2.x (note: import path is
    "@scure/bip39/wordlists/english.js" with the .js suffix in v2).
  • 8 vitest cases mirroring the Rust tests, all green.

Node (@kez/cli):
  • Same CLI surface added: identity new --mnemonic-words 12|24,
    identity mnemonic --words 12|24, identity from-mnemonic "<phrase>".
  • --mnemonic flag accepted alongside --nsec / --ed25519-seed in the
    flag parser, with mutex enforcement; loadSigner dispatches it.

Verified cross-implementation interop:
  • Same 24-word phrase → identical Ed25519 pubkey in Rust and Node.
  • Same 12-word phrase → identical pubkey (proves the SHA-256
    domain-tagged derivation matches byte-for-byte).
  • A claim signed in Rust with --mnemonic verifies in Node (Status:
    valid).

Tests: 114 Rust + 99 Node total, zero regressions.

TUTORIAL.md updated in both rust/ and nodejs/ with the new section in
"Pick your primary key" plus a callout that --mnemonic can substitute
for --ed25519-seed throughout the rest of the tutorial.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 17:41:01 -06:00

875 lines
29 KiB
Rust

use anyhow::{Context, Result, bail};
use chrono::Utc;
use clap::{Parser, Subcommand, ValueEnum};
use kez_channels::nostr as nostr_chan;
use kez_channels::{ChannelHit, Registry, parse_proof};
use kez_core::{
ClaimPayload, Ed25519Secret, Identity, MnemonicWords, NostrSecret, SignedClaim, Signer,
Sigchain, VerificationStatus, dns_txt_name, generate_mnemonic, mnemonic_from_seed_24,
seed_from_mnemonic,
};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Parser)]
#[command(name = "kez")]
#[command(about = "KEZ portable identity graph CLI")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Identity {
#[command(subcommand)]
command: IdentityCommand,
},
Claim {
#[command(subcommand)]
command: ClaimCommand,
},
Verify {
#[command(subcommand)]
command: VerifyCommand,
},
Sigchain {
#[command(subcommand)]
command: SigchainCommand,
},
}
#[derive(Debug, Subcommand)]
enum SigchainCommand {
/// Append an `add` event to the chain for the signing key.
Add {
subject: String,
#[arg(long, conflicts_with = "ed25519_seed")]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
#[arg(long)]
proof_url: Option<String>,
},
/// Append a `revoke` event to the chain for the signing key.
Revoke {
subject: String,
#[arg(long, conflicts_with = "ed25519_seed")]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
},
/// Print the chain (events one per line, plus a summary).
Show {
/// Read-only: identify the chain by primary alone, no key needed.
#[arg(long)]
primary: Option<String>,
#[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
mnemonic: Option<String>,
},
/// Export the chain in a portable format.
Export {
#[arg(long)]
primary: Option<String>,
#[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
mnemonic: Option<String>,
#[arg(long, value_enum, default_value_t = ExportFormat::Jsonl)]
format: ExportFormat,
#[arg(long)]
out: Option<PathBuf>,
},
/// Publish the chain to one or more destinations.
Publish {
#[arg(long)]
primary: Option<String>,
#[arg(long, conflicts_with_all = ["ed25519_seed", "primary"])]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with_all = ["nsec", "primary"])]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed", "primary"])]
mnemonic: Option<String>,
/// POST every event to a kez-sig-server at this URL.
#[arg(long)]
server: Option<String>,
/// Write the chain as JSONL to a file (you upload it yourself).
#[arg(long)]
web: bool,
/// Output path for `--web` (required if `--web` is set).
#[arg(long, requires = "web")]
out: Option<PathBuf>,
/// Print a DNS TXT zone record for `_kez-chain.<domain>` with the
/// compact bundle. You install it in your registrar.
#[arg(long)]
dns: Option<String>,
/// Publish the compact bundle as a kind-30078 event to this nostr
/// relay. Requires `--nsec` (not `--ed25519-seed`).
#[arg(long)]
nostr: Option<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ExportFormat {
Jsonl,
Compact,
}
#[derive(Debug, Subcommand)]
enum IdentityCommand {
/// Generate a new primary key. Defaults to nostr; pass --key-type
/// ed25519 for an Ed25519 key. For Ed25519, a 24-word BIP-39 phrase
/// is also printed (it's an equivalent representation of the seed).
/// Use --mnemonic-words 12 to generate from a 12-word phrase instead.
New {
#[arg(long, value_enum, default_value_t = KeyType::Nostr)]
key_type: KeyType,
/// 12 or 24. Only valid with --key-type ed25519. If unset, a
/// 24-word phrase is shown alongside the hex seed for Ed25519.
#[arg(long = "mnemonic-words")]
mnemonic_words: Option<u8>,
},
/// Print a fresh BIP-39 mnemonic phrase without deriving a key.
/// Useful for offline backup workflows.
Mnemonic {
/// 12 or 24. Default 24.
#[arg(long, default_value_t = 24)]
words: u8,
},
/// Derive and print the Ed25519 primary key from an existing
/// BIP-39 phrase (12 or 24 words, auto-detected).
FromMnemonic {
/// The phrase, quoted. Words separated by spaces. Case- and
/// whitespace-tolerant.
phrase: String,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum KeyType {
Nostr,
Ed25519,
}
#[derive(Debug, Subcommand)]
enum ClaimCommand {
Create {
subject: String,
#[arg(long, conflicts_with = "ed25519_seed")]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
format: OutputFormat,
#[arg(long)]
out: Option<PathBuf>,
},
Dns {
domain: String,
#[arg(long, conflicts_with = "ed25519_seed")]
nsec: Option<String>,
#[arg(long = "ed25519-seed", conflicts_with = "nsec")]
ed25519_seed: Option<String>,
#[arg(long, conflicts_with_all = ["nsec", "ed25519_seed"])]
mnemonic: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum VerifyCommand {
/// Verify a local proof file (developer helper).
File { path: PathBuf },
/// Verify any KEZ identifier (dns:, github:, ...) by dispatching to its channel.
Id { identifier: String },
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
Json,
Markdown,
Compact,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Identity { command } => match command {
IdentityCommand::New { key_type, mnemonic_words } => {
identity_new(key_type, mnemonic_words)
}
IdentityCommand::Mnemonic { words } => identity_mnemonic(words),
IdentityCommand::FromMnemonic { phrase } => identity_from_mnemonic(&phrase),
},
Command::Claim { command } => match command {
ClaimCommand::Create {
subject,
nsec,
ed25519_seed,
mnemonic,
format,
out,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
claim_create(subject, nsec, ed25519_seed, format, out)
}
ClaimCommand::Dns {
domain,
nsec,
ed25519_seed,
mnemonic,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
claim_dns(domain, nsec, ed25519_seed)
}
},
Command::Verify { command } => match command {
VerifyCommand::File { path } => verify_file(path),
VerifyCommand::Id { identifier } => verify_identifier(identifier).await,
},
Command::Sigchain { command } => sigchain_dispatch(command).await,
}
}
/// If the caller passed `--mnemonic <phrase>`, derive the Ed25519 seed
/// from it and return as hex. Otherwise return the `--ed25519-seed`
/// passthrough unchanged. Clap conflicts_with ensures both can't be
/// set at once.
fn resolve_seed(
ed25519_seed: Option<String>,
mnemonic: Option<String>,
) -> Result<Option<String>> {
match (ed25519_seed, mnemonic) {
(Some(s), None) => Ok(Some(s)),
(None, Some(phrase)) => {
let seed = seed_from_mnemonic(&phrase)
.map_err(|e| anyhow::anyhow!("invalid mnemonic: {e}"))?;
Ok(Some(hex::encode(seed)))
}
(None, None) => Ok(None),
(Some(_), Some(_)) => unreachable!("clap conflicts_with"),
}
}
async fn sigchain_dispatch(cmd: SigchainCommand) -> Result<()> {
match cmd {
SigchainCommand::Add {
subject,
nsec,
ed25519_seed,
mnemonic,
proof_url,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_add(subject, nsec, ed25519_seed, proof_url)
}
SigchainCommand::Revoke {
subject,
nsec,
ed25519_seed,
mnemonic,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_revoke(subject, nsec, ed25519_seed)
}
SigchainCommand::Show {
primary,
nsec,
ed25519_seed,
mnemonic,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_show(primary, nsec, ed25519_seed)
}
SigchainCommand::Export {
primary,
nsec,
ed25519_seed,
mnemonic,
format,
out,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_export(primary, nsec, ed25519_seed, format, out)
}
SigchainCommand::Publish {
primary,
nsec,
ed25519_seed,
mnemonic,
server,
web,
out,
dns,
nostr,
} => {
let ed25519_seed = resolve_seed(ed25519_seed, mnemonic)?;
sigchain_publish(primary, nsec, ed25519_seed, server, web, out, dns, nostr).await
}
}
}
fn identity_new(key_type: KeyType, mnemonic_words: Option<u8>) -> Result<()> {
match (key_type, mnemonic_words) {
(KeyType::Nostr, Some(_)) => {
bail!("--mnemonic-words is only valid with --key-type ed25519");
}
(KeyType::Nostr, None) => {
let secret = NostrSecret::generate();
println!("Primary: nostr:{}", secret.npub());
println!("Public: {}", secret.npub());
println!("Secret: {}", secret.nsec());
println!();
println!(
"Store the secret somewhere safe. Anyone with the nsec can sign as this identity."
);
}
(KeyType::Ed25519, words_opt) => {
// Default is 24 — the canonical bijective form (entropy IS seed).
let words = MnemonicWords::from_count(words_opt.unwrap_or(24) as usize)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (secret, phrase) = Ed25519Secret::generate_with_mnemonic(words)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let id = secret.identity()?;
println!("Primary: {id}");
println!("Public: {}", secret.pubkey_hex());
println!("Secret: {} (32-byte seed)", secret.seed_hex());
println!("Mnemonic ({} words): \"{}\"", words.count(), phrase);
println!();
match words {
MnemonicWords::TwentyFour => println!(
"The 24-word phrase and the hex seed are equivalent backups —\n\
either restores this identity. Store at least one safely."
),
MnemonicWords::Twelve => println!(
"The 12-word phrase is the canonical backup. The hex seed is\n\
derived from it (one-way) — you can't reconstruct the phrase\n\
from the seed. Store the phrase safely."
),
}
}
}
Ok(())
}
fn identity_mnemonic(words: u8) -> Result<()> {
let w = MnemonicWords::from_count(words as usize).map_err(|e| anyhow::anyhow!("{e}"))?;
let phrase = generate_mnemonic(w).map_err(|e| anyhow::anyhow!("{e}"))?;
println!("{phrase}");
Ok(())
}
fn identity_from_mnemonic(phrase: &str) -> Result<()> {
let secret = Ed25519Secret::from_mnemonic(phrase).map_err(|e| anyhow::anyhow!("{e}"))?;
let id = secret.identity()?;
let word_count = phrase.split_whitespace().count();
println!("Primary: {id}");
println!("Public: {}", secret.pubkey_hex());
println!("Secret: {} (32-byte seed)", secret.seed_hex());
println!("Mnemonic ({} words): \"{}\"", word_count, phrase.trim());
if word_count == 24 {
// For 24-word, verify it round-trips so the user knows it's canonical.
let mut seed_bytes = [0u8; 32];
seed_bytes.copy_from_slice(&hex::decode(secret.seed_hex())?);
let derived = mnemonic_from_seed_24(&seed_bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
if derived.trim() != phrase.trim() {
// Words were correct (parse succeeded) but their reordering differs
// — shouldn't happen, but worth flagging if it ever does.
println!("(note: canonical form is \"{}\")", derived);
}
}
Ok(())
}
/// Build a signed claim from whichever signing key the caller supplied.
/// Exactly one of `nsec` / `ed25519_seed` must be present (clap enforces).
fn build_claim(
subject: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<SignedClaim> {
let subject = Identity::parse(subject)?;
match (nsec, ed25519_seed) {
(Some(nsec), None) => {
let signer = NostrSecret::from_nsec(&nsec).context("invalid nsec")?;
let primary = Identity::parse(format!("nostr:{}", signer.npub()))?;
let payload = ClaimPayload::new(subject, primary, Utc::now());
Ok(SignedClaim::sign_with(payload, Signer::Nostr(&signer))?)
}
(None, Some(seed)) => {
let signer = Ed25519Secret::from_seed_hex(&seed).context("invalid ed25519 seed")?;
let primary = signer.identity()?;
let payload = ClaimPayload::new(subject, primary, Utc::now());
Ok(SignedClaim::sign_with(payload, Signer::Ed25519(&signer))?)
}
(None, None) => anyhow::bail!("missing key: pass --nsec or --ed25519-seed"),
(Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
}
}
fn claim_create(
subject: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
format: OutputFormat,
out: Option<PathBuf>,
) -> Result<()> {
let signed = build_claim(subject, nsec, ed25519_seed)?;
let output = match format {
OutputFormat::Json => signed.to_pretty_json()?,
OutputFormat::Markdown => signed.to_markdown_proof()?,
OutputFormat::Compact => signed.to_compact()?,
};
write_or_print(out, &output)
}
fn claim_dns(
domain: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<()> {
let subject = if domain.starts_with("dns:") {
domain
} else {
format!("dns:{domain}")
};
let signed = build_claim(subject, nsec, ed25519_seed)?;
let name = dns_txt_name(&signed.payload.subject)?;
let value = signed.to_compact()?;
println!("Name: {name}");
println!("Value: {value}");
println!();
println!("Zone file:");
println!("{name} TXT {}", quote_dns_txt_value(&value));
Ok(())
}
fn verify_file(path: PathBuf) -> Result<()> {
let raw =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
let proof = parse_proof(&raw).context("failed to parse KEZ proof")?;
let status = proof.verify().context("signature verification failed")?;
print_status(&status);
Ok(())
}
async fn verify_identifier(identifier: String) -> Result<()> {
let identity = Identity::parse(identifier).context("invalid KEZ identifier")?;
let registry = Registry::with_defaults()
.map_err(|e| anyhow::anyhow!("{e}"))
.context("failed to build channel registry")?;
let hit: ChannelHit = registry
.verify(&identity)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
print_status(&hit.status);
Ok(())
}
fn print_status(status: &VerificationStatus) {
println!("Primary: {}", status.primary);
println!();
println!("Verified identities:");
for identity in &status.verified {
println!("- {identity}");
}
println!();
println!("Status: {}", status.status);
println!("Confidence: {}", status.confidence);
}
fn write_or_print(out: Option<PathBuf>, output: &str) -> Result<()> {
match out {
Some(path) => {
fs::write(&path, output).with_context(|| format!("failed to write {}", path.display()))
}
None => {
// Match Node's `writeOrPrint`: avoid double-newlines if `output`
// already ends in one (the sigchain JSONL case).
if output.ends_with('\n') {
print!("{output}");
} else {
println!("{output}");
}
Ok(())
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Sigchain commands
// ─────────────────────────────────────────────────────────────────────────────
/// Where the local chain for `primary` lives on disk.
fn sigchain_path(primary: &Identity) -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
let dir = home.join(".kez").join("sigchains");
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let safe = primary.as_str().replace(':', "_");
Ok(dir.join(format!("{safe}.jsonl")))
}
/// Load the chain for `primary` from disk, or return an empty chain if no
/// file exists.
fn load_chain(primary: &Identity) -> Result<Sigchain> {
let path = sigchain_path(primary)?;
if !path.exists() {
return Ok(Sigchain::new(primary.clone()));
}
let text = fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
Ok(Sigchain::from_jsonl(&text)?)
}
fn save_chain(chain: &Sigchain) -> Result<()> {
let path = sigchain_path(chain.primary())?;
let text = chain.to_jsonl()?;
fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
/// Build a `Signer` borrow from whichever flag the user passed.
/// Returns the loaded keys so the caller can keep them alive.
enum SignerKeys {
Nostr(NostrSecret),
Ed25519(Ed25519Secret),
}
impl SignerKeys {
fn from_flags(nsec: Option<String>, ed25519_seed: Option<String>) -> Result<Self> {
match (nsec, ed25519_seed) {
(Some(nsec), None) => Ok(Self::Nostr(
NostrSecret::from_nsec(&nsec).context("invalid nsec")?,
)),
(None, Some(seed)) => Ok(Self::Ed25519(
Ed25519Secret::from_seed_hex(&seed).context("invalid ed25519 seed")?,
)),
(None, None) => bail!("missing key: pass --nsec or --ed25519-seed"),
(Some(_), Some(_)) => unreachable!("clap conflicts_with prevents this"),
}
}
fn primary(&self) -> Result<Identity> {
match self {
SignerKeys::Nostr(s) => Identity::parse(format!("nostr:{}", s.npub())),
SignerKeys::Ed25519(s) => s.identity(),
}
.map_err(Into::into)
}
fn as_signer(&self) -> Signer<'_> {
match self {
SignerKeys::Nostr(s) => Signer::Nostr(s),
SignerKeys::Ed25519(s) => Signer::Ed25519(s),
}
}
}
/// Resolve the primary identity for a read-only command. Accepts `--primary`
/// directly, or derives it from a signing key.
fn resolve_primary_readonly(
primary: Option<String>,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<Identity> {
if let Some(p) = primary {
return Ok(Identity::parse(p)?);
}
SignerKeys::from_flags(nsec, ed25519_seed)?.primary()
}
fn sigchain_add(
subject: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
proof_url: Option<String>,
) -> Result<()> {
let keys = SignerKeys::from_flags(nsec, ed25519_seed)?;
let primary = keys.primary()?;
let subject = Identity::parse(subject)?;
let mut chain = load_chain(&primary)?;
let event = chain.sign_add(subject.clone(), proof_url, keys.as_signer())?;
println!(
"Appended add {} at seq {} (head hash: {})",
subject,
event.payload.seq,
event.hash()?
);
save_chain(&chain)?;
println!("Chain saved to {}", sigchain_path(&primary)?.display());
Ok(())
}
fn sigchain_revoke(
subject: String,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<()> {
let keys = SignerKeys::from_flags(nsec, ed25519_seed)?;
let primary = keys.primary()?;
let subject = Identity::parse(subject)?;
let mut chain = load_chain(&primary)?;
let event = chain.sign_revoke(subject.clone(), keys.as_signer())?;
println!(
"Appended revoke {} at seq {} (head hash: {})",
subject,
event.payload.seq,
event.hash()?
);
save_chain(&chain)?;
println!("Chain saved to {}", sigchain_path(&primary)?.display());
Ok(())
}
fn sigchain_show(
primary: Option<String>,
nsec: Option<String>,
ed25519_seed: Option<String>,
) -> Result<()> {
let primary = resolve_primary_readonly(primary, nsec, ed25519_seed)?;
let chain = load_chain(&primary)?;
println!("Primary: {primary}");
println!("Path: {}", sigchain_path(&primary)?.display());
println!("Length: {} event(s)", chain.len());
println!();
for (i, event) in chain.events().iter().enumerate() {
let subject = event
.payload
.subject()
.map(|s| s.to_string())
.unwrap_or_else(|| "<no subject>".into());
println!(
" [{i}] seq={} op={:6} subject={subject}",
event.payload.seq, event.payload.op
);
}
if !chain.is_empty() {
println!();
println!("Head hash: {}", chain.head_hash()?.unwrap());
}
Ok(())
}
fn sigchain_export(
primary: Option<String>,
nsec: Option<String>,
ed25519_seed: Option<String>,
format: ExportFormat,
out: Option<PathBuf>,
) -> Result<()> {
let primary = resolve_primary_readonly(primary, nsec, ed25519_seed)?;
let chain = load_chain(&primary)?;
if chain.is_empty() {
bail!("no chain found for {primary}");
}
let output = match format {
ExportFormat::Jsonl => chain.to_jsonl()?,
ExportFormat::Compact => chain.to_compact_bundle()?,
};
write_or_print(out, &output)
}
async fn sigchain_publish(
primary: Option<String>,
nsec: Option<String>,
ed25519_seed: Option<String>,
server: Option<String>,
web: bool,
out: Option<PathBuf>,
dns: Option<String>,
nostr: Option<String>,
) -> Result<()> {
// Need at least one destination.
if server.is_none() && !web && dns.is_none() && nostr.is_none() {
bail!("no publish destination: pass --server / --web / --dns / --nostr");
}
// Resolve the primary and (optionally) the signer.
// For --nostr we need a NostrSecret specifically (to sign the wrapping
// event); for other destinations we just need the chain.
let (primary, nsec_signer): (Identity, Option<NostrSecret>) = if let Some(p) = primary {
(Identity::parse(p)?, None)
} else {
let keys = SignerKeys::from_flags(nsec, ed25519_seed)?;
let primary = keys.primary()?;
let nsec_signer = match keys {
SignerKeys::Nostr(s) => Some(s),
SignerKeys::Ed25519(_) => None,
};
(primary, nsec_signer)
};
let chain = load_chain(&primary)?;
if chain.is_empty() {
bail!("no chain found for {primary}");
}
if let Some(server_url) = server.as_deref() {
publish_to_server(&chain, server_url).await?;
}
if web {
let out = out.context("--web requires --out <path>")?;
publish_to_web(&chain, &out)?;
}
if let Some(domain) = dns.as_deref() {
publish_to_dns(&chain, domain)?;
}
if let Some(relay) = nostr.as_deref() {
let signer = nsec_signer
.as_ref()
.context("--nostr publish requires --nsec (nostr key needed to sign the wrapping event)")?;
// The wrapping nostr key must match the chain's primary, otherwise
// verifiers can't tie the event to the identity.
let signer_primary = Identity::parse(format!("nostr:{}", signer.npub()))?;
if signer_primary != primary {
bail!(
"--nostr publish requires the signing nsec to match the chain primary ({primary}); got {signer_primary}"
);
}
publish_to_nostr(&chain, relay, signer).await?;
}
Ok(())
}
async fn publish_to_server(chain: &Sigchain, server_url: &str) -> Result<()> {
let base = server_url.trim_end_matches('/');
let scheme = chain.primary().scheme();
let id = chain.primary().value();
let client = reqwest::Client::builder()
.user_agent("kez-cli/0.1")
.build()?;
let endpoint = format!("{base}/v1/sigchains/{scheme}/{id}/events");
let mut posted = 0;
let mut already_present = 0;
for event in chain.events() {
let resp = client
.post(&endpoint)
.json(event)
.send()
.await
.with_context(|| format!("POST {endpoint}"))?;
let status = resp.status();
if status.is_success() {
posted += 1;
} else if status == reqwest::StatusCode::CONFLICT {
// Idempotent: server already has this seq, fine.
already_present += 1;
} else {
let body = resp.text().await.unwrap_or_default();
bail!("POST {endpoint}: {status} {body}");
}
}
println!(
"server({server_url}): posted {posted} event(s), {already_present} already present"
);
Ok(())
}
fn publish_to_web(chain: &Sigchain, path: &Path) -> Result<()> {
let text = chain.to_jsonl()?;
fs::write(path, text).with_context(|| format!("write {}", path.display()))?;
println!(
"web: wrote {} event(s) to {} (upload to https://<your-domain>/.well-known/kez-sigchain.jsonl)",
chain.len(),
path.display()
);
Ok(())
}
fn publish_to_dns(chain: &Sigchain, domain: &str) -> Result<()> {
let compact = chain.to_compact_bundle()?;
let name = format!("_kez-chain.{domain}");
println!("dns({domain}):");
println!(" Name: {name}");
println!(" Value: {compact}");
println!();
println!(" Zone file (install in your DNS registrar):");
println!(" {name} TXT {}", quote_dns_txt_value(&compact));
Ok(())
}
async fn publish_to_nostr(
chain: &Sigchain,
relay: &str,
signer: &NostrSecret,
) -> Result<()> {
let content = chain.to_compact_bundle()?;
let event = nostr_chan::build_signed_event(
signer,
Utc::now().timestamp(),
nostr_chan::KEZ_NOSTR_KIND,
vec![vec!["d".into(), "kez-sigchain".into()]],
content,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
nostr_chan::publish_event_to_relay(relay, &event)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!(
"nostr({relay}): published kind-{} event {}",
nostr_chan::KEZ_NOSTR_KIND,
event.id
);
Ok(())
}
fn quote_dns_txt_value(value: &str) -> String {
value
.chars()
.collect::<Vec<_>>()
.chunks(240)
.map(|chunk| {
let escaped = chunk
.iter()
.flat_map(|ch| match ch {
'"' => ['\\', '"'].into_iter().collect::<Vec<_>>(),
'\\' => ['\\', '\\'].into_iter().collect::<Vec<_>>(),
other => [*other].into_iter().collect::<Vec<_>>(),
})
.collect::<String>();
format!("\"{escaped}\"")
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_dns_txt_value_chunks_long_inputs() {
let value = "a".repeat(500);
let quoted = quote_dns_txt_value(&value);
// 500 chars / 240 chunk -> 3 quoted segments.
let segments = quoted.split(' ').count();
assert_eq!(segments, 3);
assert!(quoted.starts_with('"'));
assert!(quoted.ends_with('"'));
}
#[test]
fn quote_dns_txt_value_escapes_quotes_and_backslashes() {
let quoted = quote_dns_txt_value(r#"hello "kez" \n"#);
assert!(quoted.contains(r#"\""#));
assert!(quoted.contains(r"\\"));
}
}