Privacy-preserving location sharing with end-to-end encryption
coord.is
1use axum::{
2 extract::State,
3 http::StatusCode,
4 response::Html,
5 routing::{get, post, put},
6 Json, Router,
7};
8use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
9use dashmap::DashMap;
10use ed25519_dalek::{Signature, Verifier, VerifyingKey};
11use serde::{Deserialize, Serialize};
12use std::{collections::HashMap, sync::Arc, time::SystemTime};
13
14const MAX_BLOB_SIZE: usize = 64 * 1024;
15const MAX_TIMESTAMP_AGE_SECS: u64 = 300;
16
17#[derive(Clone, Serialize)]
18struct StoredLocation {
19 #[serde(serialize_with = "as_base64")]
20 blob: Vec<u8>,
21 updated: u64,
22}
23
24fn as_base64<S: serde::Serializer>(bytes: &Vec<u8>, s: S) -> Result<S::Ok, S::Error> {
25 s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
26}
27
28type PostResponse = HashMap<String, Option<StoredLocation>>;
29
30type LocationStore = Arc<DashMap<[u8; 32], StoredLocation>>;
31
32#[derive(Deserialize)]
33struct PutRequest {
34 pubkey: String,
35 timestamp: u64,
36 blob: String,
37 signature: String,
38}
39
40#[derive(Deserialize)]
41struct PostRequest {
42 ids: Vec<String>,
43}
44
45
46const VERSION: &str = env!("CARGO_PKG_VERSION");
47
48pub fn app() -> Router {
49 let store: LocationStore = Arc::new(DashMap::new());
50 Router::new()
51 .route("/api/version", get(version_info))
52 .route("/api/privacy", get(privacy))
53 .route("/api/location", put(put_location))
54 .route("/api/location", post(post_location))
55 .with_state(store)
56}
57
58async fn version_info() -> Json<serde_json::Value> {
59 Json(serde_json::json!({
60 "name": "coords",
61 "version": VERSION
62 }))
63}
64
65async fn privacy() -> Html<&'static str> {
66 Html(include_str!("privacy.html"))
67}
68
69async fn put_location(
70 State(store): State<LocationStore>,
71 Json(req): Json<PutRequest>,
72) -> Result<StatusCode, (StatusCode, &'static str)> {
73 let pubkey_prefix = req.pubkey.chars().take(8).collect::<String>();
74 println!("[PUT /api/location] pubkey={}..., blob_size={}", pubkey_prefix, req.blob.len());
75
76 // Decode pubkey
77 let pubkey_bytes: [u8; 32] = URL_SAFE_NO_PAD
78 .decode(&req.pubkey)
79 .map_err(|_| (StatusCode::BAD_REQUEST, "invalid pubkey encoding"))?
80 .try_into()
81 .map_err(|_| (StatusCode::BAD_REQUEST, "invalid pubkey length"))?;
82
83 let verifying_key = VerifyingKey::from_bytes(&pubkey_bytes)
84 .map_err(|_| (StatusCode::BAD_REQUEST, "invalid pubkey"))?;
85
86 // Decode blob
87 let blob = URL_SAFE_NO_PAD
88 .decode(&req.blob)
89 .map_err(|_| (StatusCode::BAD_REQUEST, "invalid blob encoding"))?;
90
91 if blob.len() > MAX_BLOB_SIZE {
92 return Err((StatusCode::PAYLOAD_TOO_LARGE, "blob exceeds 64KB"));
93 }
94
95 // Decode signature
96 let sig_bytes: [u8; 64] = URL_SAFE_NO_PAD
97 .decode(&req.signature)
98 .map_err(|_| (StatusCode::BAD_REQUEST, "invalid signature encoding"))?
99 .try_into()
100 .map_err(|_| (StatusCode::BAD_REQUEST, "invalid signature length"))?;
101
102 let signature = Signature::from_bytes(&sig_bytes);
103
104 // Verify timestamp
105 let now = SystemTime::now()
106 .duration_since(SystemTime::UNIX_EPOCH)
107 .unwrap()
108 .as_secs();
109
110 if now.saturating_sub(req.timestamp) > MAX_TIMESTAMP_AGE_SECS {
111 return Err((StatusCode::UNAUTHORIZED, "timestamp too old"));
112 }
113
114 // Verify signature over blob || timestamp
115 let mut message = blob.clone();
116 message.extend_from_slice(&req.timestamp.to_be_bytes());
117
118 verifying_key
119 .verify(&message, &signature)
120 .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid signature"))?;
121
122 // Store
123 store.insert(
124 pubkey_bytes,
125 StoredLocation {
126 blob,
127 updated: now,
128 },
129 );
130
131 println!("[PUT /api/location] stored successfully for {}...", pubkey_prefix);
132 Ok(StatusCode::NO_CONTENT)
133}
134
135async fn post_location(
136 State(store): State<LocationStore>,
137 Json(req): Json<PostRequest>,
138) -> Result<Json<PostResponse>, (StatusCode, &'static str)> {
139 println!("[POST /api/location] requesting {} ids", req.ids.len());
140 for id in &req.ids {
141 let prefix = id.chars().take(8).collect::<String>();
142 println!("[POST /api/location] - {}...", prefix);
143 }
144
145 if req.ids.len() > 50 {
146 return Err((StatusCode::BAD_REQUEST, "max 50 ids per request"));
147 }
148
149 let mut results = PostResponse::new();
150
151 for id in req.ids {
152 let pubkey_bytes: Result<[u8; 32], _> = URL_SAFE_NO_PAD
153 .decode(&id)
154 .map_err(|_| ())
155 .and_then(|b| b.try_into().map_err(|_| ()));
156
157 let id_prefix = id.chars().take(8).collect::<String>();
158 let value = match pubkey_bytes {
159 Ok(key) => {
160 let found = store.get(&key).map(|entry| entry.clone());
161 println!("[POST /api/location] {}... -> {}", id_prefix, if found.is_some() { "FOUND" } else { "NOT FOUND" });
162 found
163 },
164 Err(_) => {
165 println!("[POST /api/location] {}... -> INVALID KEY", id_prefix);
166 None
167 },
168 };
169
170 results.insert(id, value);
171 }
172
173 Ok(Json(results))
174}