keypair
This commit is contained in:
@@ -33,6 +33,9 @@ tauri-specta = { version = "2.0.0-rc.21", features = ["derive", "typescript"] }
|
|||||||
specta = "2.0.0-rc.21"
|
specta = "2.0.0-rc.21"
|
||||||
specta-typescript = { version = "0.0.9" }
|
specta-typescript = { version = "0.0.9" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
windows = "0.58"
|
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
|
||||||
|
);
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
mod cursor;
|
mod cursor;
|
||||||
mod db;
|
mod db;
|
||||||
mod friends;
|
mod friends;
|
||||||
|
mod keypair;
|
||||||
mod ufa;
|
mod ufa;
|
||||||
mod windowing;
|
mod windowing;
|
||||||
|
|
||||||
async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let handle = app.handle();
|
let handle = app.handle();
|
||||||
db::init(handle).await?;
|
db::init(handle).await?;
|
||||||
|
keypair::init(handle).await?;
|
||||||
ufa::init();
|
ufa::init();
|
||||||
cursor::init(handle);
|
cursor::init(handle);
|
||||||
windowing::init(handle);
|
windowing::init(handle);
|
||||||
@@ -21,7 +23,8 @@ pub fn run() {
|
|||||||
friends::create_friend,
|
friends::create_friend,
|
||||||
friends::list_friends,
|
friends::list_friends,
|
||||||
friends::get_friend,
|
friends::get_friend,
|
||||||
friends::delete_friend
|
friends::delete_friend,
|
||||||
|
keypair::get_public_key,
|
||||||
])
|
])
|
||||||
.events(tauri_specta::collect_events![friends::FriendsChanged]);
|
.events(tauri_specta::collect_events![friends::FriendsChanged]);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ async getFriend(id: string) : Promise<Friend | null> {
|
|||||||
},
|
},
|
||||||
async deleteFriend(id: string) : Promise<boolean> {
|
async deleteFriend(id: string) : Promise<boolean> {
|
||||||
return await TAURI_INVOKE("delete_friend", { id });
|
return await TAURI_INVOKE("delete_friend", { id });
|
||||||
|
},
|
||||||
|
async getPublicKey() : Promise<string> {
|
||||||
|
return await TAURI_INVOKE("get_public_key");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user