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>
875 lines
29 KiB
Rust
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"\\"));
|
|
}
|
|
}
|