From bdd11355c2ff973949ea32592dd578a10db2b350 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Sat, 1 Aug 2026 09:36:24 +0800 Subject: [PATCH] keypair --- src-tauri/Cargo.toml | 3 + .../20260801000000_create_keypair.sql | 5 + src-tauri/src/keypair/mod.rs | 189 ++++++++++++++++++ src-tauri/src/keypair/tests.rs | 81 ++++++++ src-tauri/src/lib.rs | 5 +- src/lib/bindings.ts | 3 + 6 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 src-tauri/migrations/20260801000000_create_keypair.sql create mode 100644 src-tauri/src/keypair/mod.rs create mode 100644 src-tauri/src/keypair/tests.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5130e4f..ae19894 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,9 @@ tauri-specta = { version = "2.0.0-rc.21", features = ["derive", "typescript"] } specta = "2.0.0-rc.21" specta-typescript = { version = "0.0.9" } +[dev-dependencies] +tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] } + [target.'cfg(target_os = "windows")'.dependencies] windows = "0.58" diff --git a/src-tauri/migrations/20260801000000_create_keypair.sql b/src-tauri/migrations/20260801000000_create_keypair.sql new file mode 100644 index 0000000..b76e4b9 --- /dev/null +++ b/src-tauri/migrations/20260801000000_create_keypair.sql @@ -0,0 +1,5 @@ +CREATE TABLE keypair ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + public_key TEXT NOT NULL, + secret_key TEXT NOT NULL +); diff --git a/src-tauri/src/keypair/mod.rs b/src-tauri/src/keypair/mod.rs new file mode 100644 index 0000000..868821b --- /dev/null +++ b/src-tauri/src/keypair/mod.rs @@ -0,0 +1,189 @@ +use std::fmt; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::{Signer, SigningKey}; +use sqlx::FromRow; +use tauri::{AppHandle, Manager, State}; + +use crate::db::{self, AppDatabase}; + +#[cfg(test)] +mod tests; + +const KEYPAIR_ID: i64 = 1; + +#[derive(Debug, FromRow)] +struct StoredKeypair { + public_key: String, + secret_key: String, +} + +#[derive(Debug)] +pub enum KeypairError { + Database(sqlx::Error), + Randomness(getrandom::Error), + InvalidEncoding(base64::DecodeError), + InvalidKeyLength { + key: &'static str, + expected: usize, + actual: usize, + }, + PublicKeyMismatch, + MissingAfterPrepare, +} + +impl fmt::Display for KeypairError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Database(error) => write!(formatter, "keypair database error: {error}"), + Self::Randomness(error) => write!(formatter, "could not generate a keypair: {error}"), + Self::InvalidEncoding(error) => { + write!(formatter, "stored keypair is not valid base64: {error}") + } + Self::InvalidKeyLength { + key, + expected, + actual, + } => write!( + formatter, + "stored {key} has an invalid length: expected {expected} bytes, got {actual}" + ), + Self::PublicKeyMismatch => { + write!(formatter, "stored public key does not match the secret key") + } + Self::MissingAfterPrepare => { + write!(formatter, "keypair was not present after preparation") + } + } + } +} + +impl std::error::Error for KeypairError {} + +impl From for KeypairError { + fn from(error: sqlx::Error) -> Self { + Self::Database(error) + } +} + +impl From for KeypairError { + fn from(error: getrandom::Error) -> Self { + Self::Randomness(error) + } +} + +impl From for KeypairError { + fn from(error: base64::DecodeError) -> Self { + Self::InvalidEncoding(error) + } +} + +pub async fn init(handle: &AppHandle) -> Result<(), KeypairError> { + let keypair = prepare(&handle.state::()).await?; + handle.manage(keypair); + Ok(()) +} + +fn decode_key( + encoded: &str, + key: &'static str, +) -> Result<[u8; LENGTH], KeypairError> { + let decoded = URL_SAFE_NO_PAD.decode(encoded)?; + let actual = decoded.len(); + decoded + .try_into() + .map_err(|_| KeypairError::InvalidKeyLength { + key, + expected: LENGTH, + actual, + }) +} + +impl StoredKeypair { + fn signing_key(&self) -> Result { + let secret_key = decode_key::<32>(&self.secret_key, "secret key")?; + let public_key = decode_key::<32>(&self.public_key, "public key")?; + let signing_key = SigningKey::from_bytes(&secret_key); + + if signing_key.verifying_key().to_bytes() != public_key { + return Err(KeypairError::PublicKeyMismatch); + } + + Ok(signing_key) + } +} + +#[allow(dead_code)] // The signing key will be read by the pending WebSocket sender. +pub struct AppKeypair { + signing_key: SigningKey, + public_key: String, +} + +impl AppKeypair { + fn from_stored(keypair: StoredKeypair) -> Result { + let signing_key = keypair.signing_key()?; + Ok(Self { + signing_key, + public_key: keypair.public_key, + }) + } + + pub fn public_key(&self) -> &str { + &self.public_key + } + + #[allow(dead_code)] // Exercised by tests until the WebSocket sender is connected. + pub fn sign(&self, payload: &[u8]) -> String { + let signature = self.signing_key.sign(payload); + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + } +} + +async fn stored(database: &AppDatabase) -> Result, KeypairError> { + sqlx::query_as::<_, StoredKeypair>("SELECT public_key, secret_key FROM keypair WHERE id = ?1") + .bind(KEYPAIR_ID) + .fetch_optional(database.pool()) + .await + .map_err(Into::into) +} + +/// Creates and persists the app identity when it does not exist yet. +/// +/// The returned keypair contains the stable entity ID shared with friends and +/// the decoded signing key. The secret key is intentionally stored unencrypted +/// in SQLite and loaded into memory once during app startup. +pub async fn prepare(database: &AppDatabase) -> Result { + if let Some(keypair) = stored(database).await? { + return AppKeypair::from_stored(keypair); + } + + let mut secret_key = [0_u8; 32]; + getrandom::fill(&mut secret_key)?; + let signing_key = SigningKey::from_bytes(&secret_key); + let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().as_bytes()); + let secret_key = URL_SAFE_NO_PAD.encode(secret_key); + + // Another caller may prepare the singleton concurrently. In that case the + // already-persisted identity remains authoritative. + sqlx::query( + "INSERT INTO keypair (id, public_key, secret_key) VALUES (?1, ?2, ?3) \ + ON CONFLICT(id) DO NOTHING", + ) + .bind(KEYPAIR_ID) + .bind(public_key) + .bind(secret_key) + .execute(database.pool()) + .await?; + + let keypair = stored(database) + .await? + .ok_or(KeypairError::MissingAfterPrepare)?; + AppKeypair::from_stored(keypair) +} + +#[tauri::command] +#[specta::specta] +pub fn get_public_key(keypair: State<'_, AppKeypair>) -> String { + keypair.public_key().to_owned() +} diff --git a/src-tauri/src/keypair/tests.rs b/src-tauri/src/keypair/tests.rs new file mode 100644 index 0000000..3a1f19a --- /dev/null +++ b/src-tauri/src/keypair/tests.rs @@ -0,0 +1,81 @@ +use base64::Engine; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use sqlx::sqlite::SqlitePoolOptions; + +use super::{KeypairError, URL_SAFE_NO_PAD, prepare}; +use crate::db::AppDatabase; + +async fn database() -> AppDatabase { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("connect to in-memory SQLite"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run database migrations"); + AppDatabase::new(pool) +} + +#[tokio::test] +async fn prepare_persists_one_stable_plaintext_keypair() { + let database = database().await; + + let first_keypair = prepare(&database).await.expect("prepare keypair"); + let second_keypair = prepare(&database).await.expect("load keypair"); + let (row_count, stored_public_key, stored_secret_key): (i64, String, String) = + sqlx::query_as("SELECT COUNT(*), public_key, secret_key FROM keypair") + .fetch_one(database.pool()) + .await + .expect("read stored keypair"); + + assert_eq!(first_keypair.public_key(), second_keypair.public_key()); + assert_eq!(row_count, 1); + assert_eq!(stored_public_key, first_keypair.public_key()); + assert_eq!(URL_SAFE_NO_PAD.decode(stored_public_key).unwrap().len(), 32); + assert_eq!(URL_SAFE_NO_PAD.decode(stored_secret_key).unwrap().len(), 32); +} + +#[tokio::test] +async fn sign_produces_a_signature_verifiable_by_the_entity_id() { + let database = database().await; + let payload = b"payload sent to a friend"; + + let keypair = prepare(&database).await.expect("prepare keypair"); + let signature = keypair.sign(payload); + + let public_key: [u8; 32] = URL_SAFE_NO_PAD + .decode(keypair.public_key()) + .unwrap() + .try_into() + .unwrap(); + let signature = Signature::from_slice(&URL_SAFE_NO_PAD.decode(signature).unwrap()).unwrap(); + let verifying_key = VerifyingKey::from_bytes(&public_key).unwrap(); + + assert!(verifying_key.verify(payload, &signature).is_ok()); + assert!( + verifying_key + .verify(b"a different payload", &signature) + .is_err() + ); +} + +#[tokio::test] +async fn prepare_rejects_a_public_key_that_does_not_match_the_secret() { + let database = database().await; + prepare(&database).await.expect("prepare keypair"); + let different_public_key = URL_SAFE_NO_PAD.encode([0_u8; 32]); + sqlx::query("UPDATE keypair SET public_key = ?1 WHERE id = 1") + .bind(different_public_key) + .execute(database.pool()) + .await + .expect("corrupt stored public key"); + + let error = match prepare(&database).await { + Ok(_) => panic!("accepted mismatched public and secret keys"), + Err(error) => error, + }; + + assert!(matches!(error, KeypairError::PublicKeyMismatch)); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d836b73..5c0f5f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,14 @@ mod cursor; mod db; mod friends; +mod keypair; mod ufa; mod windowing; async fn launch_app(app: &tauri::App) -> Result<(), Box> { let handle = app.handle(); db::init(handle).await?; + keypair::init(handle).await?; ufa::init(); cursor::init(handle); windowing::init(handle); @@ -21,7 +23,8 @@ pub fn run() { friends::create_friend, friends::list_friends, friends::get_friend, - friends::delete_friend + friends::delete_friend, + keypair::get_public_key, ]) .events(tauri_specta::collect_events![friends::FriendsChanged]); diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 8fc788a..44040c5 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -16,6 +16,9 @@ async getFriend(id: string) : Promise { }, async deleteFriend(id: string) : Promise { return await TAURI_INVOKE("delete_friend", { id }); +}, +async getPublicKey() : Promise { + return await TAURI_INVOKE("get_public_key"); } }