qualia_client_core/
connection_identifier.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct RendezvousHint {
19 pub kind: String,
21 pub value: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct ConnectionIdentifier {
27 pub version: u8,
28 pub front_door_did: String,
30 pub identity_pubkey_hex: String,
32 pub wireguard_pubkey_hex: String,
34 pub overlay_addr: String,
36 pub rendezvous: Vec<RendezvousHint>,
38 pub relation_type: String,
40 pub display_name: String,
41 pub created_at: u64,
42 pub expires_at: u64,
44 pub nonce: String,
46 #[serde(default)]
48 pub signature_hex: String,
49}
50
51impl ConnectionIdentifier {
52 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 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 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 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 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
123pub 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 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 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}