//! SyncKit developer apps: create/lookup a registered app, hash its API key //! (SHA-256; the raw key is never stored), and resolve an app by key or id. use sqlx::PgPool; use crate::db::models::DbSyncApp; use crate::db::{ItemId, ProjectId, SyncAppId, UserId}; use crate::error::Result; // ── Sync Apps ── /// Compute the SHA-256 hash of an API key (hex-encoded). pub fn hash_api_key(api_key: &str) -> String { use sha2::Digest; let hash = sha2::Sha256::digest(api_key.as_bytes()); hex::encode(hash) } /// Create a new sync app. Stores the hashed API key and prefix. /// /// Creates the app row AND its `sync_app_usage_current` row in one transaction. /// Every billing/blob path (`confirm_developer_blob`, key claim/release, egress) /// does `SELECT ... FOR UPDATE` on that usage row and errors if it's missing, so /// an app without one is unusable. Migration 117 seeded the row for apps that /// existed then; this keeps every app created since in the same state. #[tracing::instrument(skip_all)] pub async fn create_sync_app( pool: &PgPool, creator_id: UserId, name: &str, api_key: &str, project_id: Option, item_id: Option, ) -> Result { let key_hash = hash_api_key(api_key); let key_prefix = &api_key[..8.min(api_key.len())]; let mut tx = pool.begin().await?; let app = sqlx::query_as::<_, DbSyncApp>( r" INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING * ", ) .bind(creator_id) .bind(name) .bind(&key_hash) .bind(key_prefix) .bind(project_id) .bind(item_id) .fetch_one(&mut *tx) .await?; sqlx::query( "INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT (app_id) DO NOTHING", ) .bind(app.id) .execute(&mut *tx) .await?; tx.commit().await?; Ok(app) } /// Update the project/item link for a sync app. #[tracing::instrument(skip_all)] pub async fn update_sync_app_link( pool: &PgPool, app_id: SyncAppId, project_id: Option, item_id: Option, ) -> Result { let app = sqlx::query_as::<_, DbSyncApp>( r" UPDATE sync_apps SET project_id = $2, item_id = $3 WHERE id = $1 RETURNING * ", ) .bind(app_id) .bind(project_id) .bind(item_id) .fetch_one(pool) .await?; Ok(app) } /// Get a sync app by API key (only if active). Hashes the input before lookup. #[tracing::instrument(skip_all)] pub async fn get_sync_app_by_api_key(pool: &PgPool, api_key: &str) -> Result> { let key_hash = hash_api_key(api_key); let app = sqlx::query_as::<_, DbSyncApp>( "SELECT * FROM sync_apps WHERE api_key_hash = $1 AND is_active = true", ) .bind(&key_hash) .fetch_optional(pool) .await?; Ok(app) } #[tracing::instrument(skip_all)] pub async fn get_sync_app_by_id(pool: &PgPool, id: SyncAppId) -> Result> { let app = sqlx::query_as::<_, DbSyncApp>("SELECT * FROM sync_apps WHERE id = $1") .bind(id) .fetch_optional(pool) .await?; Ok(app) } /// List all sync apps for a creator. #[tracing::instrument(skip_all)] pub async fn get_sync_apps_by_creator(pool: &PgPool, creator_id: UserId) -> Result> { let apps = sqlx::query_as::<_, DbSyncApp>( "SELECT * FROM sync_apps WHERE creator_id = $1 ORDER BY created_at DESC LIMIT 100", ) .bind(creator_id) .fetch_all(pool) .await?; Ok(apps) } /// Get all sync apps linked to a specific project. pub async fn get_sync_apps_by_project( pool: &PgPool, project_id: ProjectId, ) -> Result> { let apps = sqlx::query_as::<_, DbSyncApp>( "SELECT * FROM sync_apps WHERE project_id = $1 ORDER BY created_at DESC LIMIT 100", ) .bind(project_id) .fetch_all(pool) .await?; Ok(apps) } /// Regenerate an API key for a sync app. Stores the hashed key and prefix. #[tracing::instrument(skip_all)] pub async fn regenerate_sync_app_key( pool: &PgPool, app_id: SyncAppId, new_api_key: &str, ) -> Result { let key_hash = hash_api_key(new_api_key); let key_prefix = &new_api_key[..8.min(new_api_key.len())]; let app = sqlx::query_as::<_, DbSyncApp>( r" UPDATE sync_apps SET api_key_hash = $2, api_key_prefix = $3 WHERE id = $1 RETURNING * ", ) .bind(app_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await?; Ok(app) } /// Get a sync app by its keys-endpoint secret (only if active). /// /// Deliberately separate from [`get_sync_app_by_api_key`]: the api_key ships /// inside every client binary, so it may not authenticate the server-to-server /// `/api/sync/keys/*` routes. An app that has never generated a secret has a /// NULL `keys_secret_hash` and matches nothing here, which is the intent -- /// those routes stay closed until the developer opts in. #[tracing::instrument(skip_all)] pub async fn get_sync_app_by_keys_secret(pool: &PgPool, secret: &str) -> Result> { let secret_hash = hash_api_key(secret); let app = sqlx::query_as::<_, DbSyncApp>( "SELECT * FROM sync_apps WHERE keys_secret_hash = $1 AND is_active = true", ) .bind(&secret_hash) .fetch_optional(pool) .await?; Ok(app) } /// Set (or rotate) the keys-endpoint secret for a sync app. /// /// Rotation is immediate and unversioned: the previous secret stops working /// the moment this returns, same as `regenerate_sync_app_key`. #[tracing::instrument(skip_all)] pub async fn set_sync_app_keys_secret( pool: &PgPool, app_id: SyncAppId, new_secret: &str, ) -> Result { let secret_hash = hash_api_key(new_secret); let secret_prefix = &new_secret[..8.min(new_secret.len())]; let app = sqlx::query_as::<_, DbSyncApp>( r" UPDATE sync_apps SET keys_secret_hash = $2, keys_secret_prefix = $3 WHERE id = $1 RETURNING * ", ) .bind(app_id) .bind(&secret_hash) .bind(secret_prefix) .fetch_one(pool) .await?; Ok(app) } /// Delete a sync app (cascades to devices and log entries). #[tracing::instrument(skip_all)] pub async fn delete_sync_app(pool: &PgPool, app_id: SyncAppId) -> Result<()> { sqlx::query("DELETE FROM sync_apps WHERE id = $1") .bind(app_id) .execute(pool) .await?; Ok(()) } #[cfg(test)] mod tests { //! API-key hashing. The raw developer key is never stored, so this function //! is the whole of that promise: if it ever became reversible, or stopped //! being deterministic, either the keys leak or every app stops //! authenticating. use super::*; #[test] fn hashing_is_deterministic() { assert_eq!( hash_api_key("synckit-key-alpha"), hash_api_key("synckit-key-alpha") ); } #[test] fn different_keys_hash_differently() { assert_ne!( hash_api_key("synckit-key-alpha"), hash_api_key("synckit-key-beta") ); } #[test] fn the_hash_is_hex_encoded_sha256() { let h = hash_api_key("synckit-key-alpha"); assert_eq!(h.len(), 64, "sha256 is 32 bytes, hex-encoded: {h}"); assert!( h.chars().all(|c| c.is_ascii_hexdigit()), "must be hex so it round-trips through a text column: {h}" ); // Pinned against a known vector so a swapped algorithm is caught rather // than merely being "some 64-char hex string". assert_eq!( hash_api_key(""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ); } #[test] fn the_raw_key_does_not_survive_in_the_hash() { // Fixtures deliberately avoid the `sk_live_` shape: gitleaks scans the // staged diff and a realistic-looking key in a test is a secret as far // as the hook is concerned, which is the correct call. let raw = "synckit-key-with-secret-inside"; assert!( !hash_api_key(raw).contains(raw), "the stored value must not carry the key it came from" ); } }