KEZ is a portable, decentralized identity graph: a person signs claims
linking their many accounts, publishes those claims in places only the
claimed account can publish to, and anyone can verify the connections
without trusting a central server.
Layout
------
- SPEC.md Language-agnostic protocol spec (v0.2)
- rust/ Rust implementation: kez-core, kez-channels, kez-cli
- nodejs/ TypeScript port at full parity
- rust-sig-server/ Optional axum + SQLite storage server for sigchains
- crosstest.sh Cross-implementation interop harness
Capabilities (both implementations, byte-compatible)
----------------------------------------------------
- Two primary-key algorithms: nostr/secp256k1 Schnorr (BIP-340) and
Ed25519 (RFC 8032). Identifiers: nostr:npub1... and ed25519:<hex>.
- JCS (RFC 8785) canonicalization for everything signed.
- Four proof encodings: JSON envelope, compact (kez:z1:<base64url(zstd(json))>),
Markdown fence, DNS TXT.
- Five channel plugins (no API keys, no auth needed for any of them):
dns: system resolver, _kez.<domain> TXT records
github: public gist scan + <user>/<user> profile README fallback
nostr: kind-30078 events from default relays
bluesky: public AppView author feed
ap: WebFinger + actor JSON (alias mastodon:)
- Identical CLI surface:
kez identity new [--key-type nostr|ed25519]
kez claim create <subject> (--nsec | --ed25519-seed) [--format ...] [--out ...]
kez claim dns <domain> (--nsec | --ed25519-seed)
kez verify file <path>
kez verify id <identifier>
kez sigchain add|revoke|show|export|publish
- Sigchains: append-only signed log per primary, hash-chained per spec §6,
stored locally at ~/.kez/sigchains/, exportable as JSONL or kez:zc1: bundle.
- Sigchain publish destinations: chain server, web (file dump), DNS (zone
record print), nostr (kind-30078 wrapping event).
kez-sig-server
--------------
Optional storage tier. Axum + SQLite, single binary, no external deps.
- No auth — the cryptography is the access control. The server validates
every signature, every seq, every prev hash before storing.
- REST API: POST /v1/sigchains/{scheme}/{id}/events (append signed event,
201 with new head hash or 4xx); GET /{scheme}/{id} (full chain as JSONL);
GET /head; GET /healthz.
- Designed for one central instance for now; the design doesn't preclude
running more later (clients gain a configurable list, verifiers
reconcile per spec §6.2).
- Channel-based publishing remains the always-available fallback if the
server is unavailable.
Tests
-----
- rust/ 99 tests
- rust-sig-server/ 10 integration tests (real HTTP, real SQLite)
- nodejs/ 91 tests (vitest)
- crosstest.sh 19 cross-impl scenarios — proves JCS bytes,
Schnorr + Ed25519 sigs, all four claim encodings,
and the sigchain JSONL bundle are byte-compatible
between Rust and Node in both directions.
What's not done yet
-------------------
- verify id consulting the sigchain for revocations (data path exists,
just not wired into the verifier output).
- rotate and add_device sigchain ops (types reserved).
- expires_at enforcement during claim verification.
- Typed VerificationStatus.status reflecting the five failure modes.
- Auth-required publishers (GitHub gist, Bluesky, ActivityPub).
346 lines
12 KiB
Rust
346 lines
12 KiB
Rust
//! ActivityPub channel: works for any ActivityPub-compatible service —
|
|
//! Mastodon, Pleroma, Akkoma, Misskey, GoToSocial, Friendica, PeerTube, …
|
|
//!
|
|
//! Two public endpoints, no auth:
|
|
//!
|
|
//! 1. **WebFinger** — `GET https://<server>/.well-known/webfinger?resource=acct:<user>@<server>`
|
|
//! Returns the user's ActivityPub actor URL.
|
|
//! 2. **Actor JSON** — `GET <actor-url>` with `Accept: application/activity+json`
|
|
//! Returns the user's profile, including `attachment` (Mastodon profile
|
|
//! fields) and `summary` (bio).
|
|
//!
|
|
//! Proof discovery order:
|
|
//! 1. `attachment[].value` (profile fields — Mastodon's explicit "user-published
|
|
//! metadata" surface)
|
|
//! 2. `summary` (bio)
|
|
//!
|
|
//! Pinned posts (`featured` collection) are a TODO for v0.2; not needed for the
|
|
//! minimal flow because the compact `kez:z1:` form fits in both attachment
|
|
//! values and bios on every major instance.
|
|
//!
|
|
//! Identifier shape: `ap:@<user>@<server>` is canonical. `mastodon:@<user>@<server>`
|
|
//! is registered as an alias and dispatches to the same adapter.
|
|
|
|
use async_trait::async_trait;
|
|
use kez_core::Identity;
|
|
use reqwest::Client;
|
|
use serde_json::Value;
|
|
|
|
use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for};
|
|
|
|
const USER_AGENT: &str = "kez-channels/0.1 (+https://example.invalid/kez)";
|
|
|
|
#[derive(Clone)]
|
|
pub struct ActivityPubChannel {
|
|
client: Client,
|
|
/// If set, every fetch (WebFinger + actor) goes here instead of
|
|
/// `https://<server>`. Used by tests pointing at wiremock. None in prod.
|
|
base_override: Option<String>,
|
|
/// Canonical scheme this instance reports via `Channel::system()`.
|
|
/// In production this is "ap"; aliases (e.g. "mastodon") get registered
|
|
/// separately in the registry.
|
|
canonical_system: &'static str,
|
|
}
|
|
|
|
impl ActivityPubChannel {
|
|
pub fn new() -> anyhow::Result<Self> {
|
|
let client = Client::builder().user_agent(USER_AGENT).build()?;
|
|
Ok(Self {
|
|
client,
|
|
base_override: None,
|
|
canonical_system: "ap",
|
|
})
|
|
}
|
|
|
|
/// For tests: route every HTTP call to `base` regardless of server name.
|
|
pub fn with_base(client: Client, base: String) -> Self {
|
|
Self {
|
|
client,
|
|
base_override: Some(base),
|
|
canonical_system: "ap",
|
|
}
|
|
}
|
|
|
|
fn base_for(&self, server: &str) -> String {
|
|
self.base_override
|
|
.clone()
|
|
.unwrap_or_else(|| format!("https://{server}"))
|
|
}
|
|
|
|
async fn fetch_json(&self, url: &str, accept: &str) -> ChannelResult<Value> {
|
|
let resp = self
|
|
.client
|
|
.get(url)
|
|
.header("Accept", accept)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?;
|
|
|
|
// Distinguish 404 (NotFound) from other failures (Unreachable).
|
|
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
|
return Err(ChannelError::Unreachable(format!("GET {url}: 404")));
|
|
}
|
|
let resp = resp
|
|
.error_for_status()
|
|
.map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?;
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| ChannelError::Other(anyhow::anyhow!("parse JSON {url}: {e}")))
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for ActivityPubChannel {
|
|
fn system(&self) -> &'static str {
|
|
self.canonical_system
|
|
}
|
|
|
|
async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult<ChannelHit> {
|
|
let (user, server) = parse_handle(identity.value())?;
|
|
let base = self.base_for(&server);
|
|
|
|
// 1. WebFinger → actor URL.
|
|
let wf_url = webfinger_url(&base, &user, &server);
|
|
let wf = self
|
|
.fetch_json(&wf_url, "application/jrd+json")
|
|
.await?;
|
|
let actor_url = extract_actor_url(&wf)
|
|
.ok_or_else(|| ChannelError::NotFound(identity.clone()))?;
|
|
|
|
// 2. Actor JSON → candidate proof strings.
|
|
let actor = self
|
|
.fetch_json(&actor_url, "application/activity+json")
|
|
.await?;
|
|
let candidates = extract_actor_candidates(&actor);
|
|
|
|
// 3. Try each candidate against parse_and_verify_for.
|
|
let mut last_error: Option<ChannelError> = None;
|
|
for raw in candidates {
|
|
match parse_and_verify_for(&raw, identity) {
|
|
Ok(hit) => return Ok(hit),
|
|
Err(err) => last_error = Some(err),
|
|
}
|
|
}
|
|
Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone())))
|
|
}
|
|
}
|
|
|
|
/// Pure: split a Mastodon-style handle (`@user@server` or `user@server`) into
|
|
/// its parts. Leading `@` is stripped.
|
|
pub fn parse_handle(value: &str) -> ChannelResult<(String, String)> {
|
|
let trimmed = value.strip_prefix('@').unwrap_or(value);
|
|
let (user, server) = trimmed.split_once('@').ok_or_else(|| {
|
|
ChannelError::Other(anyhow::anyhow!(
|
|
"expected `@user@server`, got: {value}"
|
|
))
|
|
})?;
|
|
if user.is_empty() || server.is_empty() {
|
|
return Err(ChannelError::Other(anyhow::anyhow!(
|
|
"invalid handle (empty part): {value}"
|
|
)));
|
|
}
|
|
Ok((user.to_owned(), server.to_owned()))
|
|
}
|
|
|
|
/// Pure: WebFinger URL.
|
|
pub fn webfinger_url(base: &str, user: &str, server: &str) -> String {
|
|
format!("{base}/.well-known/webfinger?resource=acct:{user}@{server}")
|
|
}
|
|
|
|
/// Pure: pull the actor URL out of a WebFinger response. We accept either
|
|
/// `application/activity+json` or `application/ld+json` in the link's
|
|
/// `type` (Mastodon uses the former, some servers use the latter).
|
|
pub fn extract_actor_url(webfinger: &Value) -> Option<String> {
|
|
let links = webfinger.get("links")?.as_array()?;
|
|
for link in links {
|
|
let rel = link.get("rel").and_then(|v| v.as_str()).unwrap_or("");
|
|
let typ = link.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
if rel == "self" && (typ.contains("activity+json") || typ.contains("ld+json"))
|
|
&& let Some(href) = link.get("href").and_then(|v| v.as_str())
|
|
{
|
|
return Some(href.to_owned());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Pure: pull candidate proof strings out of an Actor JSON. Attachments come
|
|
/// first (explicit user-published metadata fields), bio second.
|
|
pub fn extract_actor_candidates(actor: &Value) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
|
|
if let Some(attachments) = actor.get("attachment").and_then(|v| v.as_array()) {
|
|
for att in attachments {
|
|
if let Some(value) = att.get("value").and_then(|v| v.as_str()) {
|
|
out.push(strip_html(value));
|
|
}
|
|
}
|
|
}
|
|
if let Some(summary) = actor.get("summary").and_then(|v| v.as_str()) {
|
|
out.push(strip_html(summary));
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
/// Pure: strip HTML tags and decode a small set of named entities so that
|
|
/// `parse_proof` can run against the underlying text. Good enough for
|
|
/// Mastodon-style bios and PropertyValue fields; we are not building a
|
|
/// general HTML parser.
|
|
pub fn strip_html(html: &str) -> String {
|
|
let mut out = String::with_capacity(html.len());
|
|
let mut chars = html.chars().peekable();
|
|
while let Some(c) = chars.next() {
|
|
match c {
|
|
'<' => {
|
|
// Drop everything up to and including the next '>'.
|
|
for next in chars.by_ref() {
|
|
if next == '>' {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
'&' => {
|
|
let mut entity = String::new();
|
|
let mut closed = false;
|
|
for _ in 0..8 {
|
|
match chars.peek() {
|
|
Some(';') => {
|
|
chars.next();
|
|
closed = true;
|
|
break;
|
|
}
|
|
Some(&c2) if c2.is_ascii_alphanumeric() || c2 == '#' => {
|
|
chars.next();
|
|
entity.push(c2);
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
if closed {
|
|
match entity.as_str() {
|
|
"amp" => out.push('&'),
|
|
"lt" => out.push('<'),
|
|
"gt" => out.push('>'),
|
|
"quot" => out.push('"'),
|
|
"apos" | "#39" => out.push('\''),
|
|
"nbsp" => out.push(' '),
|
|
_ => {
|
|
out.push('&');
|
|
out.push_str(&entity);
|
|
out.push(';');
|
|
}
|
|
}
|
|
} else {
|
|
out.push('&');
|
|
out.push_str(&entity);
|
|
}
|
|
}
|
|
_ => out.push(c),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn parse_handle_accepts_canonical_and_unprefixed() {
|
|
let (u, s) = parse_handle("@jason@mastodon.social").unwrap();
|
|
assert_eq!(u, "jason");
|
|
assert_eq!(s, "mastodon.social");
|
|
|
|
let (u, s) = parse_handle("jason@mastodon.social").unwrap();
|
|
assert_eq!(u, "jason");
|
|
assert_eq!(s, "mastodon.social");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_handle_rejects_malformed() {
|
|
assert!(parse_handle("jason").is_err()); // no server
|
|
assert!(parse_handle("@@server").is_err()); // empty user
|
|
assert!(parse_handle("@jason@").is_err()); // empty server
|
|
}
|
|
|
|
#[test]
|
|
fn webfinger_url_matches_spec_shape() {
|
|
let url = webfinger_url("https://mastodon.social", "jason", "mastodon.social");
|
|
assert_eq!(
|
|
url,
|
|
"https://mastodon.social/.well-known/webfinger?resource=acct:jason@mastodon.social"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_actor_url_picks_self_activity_json() {
|
|
let wf = json!({
|
|
"subject": "acct:jason@mastodon.social",
|
|
"links": [
|
|
{"rel": "http://webfinger.net/rel/profile-page", "type": "text/html", "href": "https://mastodon.social/@jason"},
|
|
{"rel": "self", "type": "application/activity+json", "href": "https://mastodon.social/users/jason"}
|
|
]
|
|
});
|
|
assert_eq!(
|
|
extract_actor_url(&wf).as_deref(),
|
|
Some("https://mastodon.social/users/jason")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_actor_url_accepts_ld_json() {
|
|
let wf = json!({
|
|
"links": [{"rel": "self", "type": "application/ld+json; profile=\"...\"", "href": "https://example/u/jason"}]
|
|
});
|
|
assert_eq!(extract_actor_url(&wf).as_deref(), Some("https://example/u/jason"));
|
|
}
|
|
|
|
#[test]
|
|
fn extract_actor_url_missing_returns_none() {
|
|
assert!(extract_actor_url(&json!({})).is_none());
|
|
assert!(extract_actor_url(&json!({"links": []})).is_none());
|
|
assert!(extract_actor_url(&json!({
|
|
"links": [{"rel": "self", "type": "text/html", "href": "..."}]
|
|
}))
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn extract_actor_candidates_attachment_then_summary() {
|
|
let actor = json!({
|
|
"attachment": [
|
|
{"type": "PropertyValue", "name": "site", "value": "<a href=\"https://x\">x</a>"},
|
|
{"type": "PropertyValue", "name": "kez", "value": "kez:z1:abc"}
|
|
],
|
|
"summary": "<p>bio with kez:z1:def in it</p>"
|
|
});
|
|
let cands = extract_actor_candidates(&actor);
|
|
// attachments are emitted first, in order
|
|
assert_eq!(cands[0], "x");
|
|
assert_eq!(cands[1], "kez:z1:abc");
|
|
assert_eq!(cands[2], "bio with kez:z1:def in it");
|
|
}
|
|
|
|
#[test]
|
|
fn strip_html_handles_tags_and_entities() {
|
|
assert_eq!(strip_html("<p>hello <b>world</b></p>"), "hello world");
|
|
assert_eq!(strip_html("a & b <c>"), "a & b <c>");
|
|
assert_eq!(strip_html(""quoted""), r#""quoted""#);
|
|
assert_eq!(strip_html("'apos'"), "'apos'");
|
|
}
|
|
|
|
#[test]
|
|
fn strip_html_preserves_compact_kez_prefix() {
|
|
let html = "<p>my proof: kez:z1:KLUv_QBYabc</p>";
|
|
assert_eq!(strip_html(html), "my proof: kez:z1:KLUv_QBYabc");
|
|
}
|
|
|
|
#[test]
|
|
fn strip_html_preserves_markdown_fence_chars() {
|
|
let html = "<p>```kez\n{...}\n```</p>";
|
|
assert_eq!(strip_html(html), "```kez\n{...}\n```");
|
|
}
|
|
}
|