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-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
|
||||
);
|
||||
@@ -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 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]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user