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>
1394 lines
48 KiB
Rust
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();
|
|
}
|
|
}
|