Skip to main content

qualia_client_core/
connection_identifier.rs

1//! **Connection identifier** — the single signed payload that underlies every connection method (email
2//! string, magic link, DNS record, token metadata). It extends the connect-invite with the WireGuard
3//! peering material + ordered rendezvous hints, and encodes to a compact, copy-pasteable
4//! `qcx1_<base64url>` string. Self-certifying: ed25519-signed over its own fields, so a recipient verifies
5//! it without any third party. See `docs/plans/social-network-plan.md` §1.
6
7use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
8use serde::{Deserialize, Serialize};
9
10use base64::Engine as _;
11
12pub const CI_VERSION: u8 = 1;
13const CI_PREFIX: &str = "qcx1_";
14const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
15
16/// One rendezvous hint — where to try to reach the peer, tried in list order.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct RendezvousHint {
19    /// `"domain" | "edge" | "nym" | "relay" | "libp2p" | "mailbox" | "mdns"`.
20    pub kind: String,
21    pub value: String,
22}
23
24/// The universal, self-certifying connection payload.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct ConnectionIdentifier {
27    pub version: u8,
28    /// The peer's front-door DID (identifier, not identity).
29    pub front_door_did: String,
30    /// ed25519 identity public key (hex) — verifies `signature_hex`.
31    pub identity_pubkey_hex: String,
32    /// WireGuard public key (hex) — the peering material.
33    pub wireguard_pubkey_hex: String,
34    /// Derived overlay address (CGA-like, from the WireGuard pubkey).
35    pub overlay_addr: String,
36    /// Ordered rendezvous hints (domain, edge, nym, relay, libp2p…).
37    pub rendezvous: Vec<RendezvousHint>,
38    /// Proposed relationship type (`spc:relationType` id) — the agreement seed.
39    pub relation_type: String,
40    pub display_name: String,
41    pub created_at: u64,
42    /// 0 = no expiry.
43    pub expires_at: u64,
44    /// Single-use nonce (anti-replay).
45    pub nonce: String,
46    /// ed25519 signature (hex) over [`signing_payload`](Self::signing_payload).
47    #[serde(default)]
48    pub signature_hex: String,
49}
50
51impl ConnectionIdentifier {
52    /// The canonical bytes the signature covers (everything except the signature itself).
53    pub fn signing_payload(&self) -> Vec<u8> {
54        let rv: Vec<String> = self
55            .rendezvous
56            .iter()
57            .map(|r| format!("{}={}", r.kind, r.value))
58            .collect();
59        format!(
60            "qcx1|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
61            self.version,
62            self.front_door_did,
63            self.identity_pubkey_hex,
64            self.wireguard_pubkey_hex,
65            self.overlay_addr,
66            rv.join(","),
67            self.relation_type,
68            self.display_name,
69            self.created_at,
70            self.expires_at,
71            self.nonce,
72        )
73        .into_bytes()
74    }
75
76    /// Sign the identifier with the ed25519 identity key (sets `identity_pubkey_hex` + `signature_hex`).
77    pub fn sign(&mut self, key: &SigningKey) {
78        self.identity_pubkey_hex = hex::encode(VerifyingKey::from(key).to_bytes());
79        let sig = key.sign(&self.signing_payload());
80        self.signature_hex = hex::encode(sig.to_bytes());
81    }
82
83    /// Verify the self-certifying signature. Does **not** check expiry (see [`is_expired`](Self::is_expired)).
84    pub fn verify(&self) -> Result<(), String> {
85        let pk =
86            hex::decode(&self.identity_pubkey_hex).map_err(|e| format!("bad identity key: {e}"))?;
87        let pk: [u8; 32] = pk
88            .as_slice()
89            .try_into()
90            .map_err(|_| "identity key must be 32 bytes".to_string())?;
91        let vk = VerifyingKey::from_bytes(&pk).map_err(|e| format!("bad identity key: {e}"))?;
92        let sig = hex::decode(&self.signature_hex).map_err(|e| format!("bad signature: {e}"))?;
93        let sig: [u8; 64] = sig
94            .as_slice()
95            .try_into()
96            .map_err(|_| "signature must be 64 bytes".to_string())?;
97        vk.verify(&self.signing_payload(), &Signature::from_bytes(&sig))
98            .map_err(|_| "signature verification failed".to_string())
99    }
100
101    pub fn is_expired(&self, now_unix: u64) -> bool {
102        self.expires_at != 0 && now_unix > self.expires_at
103    }
104
105    /// Encode to a compact, copy-pasteable `qcx1_<base64url>` string (CBOR under the hood).
106    pub fn encode(&self) -> Result<String, String> {
107        let mut buf = Vec::new();
108        ciborium::into_writer(self, &mut buf).map_err(|e| format!("encode: {e}"))?;
109        Ok(format!("{CI_PREFIX}{}", B64.encode(&buf)))
110    }
111
112    /// Decode a `qcx1_…` string. Does not verify — call [`verify`](Self::verify) after.
113    pub fn decode(s: &str) -> Result<Self, String> {
114        let body = s
115            .trim()
116            .strip_prefix(CI_PREFIX)
117            .ok_or("not a qcx1 connection identifier")?;
118        let bytes = B64.decode(body).map_err(|e| format!("base64: {e}"))?;
119        ciborium::from_reader(&bytes[..]).map_err(|e| format!("decode: {e}"))
120    }
121}
122
123/// Derive a deterministic ULA IPv6 overlay address (`fd00::/8`) from a WireGuard public key — a
124/// self-certifying (CGA-like) address: it *is* a hash of the key, so it cannot be spoofed.
125pub fn derive_overlay_addr(wireguard_pubkey: &[u8]) -> String {
126    use sha2::{Digest, Sha256};
127    let h = Sha256::digest(wireguard_pubkey);
128    let mut addr = [0u8; 16];
129    addr[0] = 0xfd;
130    addr[1..16].copy_from_slice(&h[0..15]);
131    std::net::Ipv6Addr::from(addr).to_string()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn sample() -> ConnectionIdentifier {
139        ConnectionIdentifier {
140            version: CI_VERSION,
141            front_door_did: "did:qualia:frontdoor:alice".into(),
142            identity_pubkey_hex: String::new(),
143            wireguard_pubkey_hex: "aa".repeat(32),
144            overlay_addr: derive_overlay_addr(&[0xaau8; 32]),
145            rendezvous: vec![
146                RendezvousHint {
147                    kind: "domain".into(),
148                    value: "alice.example".into(),
149                },
150                RendezvousHint {
151                    kind: "edge".into(),
152                    value: "https://edge.alice.example".into(),
153                },
154            ],
155            relation_type: "spc:GuardianshipArrangement".into(),
156            display_name: "Alice".into(),
157            created_at: 1_700_000_000,
158            expires_at: 1_700_604_800,
159            nonce: "n-123".into(),
160            signature_hex: String::new(),
161        }
162    }
163
164    #[test]
165    fn sign_then_verify_ok() {
166        let key = SigningKey::from_bytes(&[7u8; 32]);
167        let mut id = sample();
168        id.sign(&key);
169        assert!(!id.signature_hex.is_empty());
170        assert!(!id.identity_pubkey_hex.is_empty());
171        id.verify().expect("valid signature verifies");
172    }
173
174    #[test]
175    fn tampering_breaks_the_signature() {
176        let key = SigningKey::from_bytes(&[7u8; 32]);
177        let mut id = sample();
178        id.sign(&key);
179        // Flip a signed field after signing.
180        id.wireguard_pubkey_hex = "bb".repeat(32);
181        assert!(
182            id.verify().is_err(),
183            "tampered payload must fail verification"
184        );
185    }
186
187    #[test]
188    fn encode_decode_roundtrips_and_stays_verified() {
189        let key = SigningKey::from_bytes(&[9u8; 32]);
190        let mut id = sample();
191        id.sign(&key);
192        let s = id.encode().expect("encode");
193        assert!(s.starts_with("qcx1_"));
194        let back = ConnectionIdentifier::decode(&s).expect("decode");
195        assert_eq!(back, id, "lossless round-trip");
196        back.verify().expect("still verifies after decode");
197    }
198
199    #[test]
200    fn expiry_is_checked_separately() {
201        let id = sample();
202        assert!(!id.is_expired(1_700_000_050));
203        assert!(id.is_expired(1_700_604_801));
204        // 0 = never expires.
205        let mut forever = sample();
206        forever.expires_at = 0;
207        assert!(!forever.is_expired(u64::MAX));
208    }
209
210    #[test]
211    fn overlay_addr_is_deterministic_ula() {
212        let a = derive_overlay_addr(&[1u8; 32]);
213        let b = derive_overlay_addr(&[1u8; 32]);
214        assert_eq!(a, b, "deterministic");
215        assert!(a.starts_with("fd"), "ULA fd00::/8");
216        assert_ne!(
217            a,
218            derive_overlay_addr(&[2u8; 32]),
219            "different key → different address"
220        );
221    }
222
223    #[test]
224    fn decode_rejects_non_qcx1() {
225        assert!(ConnectionIdentifier::decode("hello").is_err());
226        assert!(ConnectionIdentifier::decode("qcx1_!!!not-base64!!!").is_err());
227    }
228}