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

1394 lines
48 KiB
Rust

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use bech32::{FromBase32, ToBase32, Variant};
use chrono::{DateTime, Utc};
use ed25519_dalek::{
Signature as Ed25519Signature, Signer as Ed25519Signer, SigningKey as Ed25519SigningKey,
VerifyingKey as Ed25519VerifyingKey,
};
use rand::RngCore;
use secp256k1::schnorr::Signature;
use secp256k1::{Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
use std::str::FromStr;
pub mod mnemonic;
pub use mnemonic::{
MnemonicWords, generate_mnemonic, mnemonic_from_seed_24, seed_from_mnemonic,
};
pub const CLAIM_TYPE: &str = "kez.claim";
pub const SIGCHAIN_EVENT_TYPE: &str = "kez.sigchain.event";
pub const FORMAT_VERSION: u8 = 1;
pub const NOSTR_SCHNORR_ALG: &str = "nostr-secp256k1-schnorr-sha256-jcs";
pub const ED25519_SHA512_ALG: &str = "ed25519-sha512-jcs";
pub const COMPACT_PROOF_PREFIX: &str = "kez:z1:";
pub const COMPACT_CHAIN_PREFIX: &str = "kez:zc1:";
#[derive(Debug, thiserror::Error)]
pub enum KezError {
#[error("unsupported key algorithm: {0}")]
UnsupportedAlgorithm(String),
#[error("invalid identity: {0}")]
InvalidIdentity(String),
#[error("invalid nostr key")]
InvalidNostrKey,
#[error("invalid signature")]
InvalidSignature,
#[error("canonical json failed: {0}")]
CanonicalJson(String),
#[error("json failed: {0}")]
Json(#[from] serde_json::Error),
#[error("bech32 failed: {0}")]
Bech32(#[from] bech32::Error),
#[error("secp256k1 failed: {0}")]
Secp256k1(#[from] secp256k1::Error),
#[error("hex failed: {0}")]
Hex(#[from] hex::FromHexError),
#[error("base64 failed: {0}")]
Base64(#[from] base64::DecodeError),
#[error("io failed: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, KezError>;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Identity(String);
impl Identity {
pub fn parse(value: impl AsRef<str>) -> Result<Self> {
let raw = value.as_ref().trim();
if raw.is_empty() {
return Err(KezError::InvalidIdentity(raw.to_owned()));
}
if raw.starts_with("npub1") {
validate_npub(raw)?;
return Ok(Self(format!("nostr:{raw}")));
}
let Some((scheme, rest)) = raw.split_once(':') else {
return Err(KezError::InvalidIdentity(raw.to_owned()));
};
if scheme.is_empty() || rest.is_empty() {
return Err(KezError::InvalidIdentity(raw.to_owned()));
}
if scheme == "nostr" {
validate_npub(rest)?;
} else if scheme == "ed25519" {
validate_ed25519_hex(rest)?;
}
Ok(Self(format!("{scheme}:{rest}")))
}
pub fn scheme(&self) -> &str {
self.0
.split_once(':')
.map(|(scheme, _)| scheme)
.unwrap_or("")
}
pub fn value(&self) -> &str {
self.0.split_once(':').map(|(_, value)| value).unwrap_or("")
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for Identity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for Identity {
type Err = KezError;
fn from_str(s: &str) -> Result<Self> {
Self::parse(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClaimPayload {
#[serde(rename = "type")]
pub kind: String,
pub version: u8,
pub subject: Identity,
pub primary: Identity,
pub created_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
impl ClaimPayload {
pub fn new(subject: Identity, primary: Identity, created_at: DateTime<Utc>) -> Self {
Self {
kind: CLAIM_TYPE.to_owned(),
version: FORMAT_VERSION,
subject,
primary,
created_at,
expires_at: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignatureBlock {
pub alg: String,
pub key: Identity,
pub sig: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedClaim {
pub kez: String,
pub payload: ClaimPayload,
pub signature: SignatureBlock,
}
/// A signing key of either supported type. Lets `SignedClaim::sign_with`
/// dispatch on the underlying algorithm without exposing two near-identical
/// entry points.
pub enum Signer<'a> {
Nostr(&'a NostrSecret),
Ed25519(&'a Ed25519Secret),
}
impl SignedClaim {
/// Sign with a nostr/secp256k1 primary key (back-compat convenience).
pub fn sign(payload: ClaimPayload, signer: &NostrSecret) -> Result<Self> {
Self::sign_with(payload, Signer::Nostr(signer))
}
/// Sign with an Ed25519 primary key (back-compat convenience).
pub fn sign_ed25519(payload: ClaimPayload, signer: &Ed25519Secret) -> Result<Self> {
Self::sign_with(payload, Signer::Ed25519(signer))
}
/// Unified signing entry: dispatches on the signer type.
pub fn sign_with(payload: ClaimPayload, signer: Signer<'_>) -> Result<Self> {
let (alg, key, sig_hex) = match signer {
Signer::Nostr(s) => {
let key = Identity::parse(format!("nostr:{}", s.npub()))?;
let sig = sign_jcs_schnorr_hex(&payload, s)?;
(NOSTR_SCHNORR_ALG.to_owned(), key, sig)
}
Signer::Ed25519(s) => {
let key = s.identity()?;
let jcs = canonical_bytes(&payload)?;
let sig = s.sign(&jcs);
(ED25519_SHA512_ALG.to_owned(), key, hex::encode(sig))
}
};
Ok(Self {
kez: "claim".to_owned(),
payload,
signature: SignatureBlock {
alg,
key,
sig: sig_hex,
},
})
}
pub fn verify(&self) -> Result<VerificationStatus> {
if self.signature.key != self.payload.primary {
return Err(KezError::InvalidSignature);
}
match self.signature.alg.as_str() {
NOSTR_SCHNORR_ALG => {
verify_jcs_schnorr_hex(
&self.payload,
self.signature.key.value(),
&self.signature.sig,
)?;
}
ED25519_SHA512_ALG => {
let jcs = canonical_bytes(&self.payload)?;
verify_ed25519_hex(self.signature.key.value(), &jcs, &self.signature.sig)?;
}
other => return Err(KezError::UnsupportedAlgorithm(other.to_owned())),
}
Ok(VerificationStatus {
primary: self.payload.primary.clone(),
verified: vec![self.payload.subject.clone()],
status: "valid".to_owned(),
confidence: "strong".to_owned(),
})
}
pub fn to_pretty_json(&self) -> Result<String> {
Ok(serde_json::to_string_pretty(self)?)
}
pub fn to_markdown_proof(&self) -> Result<String> {
let json = self.to_pretty_json()?;
Ok(format!(
"# KEZ Proof\n\nThis account publishes a signed KEZ identity claim.\n\n- Primary: `{}`\n- Subject: `{}`\n- Created: `{}`\n\n```kez\n{}\n```\n",
self.payload.primary, self.payload.subject, self.payload.created_at, json
))
}
pub fn to_compact(&self) -> Result<String> {
encode_compact_claim(self)
}
}
/// Spec §6 sigchain event payload. JCS-canonicalized + signed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SigchainEventPayload {
#[serde(rename = "type")]
pub kind: String,
pub version: u8,
/// The primary key this event belongs to. After a `rotate`, this is
/// the new primary; before, the old one.
pub primary: Identity,
pub seq: u64,
/// `sha256:<hex>` of JCS of the prior signed envelope. None iff `seq == 0`.
#[serde(skip_serializing_if = "Option::is_none")]
pub prev: Option<String>,
pub created_at: DateTime<Utc>,
/// Operation name: "add" | "revoke" | "rotate" | "add_device".
pub op: String,
/// Op-specific fields, kept as Value so the schema can grow without a
/// breaking type change.
pub payload: serde_json::Value,
}
/// Convenience constructors for the supported ops. Use these instead of
/// hand-rolling `op`/`payload` strings so impls stay consistent.
impl SigchainEventPayload {
/// `add` op: assert that `primary` controls `subject`.
pub fn new_add(
primary: Identity,
seq: u64,
prev: Option<String>,
subject: Identity,
proof_url: Option<String>,
created_at: DateTime<Utc>,
) -> Self {
let mut payload = serde_json::Map::new();
payload.insert("subject".to_owned(), serde_json::json!(subject));
if let Some(url) = proof_url {
payload.insert("proof_url".to_owned(), serde_json::json!(url));
}
Self {
kind: SIGCHAIN_EVENT_TYPE.to_owned(),
version: FORMAT_VERSION,
primary,
seq,
prev,
created_at,
op: "add".to_owned(),
payload: serde_json::Value::Object(payload),
}
}
/// `revoke` op: retract a previously-added subject.
pub fn new_revoke(
primary: Identity,
seq: u64,
prev: Option<String>,
subject: Identity,
created_at: DateTime<Utc>,
) -> Self {
Self {
kind: SIGCHAIN_EVENT_TYPE.to_owned(),
version: FORMAT_VERSION,
primary,
seq,
prev,
created_at,
op: "revoke".to_owned(),
payload: serde_json::json!({ "subject": subject }),
}
}
/// Pull the `subject` field out of an add/revoke payload, if present.
pub fn subject(&self) -> Option<Identity> {
self.payload
.get("subject")
.and_then(|v| v.as_str())
.and_then(|s| Identity::parse(s).ok())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignedSigchainEvent {
pub kez: String,
pub payload: SigchainEventPayload,
pub signature: SignatureBlock,
}
impl SignedSigchainEvent {
/// Sign with a nostr key (back-compat convenience).
pub fn sign(payload: SigchainEventPayload, signer: &NostrSecret) -> Result<Self> {
Self::sign_with(payload, Signer::Nostr(signer))
}
/// Sign with an Ed25519 key.
pub fn sign_ed25519(
payload: SigchainEventPayload,
signer: &Ed25519Secret,
) -> Result<Self> {
Self::sign_with(payload, Signer::Ed25519(signer))
}
/// Unified signing entry — same dispatch as `SignedClaim::sign_with`.
pub fn sign_with(payload: SigchainEventPayload, signer: Signer<'_>) -> Result<Self> {
let (alg, key, sig_hex) = match signer {
Signer::Nostr(s) => {
let key = Identity::parse(format!("nostr:{}", s.npub()))?;
let sig = sign_jcs_schnorr_hex(&payload, s)?;
(NOSTR_SCHNORR_ALG.to_owned(), key, sig)
}
Signer::Ed25519(s) => {
let key = s.identity()?;
let jcs = canonical_bytes(&payload)?;
let sig = s.sign(&jcs);
(ED25519_SHA512_ALG.to_owned(), key, hex::encode(sig))
}
};
Ok(Self {
kez: "sigchain_event".to_owned(),
payload,
signature: SignatureBlock {
alg,
key,
sig: sig_hex,
},
})
}
/// Verify the signature over `payload`. Dispatches on `signature.alg`.
pub fn verify(&self) -> Result<()> {
if self.signature.key != self.payload.primary {
return Err(KezError::InvalidSignature);
}
match self.signature.alg.as_str() {
NOSTR_SCHNORR_ALG => verify_jcs_schnorr_hex(
&self.payload,
self.signature.key.value(),
&self.signature.sig,
),
ED25519_SHA512_ALG => {
let jcs = canonical_bytes(&self.payload)?;
verify_ed25519_hex(self.signature.key.value(), &jcs, &self.signature.sig)
}
other => Err(KezError::UnsupportedAlgorithm(other.to_owned())),
}
}
/// `sha256:<hex>` of the JCS-canonicalized envelope. Used by `prev`
/// on the next event in the chain.
pub fn hash(&self) -> Result<String> {
let bytes = canonical_bytes(self)?;
Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Sigchain — append-only, validated chain of signed events for one primary.
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, thiserror::Error)]
pub enum SigchainError {
#[error("expected primary {expected}, got {found}")]
WrongPrimary { expected: Identity, found: Identity },
#[error("expected seq {expected}, got {found}")]
SeqMismatch { expected: u64, found: u64 },
#[error("prev hash mismatch: expected {expected:?}, got {found:?}")]
PrevMismatch {
expected: Option<String>,
found: Option<String>,
},
#[error("signature failed: {0}")]
BadSignature(#[from] KezError),
#[error("envelope tag must be \"sigchain_event\", got {0:?}")]
WrongEnvelopeTag(String),
#[error("sigchain is empty")]
Empty,
#[error("invalid JSONL: {0}")]
BadJsonl(String),
}
/// An ordered, validated chain of signed events for a single primary.
/// All mutations go through `append` (or the `sign_*` helpers), which
/// enforce the spec §6.2 integrity rules.
#[derive(Debug, Clone)]
pub struct Sigchain {
primary: Identity,
events: Vec<SignedSigchainEvent>,
}
impl Sigchain {
/// Create an empty sigchain for `primary`. The first appended event
/// must have seq 0 and no prev.
pub fn new(primary: Identity) -> Self {
Self {
primary,
events: Vec::new(),
}
}
pub fn primary(&self) -> &Identity {
&self.primary
}
pub fn head(&self) -> Option<&SignedSigchainEvent> {
self.events.last()
}
/// `sha256:<hex>` of the head envelope. `None` iff the chain is empty.
pub fn head_hash(&self) -> Result<Option<String>> {
match self.head() {
None => Ok(None),
Some(head) => Ok(Some(head.hash()?)),
}
}
/// The seq the next appended event must use.
pub fn next_seq(&self) -> u64 {
self.events.last().map(|e| e.payload.seq + 1).unwrap_or(0)
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
pub fn events(&self) -> &[SignedSigchainEvent] {
&self.events
}
/// Append a signed event. Validates: envelope tag, primary, seq
/// monotonicity, prev hash chain, and signature.
pub fn append(&mut self, event: SignedSigchainEvent) -> std::result::Result<(), SigchainError> {
if event.kez != "sigchain_event" {
return Err(SigchainError::WrongEnvelopeTag(event.kez.clone()));
}
if event.payload.primary != self.primary {
return Err(SigchainError::WrongPrimary {
expected: self.primary.clone(),
found: event.payload.primary.clone(),
});
}
let expected_seq = self.next_seq();
if event.payload.seq != expected_seq {
return Err(SigchainError::SeqMismatch {
expected: expected_seq,
found: event.payload.seq,
});
}
let expected_prev = self.head_hash()?;
if event.payload.prev != expected_prev {
return Err(SigchainError::PrevMismatch {
expected: expected_prev,
found: event.payload.prev.clone(),
});
}
event.verify()?;
self.events.push(event);
Ok(())
}
/// Full-chain re-validation (signatures + integrity). Useful after
/// loading from external storage.
pub fn validate(&self) -> std::result::Result<(), SigchainError> {
let mut rebuilt = Sigchain::new(self.primary.clone());
for event in &self.events {
rebuilt.append(event.clone())?;
}
Ok(())
}
/// True if the most recent op for `subject` is `revoke`. False if the
/// most recent op is `add` or the subject was never added.
pub fn is_revoked(&self, subject: &Identity) -> bool {
for event in self.events.iter().rev() {
let Some(s) = event.payload.subject() else {
continue;
};
if &s == subject {
return event.payload.op == "revoke";
}
}
false
}
/// True if `subject` was added (and not later revoked).
pub fn is_active(&self, subject: &Identity) -> bool {
for event in self.events.iter().rev() {
let Some(s) = event.payload.subject() else {
continue;
};
if &s == subject {
return event.payload.op == "add";
}
}
false
}
/// Convenience: build, sign, and append an `add` event in one call.
pub fn sign_add(
&mut self,
subject: Identity,
proof_url: Option<String>,
signer: Signer<'_>,
) -> std::result::Result<&SignedSigchainEvent, SigchainError> {
let prev = self.head_hash()?;
let payload = SigchainEventPayload::new_add(
self.primary.clone(),
self.next_seq(),
prev,
subject,
proof_url,
Utc::now(),
);
let signed = SignedSigchainEvent::sign_with(payload, signer)?;
self.append(signed)?;
Ok(self.events.last().unwrap())
}
/// Convenience: build, sign, and append a `revoke` event.
pub fn sign_revoke(
&mut self,
subject: Identity,
signer: Signer<'_>,
) -> std::result::Result<&SignedSigchainEvent, SigchainError> {
let prev = self.head_hash()?;
let payload = SigchainEventPayload::new_revoke(
self.primary.clone(),
self.next_seq(),
prev,
subject,
Utc::now(),
);
let signed = SignedSigchainEvent::sign_with(payload, signer)?;
self.append(signed)?;
Ok(self.events.last().unwrap())
}
/// Serialize as JSONL (one envelope per line). The portable format
/// used by `kez sigchain export` and the chain server's GET endpoint.
pub fn to_jsonl(&self) -> Result<String> {
let mut out = String::new();
for event in &self.events {
out.push_str(&serde_json::to_string(event)?);
out.push('\n');
}
Ok(out)
}
/// Encode the whole chain as `kez:zc1:<base64url-no-pad(zstd(jsonl))>`.
/// Portable single-string form for embedding in nostr events, DNS TXT
/// values, ActivityPub fields, etc.
pub fn to_compact_bundle(&self) -> Result<String> {
let jsonl = self.to_jsonl()?;
let compressed = zstd::encode_all(jsonl.as_bytes(), 3)?;
Ok(format!(
"{COMPACT_CHAIN_PREFIX}{}",
URL_SAFE_NO_PAD.encode(compressed)
))
}
/// Inverse of `to_compact_bundle`.
pub fn from_compact_bundle(value: &str) -> std::result::Result<Self, SigchainError> {
let encoded = value.trim().strip_prefix(COMPACT_CHAIN_PREFIX).ok_or_else(|| {
SigchainError::BadJsonl(format!("missing {COMPACT_CHAIN_PREFIX} prefix"))
})?;
let compressed = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|e| SigchainError::BadJsonl(format!("base64url: {e}")))?;
let jsonl = zstd::decode_all(compressed.as_slice())
.map_err(|e| SigchainError::BadJsonl(format!("zstd: {e}")))?;
let text = String::from_utf8(jsonl)
.map_err(|e| SigchainError::BadJsonl(format!("utf8: {e}")))?;
Self::from_jsonl(&text)
}
/// Parse a JSONL bundle. Validates as it appends, so the result is
/// guaranteed to be a well-formed chain.
pub fn from_jsonl(text: &str) -> std::result::Result<Self, SigchainError> {
let mut lines = text.lines().filter(|l| !l.trim().is_empty());
let first = lines
.next()
.ok_or_else(|| SigchainError::BadJsonl("empty input".to_owned()))?;
let first_event: SignedSigchainEvent = serde_json::from_str(first)
.map_err(|e| SigchainError::BadJsonl(format!("line 0: {e}")))?;
let mut chain = Sigchain::new(first_event.payload.primary.clone());
chain.append(first_event)?;
for (i, line) in lines.enumerate() {
let event: SignedSigchainEvent = serde_json::from_str(line)
.map_err(|e| SigchainError::BadJsonl(format!("line {}: {e}", i + 1)))?;
chain.append(event)?;
}
Ok(chain)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerificationStatus {
pub primary: Identity,
pub verified: Vec<Identity>,
pub status: String,
pub confidence: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NostrSecret {
secret_key: SecretKey,
}
impl NostrSecret {
pub fn generate() -> Self {
let mut rng = rand::thread_rng();
Self {
secret_key: SecretKey::new(&mut rng),
}
}
pub fn from_nsec(nsec: &str) -> Result<Self> {
let (hrp, data, variant) = bech32::decode(nsec)?;
if hrp != "nsec" || variant != Variant::Bech32 {
return Err(KezError::InvalidNostrKey);
}
let bytes = Vec::<u8>::from_base32(&data)?;
let secret_key = SecretKey::from_slice(&bytes)?;
Ok(Self { secret_key })
}
pub fn nsec(&self) -> String {
bech32::encode(
"nsec",
self.secret_key.secret_bytes().to_base32(),
Variant::Bech32,
)
.expect("nsec encoding cannot fail for static hrp")
}
pub fn npub(&self) -> String {
let secp = Secp256k1::new();
let keypair = Keypair::from_secret_key(&secp, &self.secret_key);
let (public_key, _) = XOnlyPublicKey::from_keypair(&keypair);
bech32::encode("npub", public_key.serialize().to_base32(), Variant::Bech32)
.expect("npub encoding cannot fail for static hrp")
}
/// 32-byte x-only public key as lowercase hex (the form nostr filters
/// and event `pubkey` fields use).
pub fn pubkey_hex(&self) -> String {
let secp = Secp256k1::new();
let keypair = Keypair::from_secret_key(&secp, &self.secret_key);
let (public_key, _) = XOnlyPublicKey::from_keypair(&keypair);
hex::encode(public_key.serialize())
}
/// BIP-340 Schnorr signature over a 32-byte digest (no aux rand).
/// Used for signing NIP-01 nostr event ids when publishing.
pub fn sign_raw(&self, digest: &[u8; 32]) -> Result<[u8; 64]> {
let msg = Message::from_digest_slice(digest)?;
let secp = Secp256k1::new();
let keypair = Keypair::from_secret_key(&secp, &self.secret_key);
let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
Ok(*sig.as_ref())
}
}
#[derive(Clone)]
pub struct Ed25519Secret {
signing_key: Ed25519SigningKey,
}
impl Ed25519Secret {
pub fn generate() -> Self {
let mut rng = rand::thread_rng();
let mut seed = [0u8; 32];
rng.fill_bytes(&mut seed);
Self {
signing_key: Ed25519SigningKey::from_bytes(&seed),
}
}
pub fn from_seed_hex(seed_hex: &str) -> Result<Self> {
let bytes = hex::decode(seed_hex)?;
let seed: [u8; 32] = bytes.try_into().map_err(|_| {
KezError::InvalidIdentity("ed25519 seed must be 32 bytes / 64 hex chars".to_owned())
})?;
Ok(Self {
signing_key: Ed25519SigningKey::from_bytes(&seed),
})
}
/// 32-byte seed (secret material) as lowercase hex. Anyone with this can sign as this identity.
pub fn seed_hex(&self) -> String {
hex::encode(self.signing_key.to_bytes())
}
/// 32-byte public key as lowercase hex.
pub fn pubkey_hex(&self) -> String {
hex::encode(self.signing_key.verifying_key().to_bytes())
}
pub fn identity(&self) -> Result<Identity> {
Identity::parse(format!("ed25519:{}", self.pubkey_hex()))
}
/// Sign raw bytes (no pre-hash — RFC 8032 PureEdDSA does SHA-512 internally).
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
self.signing_key.sign(message).to_bytes()
}
}
impl std::fmt::Debug for Ed25519Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Never print the seed.
f.debug_struct("Ed25519Secret")
.field("pubkey_hex", &self.pubkey_hex())
.finish()
}
}
impl PartialEq for Ed25519Secret {
fn eq(&self, other: &Self) -> bool {
self.signing_key.to_bytes() == other.signing_key.to_bytes()
}
}
impl Eq for Ed25519Secret {}
pub fn dns_txt_name(identity: &Identity) -> Result<String> {
if identity.scheme() != "dns" {
return Err(KezError::InvalidIdentity(identity.to_string()));
}
Ok(format!("_kez.{}", identity.value()))
}
/// For a `nostr:npub1...` identity, return the 32-byte x-only pubkey as a
/// lowercase hex string. This is the form nostr relay filters require.
pub fn nostr_pubkey_hex(identity: &Identity) -> Result<String> {
if identity.scheme() != "nostr" {
return Err(KezError::InvalidIdentity(identity.to_string()));
}
let pk = decode_npub(identity.value())?;
Ok(hex::encode(pk.serialize()))
}
pub fn dns_txt_value(claim: &SignedClaim) -> Result<String> {
Ok(format!("kez1:{}", serde_json::to_string(claim)?))
}
pub fn parse_dns_txt_value(value: &str) -> Result<SignedClaim> {
let json = value
.strip_prefix("kez1:")
.ok_or_else(|| KezError::InvalidIdentity("DNS TXT proof missing kez1 prefix".to_owned()))?;
Ok(serde_json::from_str(json)?)
}
pub fn encode_compact_claim(claim: &SignedClaim) -> Result<String> {
let json = serde_json::to_vec(claim)?;
let compressed = zstd::encode_all(json.as_slice(), 3)?;
Ok(format!(
"{COMPACT_PROOF_PREFIX}{}",
URL_SAFE_NO_PAD.encode(compressed)
))
}
pub fn decode_compact_claim(value: &str) -> Result<SignedClaim> {
let encoded = value
.trim()
.strip_prefix(COMPACT_PROOF_PREFIX)
.ok_or_else(|| {
KezError::InvalidIdentity("compact proof missing kez:z1 prefix".to_owned())
})?;
let compressed = URL_SAFE_NO_PAD.decode(encoded)?;
let json = zstd::decode_all(compressed.as_slice())?;
Ok(serde_json::from_slice(&json)?)
}
pub fn extract_markdown_proof(markdown: &str) -> Result<SignedClaim> {
let fence = "```kez";
let Some(start) = markdown.find(fence) else {
return Err(KezError::InvalidIdentity(
"missing ```kez proof block".to_owned(),
));
};
let body_start = start + fence.len();
let Some(end) = markdown[body_start..].find("```") else {
return Err(KezError::InvalidIdentity(
"unterminated ```kez proof block".to_owned(),
));
};
let json = markdown[body_start..body_start + end].trim();
Ok(serde_json::from_str(json)?)
}
pub fn canonical_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>> {
serde_jcs::to_vec(value).map_err(|err| KezError::CanonicalJson(err.to_string()))
}
pub fn from_json<T: DeserializeOwned>(json: &str) -> Result<T> {
Ok(serde_json::from_str(json)?)
}
fn sign_jcs_schnorr_hex<T: Serialize>(payload: &T, signer: &NostrSecret) -> Result<String> {
let digest = Sha256::digest(canonical_bytes(payload)?);
let msg = Message::from_digest_slice(&digest)?;
let secp = Secp256k1::new();
let keypair = Keypair::from_secret_key(&secp, &signer.secret_key);
let signature = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
Ok(hex::encode(signature.as_ref()))
}
/// Verify a `SignatureBlock` against an arbitrary payload. Dispatches on
/// `signature.alg`. Used by `SignedClaim::verify` and
/// `SignedSigchainEvent::verify` internally; downstream crates (e.g. the
/// chat-server's handle-registration verifier) call it for non-claim
/// payloads that share the envelope shape.
pub fn verify_envelope<T: Serialize>(
payload: &T,
signature: &SignatureBlock,
) -> Result<()> {
match signature.alg.as_str() {
NOSTR_SCHNORR_ALG => {
verify_jcs_schnorr_hex(payload, signature.key.value(), &signature.sig)
}
ED25519_SHA512_ALG => {
let jcs = canonical_bytes(payload)?;
verify_ed25519_hex(signature.key.value(), &jcs, &signature.sig)
}
other => Err(KezError::UnsupportedAlgorithm(other.to_owned())),
}
}
fn verify_jcs_schnorr_hex<T: Serialize>(payload: &T, npub: &str, sig: &str) -> Result<()> {
let public_key = decode_npub(npub)?;
let signature = Signature::from_slice(&hex::decode(sig)?)?;
let digest = Sha256::digest(canonical_bytes(payload)?);
let msg = Message::from_digest_slice(&digest)?;
let secp = Secp256k1::verification_only();
secp.verify_schnorr(&signature, &msg, &public_key)?;
Ok(())
}
/// Verify a raw ed25519 signature (no JCS / no envelope wrapping).
///
/// `pubkey_hex` and `sig_hex` are lowercase-hex 32-byte and 64-byte values
/// respectively. `message` is the exact bytes the signer signed over.
/// Used by kez-chat-server for the per-request auth header — anywhere
/// outside the kez.claim / kez.handle_registration envelopes.
pub fn verify_ed25519_hex(pubkey_hex: &str, message: &[u8], sig_hex: &str) -> Result<()> {
let pubkey_bytes: [u8; 32] = hex::decode(pubkey_hex)?
.try_into()
.map_err(|_| KezError::InvalidIdentity("ed25519 pubkey must be 32 bytes".to_owned()))?;
let sig_bytes: [u8; 64] = hex::decode(sig_hex)?
.try_into()
.map_err(|_| KezError::InvalidSignature)?;
let verifying_key = Ed25519VerifyingKey::from_bytes(&pubkey_bytes)
.map_err(|_| KezError::InvalidSignature)?;
let signature = Ed25519Signature::from_bytes(&sig_bytes);
verifying_key
.verify_strict(message, &signature)
.map_err(|_| KezError::InvalidSignature)
}
fn validate_npub(npub: &str) -> Result<()> {
decode_npub(npub).map(|_| ())
}
fn validate_ed25519_hex(value: &str) -> Result<()> {
if value.len() != 64 {
return Err(KezError::InvalidIdentity(format!(
"ed25519 pubkey must be 64 hex chars, got {} chars",
value.len()
)));
}
if !value.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
return Err(KezError::InvalidIdentity(format!(
"ed25519 pubkey must be lowercase hex: {value}"
)));
}
Ok(())
}
fn decode_npub(npub: &str) -> Result<XOnlyPublicKey> {
let (hrp, data, variant) = bech32::decode(npub)?;
if hrp != "npub" || variant != Variant::Bech32 {
return Err(KezError::InvalidNostrKey);
}
let bytes = Vec::<u8>::from_base32(&data)?;
Ok(XOnlyPublicKey::from_slice(&bytes)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signs_and_verifies_claim() {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse("dns:jason.example.com").unwrap();
let payload = ClaimPayload::new(subject, primary.clone(), Utc::now());
let signed = SignedClaim::sign(payload, &secret).unwrap();
let status = signed.verify().unwrap();
assert_eq!(status.primary, primary);
assert_eq!(status.status, "valid");
}
#[test]
fn parses_bare_npub_as_nostr_identity() {
let secret = NostrSecret::generate();
let npub = secret.npub();
let identity = Identity::parse(&npub).unwrap();
assert_eq!(identity.as_str(), format!("nostr:{npub}"));
}
#[test]
fn round_trips_dns_txt_value() {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse("dns:jason.example.com").unwrap();
let signed =
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap();
let txt = dns_txt_value(&signed).unwrap();
let parsed = parse_dns_txt_value(&txt).unwrap();
assert_eq!(parsed, signed);
}
#[test]
fn extracts_markdown_proof() {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse("github:jason").unwrap();
let signed =
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap();
let markdown = signed.to_markdown_proof().unwrap();
let parsed = extract_markdown_proof(&markdown).unwrap();
assert_eq!(parsed, signed);
}
#[test]
fn round_trips_compact_claim() {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse("github:jason").unwrap();
let signed =
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap();
let compact = signed.to_compact().unwrap();
let parsed = decode_compact_claim(&compact).unwrap();
assert!(compact.starts_with(COMPACT_PROOF_PREFIX));
assert_eq!(parsed, signed);
parsed.verify().unwrap();
}
#[test]
fn tampered_claim_fails_verification() {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse("github:jason").unwrap();
let mut signed =
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap();
signed.payload.subject = Identity::parse("github:mallory").unwrap();
assert!(signed.verify().is_err());
}
#[test]
fn identity_parse_rejects_invalid_inputs() {
assert!(Identity::parse("").is_err(), "empty must fail");
assert!(Identity::parse(" ").is_err(), "whitespace-only must fail");
assert!(Identity::parse("no-colon").is_err(), "missing colon must fail");
assert!(Identity::parse(":missing-scheme").is_err());
assert!(Identity::parse("scheme:").is_err());
assert!(
Identity::parse("nostr:not-a-real-npub").is_err(),
"nostr scheme must validate the npub"
);
}
#[test]
fn identity_parse_round_trips_scheme_and_value() {
let id = Identity::parse("github:jason").unwrap();
assert_eq!(id.scheme(), "github");
assert_eq!(id.value(), "jason");
assert_eq!(id.as_str(), "github:jason");
}
#[test]
fn verify_rejects_unsupported_algorithm() {
let secret = NostrSecret::generate();
let primary = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let subject = Identity::parse("github:jason").unwrap();
let mut signed =
SignedClaim::sign(ClaimPayload::new(subject, primary, Utc::now()), &secret).unwrap();
signed.signature.alg = "made-up-suite".to_owned();
let err = signed.verify().unwrap_err();
assert!(matches!(err, KezError::UnsupportedAlgorithm(_)));
}
#[test]
fn verify_rejects_signature_key_not_matching_primary() {
let secret_a = NostrSecret::generate();
let secret_b = NostrSecret::generate();
let primary_a = Identity::parse(format!("nostr:{}", secret_a.npub())).unwrap();
let primary_b = Identity::parse(format!("nostr:{}", secret_b.npub())).unwrap();
let subject = Identity::parse("github:jason").unwrap();
let mut signed = SignedClaim::sign(
ClaimPayload::new(subject, primary_a.clone(), Utc::now()),
&secret_a,
)
.unwrap();
// Swap the envelope's declared signing key to a different identity.
signed.signature.key = primary_b;
assert!(signed.verify().is_err());
}
#[test]
fn nostr_pubkey_hex_returns_32_byte_lowercase() {
let secret = NostrSecret::generate();
let id = Identity::parse(format!("nostr:{}", secret.npub())).unwrap();
let hex_pk = nostr_pubkey_hex(&id).unwrap();
assert_eq!(hex_pk.len(), 64, "x-only pubkey is 32 bytes = 64 hex chars");
assert_eq!(hex_pk, hex_pk.to_lowercase());
}
#[test]
fn nostr_pubkey_hex_rejects_non_nostr() {
let id = Identity::parse("github:jason").unwrap();
assert!(nostr_pubkey_hex(&id).is_err());
}
#[test]
fn dns_txt_name_requires_dns_scheme() {
let dns = Identity::parse("dns:jason.example.com").unwrap();
assert_eq!(dns_txt_name(&dns).unwrap(), "_kez.jason.example.com");
let github = Identity::parse("github:jason").unwrap();
assert!(dns_txt_name(&github).is_err());
}
#[test]
fn decode_compact_claim_rejects_missing_prefix() {
assert!(decode_compact_claim("hello").is_err());
assert!(decode_compact_claim("kez1:foo").is_err());
}
#[test]
fn extract_markdown_proof_rejects_missing_or_unterminated_fence() {
assert!(extract_markdown_proof("no fence here").is_err());
assert!(extract_markdown_proof("```kez\n{ unterminated").is_err());
}
#[test]
fn ed25519_round_trips_seed_hex() {
let secret = Ed25519Secret::generate();
let seed = secret.seed_hex();
let restored = Ed25519Secret::from_seed_hex(&seed).unwrap();
assert_eq!(restored.pubkey_hex(), secret.pubkey_hex());
assert_eq!(restored, secret);
}
#[test]
fn ed25519_identity_is_lowercase_hex() {
let secret = Ed25519Secret::generate();
let id = secret.identity().unwrap();
assert_eq!(id.scheme(), "ed25519");
assert_eq!(id.value().len(), 64);
assert_eq!(id.value(), id.value().to_lowercase());
}
#[test]
fn identity_parse_validates_ed25519_format() {
assert!(Identity::parse("ed25519:tooshort").is_err());
assert!(Identity::parse(&format!("ed25519:{}", "Z".repeat(64))).is_err());
assert!(Identity::parse(&format!("ed25519:{}", "AB".repeat(32))).is_err()); // uppercase
assert!(Identity::parse(&format!("ed25519:{}", "ab".repeat(32))).is_ok());
}
#[test]
fn signs_and_verifies_ed25519_claim() {
let secret = Ed25519Secret::generate();
let primary = secret.identity().unwrap();
let subject = Identity::parse("github:jason").unwrap();
let payload = ClaimPayload::new(subject.clone(), primary.clone(), Utc::now());
let signed = SignedClaim::sign_ed25519(payload, &secret).unwrap();
assert_eq!(signed.signature.alg, ED25519_SHA512_ALG);
let status = signed.verify().unwrap();
assert_eq!(status.primary, primary);
assert_eq!(status.verified, vec![subject]);
assert_eq!(status.status, "valid");
}
#[test]
fn tampered_ed25519_claim_fails_verification() {
let secret = Ed25519Secret::generate();
let primary = secret.identity().unwrap();
let subject = Identity::parse("github:jason").unwrap();
let mut signed = SignedClaim::sign_ed25519(
ClaimPayload::new(subject, primary, Utc::now()),
&secret,
)
.unwrap();
signed.payload.subject = Identity::parse("github:mallory").unwrap();
assert!(signed.verify().is_err());
}
#[test]
fn ed25519_sign_with_unified_entry_point() {
let secret = Ed25519Secret::generate();
let primary = secret.identity().unwrap();
let subject = Identity::parse("github:jason").unwrap();
let payload = ClaimPayload::new(subject, primary, Utc::now());
let signed = SignedClaim::sign_with(payload, Signer::Ed25519(&secret)).unwrap();
signed.verify().unwrap();
}
// ── Sigchain ────────────────────────────────────────────────────────────
fn nostr_signer() -> (NostrSecret, Identity) {
let s = NostrSecret::generate();
let id = Identity::parse(format!("nostr:{}", s.npub())).unwrap();
(s, id)
}
#[test]
fn sigchain_appends_and_validates() {
let (secret, primary) = nostr_signer();
let mut chain = Sigchain::new(primary.clone());
assert!(chain.is_empty());
assert_eq!(chain.next_seq(), 0);
let subject = Identity::parse("github:jason").unwrap();
chain
.sign_add(subject.clone(), None, Signer::Nostr(&secret))
.unwrap();
assert_eq!(chain.len(), 1);
assert_eq!(chain.next_seq(), 1);
assert!(chain.is_active(&subject));
assert!(!chain.is_revoked(&subject));
chain.validate().unwrap();
}
#[test]
fn sigchain_revoke_flips_is_active() {
let (secret, primary) = nostr_signer();
let mut chain = Sigchain::new(primary);
let subject = Identity::parse("github:jason").unwrap();
chain
.sign_add(subject.clone(), None, Signer::Nostr(&secret))
.unwrap();
chain
.sign_revoke(subject.clone(), Signer::Nostr(&secret))
.unwrap();
assert!(chain.is_revoked(&subject));
assert!(!chain.is_active(&subject));
chain.validate().unwrap();
}
#[test]
fn sigchain_rejects_wrong_primary() {
let (secret_a, primary_a) = nostr_signer();
let (_secret_b, primary_b) = nostr_signer();
let mut chain = Sigchain::new(primary_a);
// Build an event whose payload claims a different primary.
let payload = SigchainEventPayload::new_add(
primary_b,
0,
None,
Identity::parse("github:jason").unwrap(),
None,
Utc::now(),
);
let signed = SignedSigchainEvent::sign(payload, &secret_a).unwrap();
let err = chain.append(signed).unwrap_err();
assert!(matches!(err, SigchainError::WrongPrimary { .. }));
}
#[test]
fn sigchain_rejects_seq_skip() {
let (secret, primary) = nostr_signer();
let mut chain = Sigchain::new(primary.clone());
chain
.sign_add(
Identity::parse("github:a").unwrap(),
None,
Signer::Nostr(&secret),
)
.unwrap();
// Hand-craft an event with seq=2 (skipping seq=1) but correct prev.
let head_hash = chain.head_hash().unwrap();
let payload = SigchainEventPayload::new_add(
primary,
2,
head_hash,
Identity::parse("github:b").unwrap(),
None,
Utc::now(),
);
let signed = SignedSigchainEvent::sign(payload, &secret).unwrap();
let err = chain.append(signed).unwrap_err();
assert!(matches!(err, SigchainError::SeqMismatch { .. }));
}
#[test]
fn sigchain_rejects_bad_prev_hash() {
let (secret, primary) = nostr_signer();
let mut chain = Sigchain::new(primary.clone());
chain
.sign_add(
Identity::parse("github:a").unwrap(),
None,
Signer::Nostr(&secret),
)
.unwrap();
// seq=1 but with the wrong prev hash.
let payload = SigchainEventPayload::new_add(
primary,
1,
Some("sha256:0000".to_owned()),
Identity::parse("github:b").unwrap(),
None,
Utc::now(),
);
let signed = SignedSigchainEvent::sign(payload, &secret).unwrap();
let err = chain.append(signed).unwrap_err();
assert!(matches!(err, SigchainError::PrevMismatch { .. }));
}
#[test]
fn sigchain_round_trips_jsonl() {
let (secret, primary) = nostr_signer();
let mut chain = Sigchain::new(primary);
let subject = Identity::parse("github:jason").unwrap();
chain
.sign_add(subject.clone(), None, Signer::Nostr(&secret))
.unwrap();
chain
.sign_revoke(subject.clone(), Signer::Nostr(&secret))
.unwrap();
let jsonl = chain.to_jsonl().unwrap();
let restored = Sigchain::from_jsonl(&jsonl).unwrap();
assert_eq!(restored.events(), chain.events());
assert!(restored.is_revoked(&subject));
}
#[test]
fn sigchain_from_jsonl_detects_tamper() {
let (secret, primary) = nostr_signer();
let mut chain = Sigchain::new(primary);
chain
.sign_add(
Identity::parse("github:a").unwrap(),
None,
Signer::Nostr(&secret),
)
.unwrap();
chain
.sign_add(
Identity::parse("github:b").unwrap(),
None,
Signer::Nostr(&secret),
)
.unwrap();
let mut jsonl = chain.to_jsonl().unwrap();
// Flip a byte in the middle line's content.
jsonl = jsonl.replacen("github:b", "github:c", 1);
let err = Sigchain::from_jsonl(&jsonl).unwrap_err();
// Either signature breaks first, or prev-hash check catches re-serialization.
assert!(matches!(
err,
SigchainError::BadSignature(_) | SigchainError::PrevMismatch { .. }
));
}
#[test]
fn sigchain_round_trips_compact_bundle() {
let (secret, primary) = nostr_signer();
let mut chain = Sigchain::new(primary);
chain
.sign_add(
Identity::parse("github:jason").unwrap(),
None,
Signer::Nostr(&secret),
)
.unwrap();
chain
.sign_revoke(
Identity::parse("github:jason").unwrap(),
Signer::Nostr(&secret),
)
.unwrap();
let compact = chain.to_compact_bundle().unwrap();
assert!(compact.starts_with(COMPACT_CHAIN_PREFIX));
let restored = Sigchain::from_compact_bundle(&compact).unwrap();
assert_eq!(restored.events(), chain.events());
}
#[test]
fn nostr_secret_pubkey_hex_matches_nostr_pubkey_hex_helper() {
let s = NostrSecret::generate();
let id = Identity::parse(format!("nostr:{}", s.npub())).unwrap();
assert_eq!(s.pubkey_hex(), nostr_pubkey_hex(&id).unwrap());
}
#[test]
fn nostr_secret_sign_raw_produces_valid_64_byte_sig() {
let s = NostrSecret::generate();
let digest = [42u8; 32];
let sig = s.sign_raw(&digest).unwrap();
assert_eq!(sig.len(), 64);
// Verifying with secp256k1 directly proves the sig is correct.
let sig_obj = Signature::from_slice(&sig).unwrap();
let pubkey = decode_npub(&s.npub()).unwrap();
let msg = Message::from_digest_slice(&digest).unwrap();
let secp = Secp256k1::verification_only();
secp.verify_schnorr(&sig_obj, &msg, &pubkey).unwrap();
}
#[test]
fn sigchain_works_with_ed25519_signer() {
let secret = Ed25519Secret::generate();
let primary = secret.identity().unwrap();
let mut chain = Sigchain::new(primary);
let subject = Identity::parse("github:jason").unwrap();
chain
.sign_add(subject.clone(), None, Signer::Ed25519(&secret))
.unwrap();
assert!(chain.is_active(&subject));
chain.validate().unwrap();
}
}