Skip to main content

max / makenotwork

8.9 KB · 297 lines History Blame Raw
1 //! SyncKit developer apps: create/lookup a registered app, hash its API key
2 //! (SHA-256; the raw key is never stored), and resolve an app by key or id.
3
4 use sqlx::PgPool;
5
6 use crate::db::models::DbSyncApp;
7 use crate::db::{ItemId, ProjectId, SyncAppId, UserId};
8 use crate::error::Result;
9
10 // ── Sync Apps ──
11
12 /// Compute the SHA-256 hash of an API key (hex-encoded).
13 pub fn hash_api_key(api_key: &str) -> String {
14 use sha2::Digest;
15 let hash = sha2::Sha256::digest(api_key.as_bytes());
16 hex::encode(hash)
17 }
18
19 /// Create a new sync app. Stores the hashed API key and prefix.
20 ///
21 /// Creates the app row AND its `sync_app_usage_current` row in one transaction.
22 /// Every billing/blob path (`confirm_developer_blob`, key claim/release, egress)
23 /// does `SELECT ... FOR UPDATE` on that usage row and errors if it's missing, so
24 /// an app without one is unusable. Migration 117 seeded the row for apps that
25 /// existed then; this keeps every app created since in the same state.
26 #[tracing::instrument(skip_all)]
27 pub async fn create_sync_app(
28 pool: &PgPool,
29 creator_id: UserId,
30 name: &str,
31 api_key: &str,
32 project_id: Option<ProjectId>,
33 item_id: Option<ItemId>,
34 ) -> Result<DbSyncApp> {
35 let key_hash = hash_api_key(api_key);
36 let key_prefix = &api_key[..8.min(api_key.len())];
37
38 let mut tx = pool.begin().await?;
39 let app = sqlx::query_as::<_, DbSyncApp>(
40 r"
41 INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id)
42 VALUES ($1, $2, $3, $4, $5, $6)
43 RETURNING *
44 ",
45 )
46 .bind(creator_id)
47 .bind(name)
48 .bind(&key_hash)
49 .bind(key_prefix)
50 .bind(project_id)
51 .bind(item_id)
52 .fetch_one(&mut *tx)
53 .await?;
54
55 sqlx::query(
56 "INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT (app_id) DO NOTHING",
57 )
58 .bind(app.id)
59 .execute(&mut *tx)
60 .await?;
61
62 tx.commit().await?;
63 Ok(app)
64 }
65
66 /// Update the project/item link for a sync app.
67 #[tracing::instrument(skip_all)]
68 pub async fn update_sync_app_link(
69 pool: &PgPool,
70 app_id: SyncAppId,
71 project_id: Option<ProjectId>,
72 item_id: Option<ItemId>,
73 ) -> Result<DbSyncApp> {
74 let app = sqlx::query_as::<_, DbSyncApp>(
75 r"
76 UPDATE sync_apps SET project_id = $2, item_id = $3
77 WHERE id = $1
78 RETURNING *
79 ",
80 )
81 .bind(app_id)
82 .bind(project_id)
83 .bind(item_id)
84 .fetch_one(pool)
85 .await?;
86
87 Ok(app)
88 }
89
90 /// Get a sync app by API key (only if active). Hashes the input before lookup.
91 #[tracing::instrument(skip_all)]
92 pub async fn get_sync_app_by_api_key(pool: &PgPool, api_key: &str) -> Result<Option<DbSyncApp>> {
93 let key_hash = hash_api_key(api_key);
94 let app = sqlx::query_as::<_, DbSyncApp>(
95 "SELECT * FROM sync_apps WHERE api_key_hash = $1 AND is_active = true",
96 )
97 .bind(&key_hash)
98 .fetch_optional(pool)
99 .await?;
100
101 Ok(app)
102 }
103
104 #[tracing::instrument(skip_all)]
105 pub async fn get_sync_app_by_id(pool: &PgPool, id: SyncAppId) -> Result<Option<DbSyncApp>> {
106 let app = sqlx::query_as::<_, DbSyncApp>("SELECT * FROM sync_apps WHERE id = $1")
107 .bind(id)
108 .fetch_optional(pool)
109 .await?;
110
111 Ok(app)
112 }
113
114 /// List all sync apps for a creator.
115 #[tracing::instrument(skip_all)]
116 pub async fn get_sync_apps_by_creator(pool: &PgPool, creator_id: UserId) -> Result<Vec<DbSyncApp>> {
117 let apps = sqlx::query_as::<_, DbSyncApp>(
118 "SELECT * FROM sync_apps WHERE creator_id = $1 ORDER BY created_at DESC LIMIT 100",
119 )
120 .bind(creator_id)
121 .fetch_all(pool)
122 .await?;
123
124 Ok(apps)
125 }
126
127 /// Whether this creator owns any sync app at all.
128 ///
129 /// The gate on the Cloud Sync settings section (`47e67540`): the section is
130 /// absent for a reader who has never made an app, which is nearly every reader.
131 /// An `EXISTS` rather than a length check on
132 /// [`get_sync_apps_by_creator`], because the settings tab asks this on every
133 /// open and does not want the rows.
134 #[tracing::instrument(skip_all)]
135 pub async fn creator_has_sync_apps(pool: &PgPool, creator_id: UserId) -> Result<bool> {
136 let exists: bool =
137 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM sync_apps WHERE creator_id = $1)")
138 .bind(creator_id)
139 .fetch_one(pool)
140 .await?;
141
142 Ok(exists)
143 }
144
145 /// Get all sync apps linked to a specific project.
146 pub async fn get_sync_apps_by_project(
147 pool: &PgPool,
148 project_id: ProjectId,
149 ) -> Result<Vec<DbSyncApp>> {
150 let apps = sqlx::query_as::<_, DbSyncApp>(
151 "SELECT * FROM sync_apps WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100",
152 )
153 .bind(project_id)
154 .fetch_all(pool)
155 .await?;
156
157 Ok(apps)
158 }
159
160 /// Regenerate an API key for a sync app. Stores the hashed key and prefix.
161 #[tracing::instrument(skip_all)]
162 pub async fn regenerate_sync_app_key(
163 pool: &PgPool,
164 app_id: SyncAppId,
165 new_api_key: &str,
166 ) -> Result<DbSyncApp> {
167 let key_hash = hash_api_key(new_api_key);
168 let key_prefix = &new_api_key[..8.min(new_api_key.len())];
169 let app = sqlx::query_as::<_, DbSyncApp>(
170 r"
171 UPDATE sync_apps SET api_key_hash = $2, api_key_prefix = $3
172 WHERE id = $1
173 RETURNING *
174 ",
175 )
176 .bind(app_id)
177 .bind(&key_hash)
178 .bind(key_prefix)
179 .fetch_one(pool)
180 .await?;
181
182 Ok(app)
183 }
184
185 /// Get a sync app by its keys-endpoint secret (only if active).
186 ///
187 /// Deliberately separate from [`get_sync_app_by_api_key`]: the api_key ships
188 /// inside every client binary, so it may not authenticate the server-to-server
189 /// `/api/sync/keys/*` routes. An app that has never generated a secret has a
190 /// NULL `keys_secret_hash` and matches nothing here, which is the intent --
191 /// those routes stay closed until the developer opts in.
192 #[tracing::instrument(skip_all)]
193 pub async fn get_sync_app_by_keys_secret(pool: &PgPool, secret: &str) -> Result<Option<DbSyncApp>> {
194 let secret_hash = hash_api_key(secret);
195 let app = sqlx::query_as::<_, DbSyncApp>(
196 "SELECT * FROM sync_apps WHERE keys_secret_hash = $1 AND is_active = true",
197 )
198 .bind(&secret_hash)
199 .fetch_optional(pool)
200 .await?;
201
202 Ok(app)
203 }
204
205 /// Set (or rotate) the keys-endpoint secret for a sync app.
206 ///
207 /// Rotation is immediate and unversioned: the previous secret stops working
208 /// the moment this returns, same as `regenerate_sync_app_key`.
209 #[tracing::instrument(skip_all)]
210 pub async fn set_sync_app_keys_secret(
211 pool: &PgPool,
212 app_id: SyncAppId,
213 new_secret: &str,
214 ) -> Result<DbSyncApp> {
215 let secret_hash = hash_api_key(new_secret);
216 let secret_prefix = &new_secret[..8.min(new_secret.len())];
217 let app = sqlx::query_as::<_, DbSyncApp>(
218 r"
219 UPDATE sync_apps SET keys_secret_hash = $2, keys_secret_prefix = $3
220 WHERE id = $1
221 RETURNING *
222 ",
223 )
224 .bind(app_id)
225 .bind(&secret_hash)
226 .bind(secret_prefix)
227 .fetch_one(pool)
228 .await?;
229
230 Ok(app)
231 }
232
233 /// Delete a sync app (cascades to devices and log entries).
234 #[tracing::instrument(skip_all)]
235 pub async fn delete_sync_app(pool: &PgPool, app_id: SyncAppId) -> Result<()> {
236 sqlx::query("DELETE FROM sync_apps WHERE id = $1")
237 .bind(app_id)
238 .execute(pool)
239 .await?;
240
241 Ok(())
242 }
243
244 #[cfg(test)]
245 mod tests {
246 //! API-key hashing. The raw developer key is never stored, so this function
247 //! is the whole of that promise: if it ever became reversible, or stopped
248 //! being deterministic, either the keys leak or every app stops
249 //! authenticating.
250
251 use super::*;
252
253 #[test]
254 fn hashing_is_deterministic() {
255 assert_eq!(
256 hash_api_key("synckit-key-alpha"),
257 hash_api_key("synckit-key-alpha")
258 );
259 }
260
261 #[test]
262 fn different_keys_hash_differently() {
263 assert_ne!(
264 hash_api_key("synckit-key-alpha"),
265 hash_api_key("synckit-key-beta")
266 );
267 }
268
269 #[test]
270 fn the_hash_is_hex_encoded_sha256() {
271 let h = hash_api_key("synckit-key-alpha");
272 assert_eq!(h.len(), 64, "sha256 is 32 bytes, hex-encoded: {h}");
273 assert!(
274 h.chars().all(|c| c.is_ascii_hexdigit()),
275 "must be hex so it round-trips through a text column: {h}"
276 );
277 // Pinned against a known vector so a swapped algorithm is caught rather
278 // than merely being "some 64-char hex string".
279 assert_eq!(
280 hash_api_key(""),
281 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
282 );
283 }
284
285 #[test]
286 fn the_raw_key_does_not_survive_in_the_hash() {
287 // Fixtures deliberately avoid the `sk_live_` shape: gitleaks scans the
288 // staged diff and a realistic-looking key in a test is a secret as far
289 // as the hook is concerned, which is the correct call.
290 let raw = "synckit-key-with-secret-inside";
291 assert!(
292 !hash_api_key(raw).contains(raw),
293 "the stored value must not carry the key it came from"
294 );
295 }
296 }
297