This commit is contained in:
2026-08-01 09:36:24 +08:00
parent 30e441dfc9
commit bdd11355c2
6 changed files with 285 additions and 1 deletions
+3
View File
@@ -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"
@@ -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
);
+189
View File
@@ -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<sqlx::Error> for KeypairError {
fn from(error: sqlx::Error) -> Self {
Self::Database(error)
}
}
impl From<getrandom::Error> for KeypairError {
fn from(error: getrandom::Error) -> Self {
Self::Randomness(error)
}
}
impl From<base64::DecodeError> for KeypairError {
fn from(error: base64::DecodeError) -> Self {
Self::InvalidEncoding(error)
}
}
pub async fn init(handle: &AppHandle) -> Result<(), KeypairError> {
let keypair = prepare(&handle.state::<db::AppDatabase>()).await?;
handle.manage(keypair);
Ok(())
}
fn decode_key<const LENGTH: usize>(
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<SigningKey, KeypairError> {
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<Self, KeypairError> {
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<Option<StoredKeypair>, 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<AppKeypair, KeypairError> {
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()
}
+81
View File
@@ -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));
}
+4 -1
View File
@@ -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<dyn std::error::Error>> {
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]);
+3
View File
@@ -16,6 +16,9 @@ async getFriend(id: string) : Promise<Friend | null> {
},
async deleteFriend(id: string) : Promise<boolean> {
return await TAURI_INVOKE("delete_friend", { id });
},
async getPublicKey() : Promise<string> {
return await TAURI_INVOKE("get_public_key");
}
}