Tudisco 111b23b94b feat(kez-chat): scaffold the home server (v0.1)
First runnable kez-chat-server binary plus its docker-compose deploy
recipe. Implements steps 2-3 of the document.md sequenced plan; the
rust-lib refactor (step 1) is deferred — chat-server path-deps on
rust/crates/kez-core for now, which works and matches what
rust-sig-server already does.

What's in this commit:

kez-core (1-line change)
- New public `verify_envelope<T>(payload, signature)` helper that
  dispatches Schnorr / Ed25519 / future suites by signature.alg.
  Used by chat-server's registration verifier; downstream value
  beyond chat-server too.

kez-chat-server (new crate)
- src/main.rs: tokio + axum + tracing entry; clap config; graceful
  Ctrl-C shutdown.
- src/lib.rs: re-exports so tests can drive the same router.
- src/config.rs: env/flag config (bind, db, server, sig_server_url,
  web_dir) with defaults sane for both dev and prod.
- src/error.rs: typed ApiError → structured JSON responses with
  stable error codes.
- src/store.rs: SQLite-backed handle registry, UNIQUE on both
  (handle) and (primary_id); race-safe via SQL primary key.
- src/handles.rs: username validation (length, charset, reserved
  list, must start with letter/digit).
- src/registration.rs: SignedRegistration envelope sharing KEZ's
  JCS canonical-bytes pattern; signature verification via the new
  kez-core helper; replay protection via ±5-minute clock skew check.
- src/api.rs: all six routes in one file —
    GET  /v1/healthz
    GET  /v1/u/:handle
    POST /v1/register
    GET  /.well-known/webfinger
    POST /internal/nats/auth   (501 stub for v0.1; wired up in v0.2)
    GET  /                     (placeholder HTML; ServeDir when web/dist exists)

tests/http.rs — 13 integration tests
- Stands up the real router on a random port; uses reqwest.
- Coverage: healthz, lookup-404, full register→lookup round-trip,
  duplicate-handle conflict, wrong-server rejection, reserved-name
  rejection, tampered-signature rejection, stale-timestamp rejection,
  WebFinger success + wrong-server-404, placeholder SPA renders,
  NATS callout 501, JCS determinism sanity.

deploy/
- Dockerfile: multi-stage build (rust:1.86-slim → debian:bookworm-slim).
  Build context is repo root so the path dep on kez-core resolves.
  Runtime image ~50 MB; runs as non-root uid 10001.
- Dockerfile.sig-server: same pattern for the existing
  rust-sig-server, so the stack builds from one git pull.
- docker-compose.yml: three services (chat-server + nats + sig-server)
  with named volumes for persistence. Ports: 6969 (chat HTTP),
  4222/8443/8222 (NATS native/ws/monitoring), 7878 (sig-server).
- nats.conf: WebSocket on 8443 for the browser SPA, JetStream
  enabled, auth_callout pointing at chat-server's
  /internal/nats/auth endpoint (issuer nkey is a placeholder — must
  be replaced with a real one before going live).

README.md
- Documents all endpoints with example bodies.
- Quick-start for both local dev and full Docker compose.
- Honest list of what's in v0.1 vs what's still stubbed.

Smoke-tested running on 127.0.0.1:6969:
  GET /v1/healthz       → {"server":"kez.lat","status":"ok","version":"0.1.0"}
  GET /                 → placeholder HTML rendering
  GET /v1/u/ghost       → 404
  POST /internal/nats/auth → 501 with "wired up in v0.2"

cargo test  → 13 passed.
cargo build --release → 19.6s, clean.
2026-05-24 23:36:53 -06:00

1383 lines
47 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 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(())
}
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();
}
}