Skip to main content

vector_core/community/
owner.rs

1//! Owner attestation — the unforgeable binding of a Community to its owner's identity (
2//! "anchored to the owner's identity key").
3//!
4//! At creation the owner signs, with their IDENTITY key, a statement binding the Community's random
5//! `community_id`. The proven owner is then DERIVED from the signature (`event.pubkey`), never
6//! asserted separately — so:
7//!   - you cannot frame an innocent npub (that requires their key to sign), and
8//!   - the binding can't be transplanted to another community (the unique id is inside the signed
9//!     payload, so an attestation for community X can't be replayed as community Y's).
10//!
11//! Members verify it against the very community id they already hold. Server-root encrypted in
12//! transit, so outsiders learn nothing; only members see who the owner is. (The community is
13//! keyless — there is no management/authority key to bind; `community_id` alone is the anchor.)
14
15use crate::event_ext::FinalizeUnsignedWithId;
16use crate::stored_event::event_kind;
17use nostr_sdk::prelude::*;
18
19/// Tag binding the attestation to its community. `["vco", community_id]`.
20const TAG_OWNER: &str = "vco";
21
22/// The unsigned owner-attestation event. Sign it with the owner's IDENTITY signer (local or
23/// bunker — it's a normal event, so NIP-46 works) at community creation.
24pub fn build_owner_attestation_unsigned(
25    owner_pubkey: PublicKey,
26    community_id: &str,
27) -> UnsignedEvent {
28    EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
29        .tags([Tag::custom(
30            TAG_OWNER,
31            [community_id.to_string()],
32        )])
33        .finalize_unsigned_with_id(owner_pubkey)
34}
35
36/// Verify an owner-attestation event (JSON). Returns the PROVEN owner pubkey iff the signature
37/// is valid AND it binds exactly this `community_id`. `None` on any missing/mismatched/forged
38/// input — the caller then treats ownership as unverified (no crown).
39pub fn verify_owner_attestation(
40    attestation_json: &str,
41    community_id: &str,
42) -> Option<PublicKey> {
43    let ev: Event = serde_json::from_str(attestation_json).ok()?;
44    ev.verify().ok()?; // id + Schnorr signature
45    let bound = ev.tags.iter().find_map(|t| {
46        let s = t.as_slice();
47        (s.len() >= 2 && s[0] == TAG_OWNER).then(|| s[1].clone())
48    })?;
49    (bound == community_id).then_some(ev.pubkey)
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn attestation_round_trips_binds_and_rejects_forgery() {
58        let owner = Keys::generate();
59        let cid = "a".repeat(64);
60
61        let signed = build_owner_attestation_unsigned(owner.public_key(), &cid)
62            .finalize(&owner)
63            .unwrap();
64        let json = signed.as_json();
65
66        // Valid → the proven owner is the signer.
67        assert_eq!(verify_owner_attestation(&json, &cid), Some(owner.public_key()));
68        // Can't transplant to another community.
69        assert_eq!(verify_owner_attestation(&json, &"b".repeat(64)), None);
70        // Garbage in → None, never a panic.
71        assert_eq!(verify_owner_attestation("not json", &cid), None);
72
73        // Framing defense: a forger can only ever attest THEMSELVES — verify returns the
74        // forger's pubkey, never the victim's, so they can't make the UI crown someone else.
75        let mallory = Keys::generate();
76        let forged = build_owner_attestation_unsigned(mallory.public_key(), &cid)
77            .finalize(&mallory)
78            .unwrap()
79            .as_json();
80        assert_eq!(verify_owner_attestation(&forged, &cid), Some(mallory.public_key()));
81        assert_ne!(verify_owner_attestation(&forged, &cid), Some(owner.public_key()));
82    }
83
84    #[test]
85    fn attestation_tag_layout_is_frozen() {
86        // the attestation's signed form is FROZEN. It is verify-only (other clients check the
87        // owner's signature, never reconstruct the event — created_at is non-deterministic), so the
88        // pinned interop contract is the TAG layout: exactly one `["vco", <community_id>]` tag, empty
89        // content. A drift here (extra element, reordered, renamed) breaks every verifier reading idx 1.
90        let owner = Keys::generate();
91        let cid = "c".repeat(64);
92        let unsigned = build_owner_attestation_unsigned(owner.public_key(), &cid);
93        assert_eq!(unsigned.content, "");
94        let tags: Vec<Vec<String>> = unsigned.tags.iter().map(|t| t.as_slice().to_vec()).collect();
95        assert_eq!(tags, vec![vec!["vco".to_string(), cid]], "exactly one [\"vco\", community_id] tag");
96    }
97}