Kez/rust/crates/kez-channels/src/activitypub.rs
Tudisco d0db6f00f1 Initial implementation of KEZ — protocol, two impls, and storage server
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).
2026-05-24 14:41:00 -06:00

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 &amp; b &lt;c&gt;"), "a & b <c>");
assert_eq!(strip_html("&quot;quoted&quot;"), r#""quoted""#);
assert_eq!(strip_html("&#39;apos&#39;"), "'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```");
}
}