From ec48e96a633c0754edc0e91ae7f178b1e5ecac0e Mon Sep 17 00:00:00 2001 From: Brian Donovan <1938+eventualbuddha@users.noreply.github.com> Date: Wed, 27 Mar 2024 09:57:23 -0700 Subject: [PATCH] feat(jx): add support for signing election data (#95) --- Cargo.lock | 1 + apps/cacvote-jx-terminal/backend/src/app.rs | 158 +++++++++++++++-- apps/cacvote-jx-terminal/backend/src/db.rs | 77 ++++++++- apps/cacvote-jx-terminal/backend/src/main.rs | 8 +- .../backend/src/smartcard.rs | 112 +++++++++--- apps/cacvote-jx-terminal/backend/src/sync.rs | 12 +- .../frontend/public/styles.css | 21 +++ .../frontend/src/layouts/app_layout.rs | 24 +++ .../frontend/src/pages/elections_page.rs | 161 ++++++++++++++++-- libs/auth-rs/Cargo.toml | 1 + libs/auth-rs/examples/sign_with_card.rs | 88 ++++++++++ libs/auth-rs/examples/verify_signature.rs | 1 - libs/auth-rs/src/card_command.rs | 2 +- libs/auth-rs/src/card_reader.rs | 12 ++ libs/auth-rs/src/vx_card.rs | 101 +++++------ libs/auth/src/java_card.ts | 6 - libs/types-rs/src/cacvote/mod.rs | 120 +++++++------ services/cacvote-server/bin/create-object.rs | 18 +- .../cacvote-server/bin/get-object-by-id.rs | 4 - services/cacvote-server/src/client.rs | 37 ++-- services/cacvote-server/src/db.rs | 2 +- vxsuite.code-workspace | 2 + 22 files changed, 739 insertions(+), 229 deletions(-) create mode 100644 libs/auth-rs/examples/sign_with_card.rs diff --git a/Cargo.lock b/Cargo.lock index a4028cd45..617d4e144 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,6 +234,7 @@ dependencies = [ name = "auth-rs" version = "0.1.0" dependencies = [ + "clap", "color-eyre", "openssl", "pcsc", diff --git a/apps/cacvote-jx-terminal/backend/src/app.rs b/apps/cacvote-jx-terminal/backend/src/app.rs index d48d0c971..e119bae5f 100644 --- a/apps/cacvote-jx-terminal/backend/src/app.rs +++ b/apps/cacvote-jx-terminal/backend/src/app.rs @@ -8,30 +8,33 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::time::Duration; use async_stream::try_stream; +use axum::body::Bytes; use axum::response::sse::{Event, KeepAlive}; use axum::response::Sse; +use axum::routing::post; +use axum::Json; use axum::{extract::DefaultBodyLimit, routing::get, Router}; use axum::{extract::State, http::StatusCode, response::IntoResponse}; use futures_core::Stream; +use serde_json::json; use sqlx::PgPool; use tokio::time::sleep; use tower_http::services::{ServeDir, ServeFile}; use tower_http::trace::TraceLayer; use tracing::Level; +use types_rs::cacvote::{Election, Payload, SessionData, SignedObject}; +use types_rs::election::ElectionDefinition; +use uuid::Uuid; use crate::config::{Config, MAX_REQUEST_SIZE}; -use crate::smartcard; +use crate::{db, smartcard}; // type AppState = (Config, PgPool, smartcard::StatusGetter); -type AppState = (Config, PgPool, smartcard::DynStatusGetter); +type AppState = (Config, PgPool, smartcard::DynSmartcard); /// Prepares the application with all the routes. Run the application with /// `app::run(…)` once you have it. -pub(crate) fn setup( - pool: PgPool, - config: Config, - smartcard_status: smartcard::DynStatusGetter, -) -> Router { +pub(crate) fn setup(pool: PgPool, config: Config, smartcard: smartcard::DynSmartcard) -> Router { let _entered = tracing::span!(Level::DEBUG, "Setting up application").entered(); let router = match &config.public_dir { @@ -49,9 +52,11 @@ pub(crate) fn setup( router .route("/api/status", get(get_status)) .route("/api/status-stream", get(get_status_stream)) + .route("/api/elections", get(get_elections)) + .route("/api/elections", post(create_election)) .layer(DefaultBodyLimit::max(MAX_REQUEST_SIZE)) .layer(TraceLayer::new_for_http()) - .with_state((config, pool, smartcard_status)) + .with_state((config, pool, smartcard)) } /// Runs an application built by `app::setup(…)`. @@ -69,13 +74,144 @@ async fn get_status() -> impl IntoResponse { } async fn get_status_stream( - State((_, _pool, _smartcard_status)): State, + State((_, _pool, smartcard)): State, ) -> Sse>> { Sse::new(try_stream! { + let mut last_card_details = None; + loop { - yield Event::default(); - sleep(Duration::from_secs(1)).await; + let new_card_details = smartcard.get_card_details(); + + if new_card_details != last_card_details { + last_card_details = new_card_details.clone(); + yield Event::default().json_data(SessionData { + jurisdiction_code: new_card_details.map(|d| d.card_details.jurisdiction_code()), + }).unwrap(); + } + + sleep(Duration::from_millis(100)).await; } }) .keep_alive(KeepAlive::default()) } + +async fn get_elections(State((_, pool, _)): State) -> impl IntoResponse { + let mut connection = match pool.acquire().await { + Ok(connection) => connection, + Err(e) => { + tracing::error!("error getting database connection: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error getting database connection" })), + ); + } + }; + + let elections = match db::get_elections(&mut connection).await { + Ok(elections) => elections, + Err(e) => { + tracing::error!("error getting elections from database: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error getting elections from database" })), + ); + } + }; + + (StatusCode::OK, Json(json!({ "elections": elections }))) +} + +async fn create_election( + State((_, pool, smartcard)): State, + body: Bytes, +) -> impl IntoResponse { + let jurisdiction_code = match smartcard.get_card_details() { + Some(card_details) => card_details.card_details.jurisdiction_code(), + None => { + tracing::error!("no card details found"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "no card details found" })), + ); + } + }; + + let election_definition = match ElectionDefinition::try_from(&body[..]) { + Ok(election_definition) => election_definition, + Err(e) => { + tracing::error!("error parsing election definition: {e}"); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("error parsing election definition: {e}") })), + ); + } + }; + + let mut connection = match pool.acquire().await { + Ok(connection) => connection, + Err(e) => { + tracing::error!("error getting database connection: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error getting database connection" })), + ); + } + }; + + let payload = Payload::Election(Election { + jurisdiction_code, + election_definition, + }); + let serialized_payload = match serde_json::to_vec(&payload) { + Ok(serialized_payload) => serialized_payload, + Err(e) => { + tracing::error!("error serializing payload: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error serializing payload" })), + ); + } + }; + + let signed = match smartcard.sign(&serialized_payload, None) { + Ok(signed) => signed, + Err(e) => { + tracing::error!("error signing payload: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error signing payload" })), + ); + } + }; + let certificates: Vec = match signed + .cert_stack + .iter() + .map(|cert| cert.to_pem()) + .collect::, _>>() + { + Ok(certificates) => certificates.concat(), + Err(e) => { + tracing::error!("error converting certificates to PEM: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error converting certificates to PEM" })), + ); + } + }; + let signed_object = SignedObject { + id: Uuid::new_v4(), + payload: serialized_payload, + certificates, + signature: signed.data, + }; + + if let Err(e) = db::add_object(&mut connection, &signed_object).await { + tracing::error!("error adding object to database: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "error adding object to database" })), + ); + } + + (StatusCode::CREATED, Json(json!({ "id": signed_object.id }))) +} diff --git a/apps/cacvote-jx-terminal/backend/src/db.rs b/apps/cacvote-jx-terminal/backend/src/db.rs index 8275b452d..5b8bee51d 100644 --- a/apps/cacvote-jx-terminal/backend/src/db.rs +++ b/apps/cacvote-jx-terminal/backend/src/db.rs @@ -35,6 +35,46 @@ pub(crate) async fn setup(config: &Config) -> color_eyre::Result { Ok(pool) } +pub(crate) async fn get_elections( + connection: &mut sqlx::PgConnection, +) -> color_eyre::eyre::Result> { + let objects = sqlx::query_as!( + SignedObject, + r#" + SELECT + id, + payload, + certificates, + signature + FROM objects + WHERE object_type = 'Election' + "#, + ) + .fetch_all(connection) + .await?; + + let mut elections = Vec::new(); + + for object in objects { + let payload = match object.try_to_inner() { + Ok(payload) => { + tracing::debug!("got object payload: {payload:?}"); + payload + } + Err(err) => { + tracing::error!("unable to parse object payload: {err:?}"); + continue; + } + }; + + if let types_rs::cacvote::Payload::Election(election) = payload { + elections.push(election); + } + } + + Ok(elections) +} + #[tracing::instrument(skip(connection, object))] pub async fn add_object_from_server( connection: &mut sqlx::PgConnection, @@ -48,7 +88,7 @@ pub async fn add_object_from_server( bail!("No jurisdiction found"); }; - let object_type = object.try_to_inner()?.object_type; + let object_type = object.try_to_inner()?.object_type(); sqlx::query!( r#" @@ -70,6 +110,41 @@ pub async fn add_object_from_server( Ok(object.id) } +#[tracing::instrument(skip(connection, object))] +pub async fn add_object( + connection: &mut sqlx::PgConnection, + object: &SignedObject, +) -> color_eyre::Result { + if !object.verify()? { + bail!("Unable to verify signature/certificates") + } + + let Some(jurisdiction_code) = object.jurisdiction_code() else { + bail!("No jurisdiction found"); + }; + + let object_type = object.try_to_inner()?.object_type(); + + sqlx::query!( + r#" + INSERT INTO objects (id, jurisdiction, object_type, payload, certificates, signature) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + &object.id, + jurisdiction_code.as_str(), + object_type, + &object.payload, + &object.certificates, + &object.signature + ) + .execute(connection) + .await?; + + tracing::info!("Created object with id {}", object.id); + + Ok(object.id) +} + #[tracing::instrument(skip(connection, entries))] pub(crate) async fn add_journal_entries( connection: &mut sqlx::PgConnection, diff --git a/apps/cacvote-jx-terminal/backend/src/main.rs b/apps/cacvote-jx-terminal/backend/src/main.rs index 4753143ef..4a84a8c75 100644 --- a/apps/cacvote-jx-terminal/backend/src/main.rs +++ b/apps/cacvote-jx-terminal/backend/src/main.rs @@ -54,7 +54,7 @@ mod log; mod smartcard; mod sync; -use crate::smartcard::StatusGetter; +use crate::smartcard::Smartcard; #[tokio::main] async fn main() -> color_eyre::Result<()> { @@ -65,7 +65,7 @@ async fn main() -> color_eyre::Result<()> { let pool = db::setup(&config).await?; sync::sync_periodically(&pool, config.clone()).await; let smartcard_watcher = Watcher::watch(); - let smartcard_status = StatusGetter::new(smartcard_watcher.readers_with_cards()); - let smartcard_status = Arc::new(smartcard_status) as smartcard::DynStatusGetter; - app::run(app::setup(pool, config.clone(), smartcard_status), &config).await + let smartcard = Smartcard::new(smartcard_watcher.readers_with_cards()); + let smartcard = Arc::new(smartcard) as smartcard::DynSmartcard; + app::run(app::setup(pool, config.clone(), smartcard), &config).await } diff --git a/apps/cacvote-jx-terminal/backend/src/smartcard.rs b/apps/cacvote-jx-terminal/backend/src/smartcard.rs index 56f2e6e44..df5d3bf5a 100644 --- a/apps/cacvote-jx-terminal/backend/src/smartcard.rs +++ b/apps/cacvote-jx-terminal/backend/src/smartcard.rs @@ -1,48 +1,77 @@ use std::fmt::Debug; -use std::ops::Deref; use std::sync::{Arc, Mutex}; use auth_rs::card_details::CardDetailsWithAuthInfo; -use auth_rs::vx_card::VxCard; +use auth_rs::vx_card::{VxCard, CARD_VX_ADMIN_CERT}; use auth_rs::{CardReader, SharedCardReaders}; +use openssl::x509::X509; use types_rs::cacvote::SmartcardStatus; -pub(crate) type DynStatusGetter = Arc; +pub(crate) type DynSmartcard = Arc; #[cfg_attr(test, mockall::automock)] -pub(crate) trait StatusGetterTrait { - fn get(&self) -> SmartcardStatus; - fn get_card_details(&mut self) -> Option; +pub(crate) trait SmartcardTrait { + fn get_status(&self) -> SmartcardStatus; + fn get_card_details(&self) -> Option; + + #[allow(clippy::needless_lifetimes)] // automock needs the lifetimes + fn sign<'a, 'b, 'c>(&'a self, data: &'b [u8], pin: Option<&'c str>) -> Result; +} + +#[derive(Debug)] +pub(crate) struct Signed { + pub(crate) data: Vec, + pub(crate) cert_stack: Vec, +} + +/// Provides access to the current smartcard. +#[derive(Clone)] +pub(crate) struct Smartcard(Arc>); + +impl Smartcard { + pub(crate) fn new(readers_with_cards: SharedCardReaders) -> Self { + Self(Arc::new(Mutex::new(SmartcardInner::new( + readers_with_cards, + )))) + } +} + +impl Debug for Smartcard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0.lock() { + Ok(inner) => inner.fmt(f), + Err(e) => write!(f, "Smartcard {{ error: {e} }}"), + } + } } -/// Provides access to the current smartcard status. #[derive(Clone)] -pub(crate) struct StatusGetter { +struct SmartcardInner { #[allow(dead_code)] ctx: pcsc::Context, #[allow(dead_code)] readers_with_cards: SharedCardReaders, #[allow(dead_code)] - last_selected_card_reader_info: Arc>>, + last_selected_card_reader_info: Option<(String, CardDetailsWithAuthInfo)>, card: Option>, } -impl Debug for StatusGetter { +impl Debug for SmartcardInner { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("StatusGetter") + f.debug_struct("Smartcard") .field("readers_with_cards", &self.readers_with_cards) .finish() } } -impl StatusGetter { +impl SmartcardInner { #[allow(dead_code)] #[must_use] pub(crate) fn new(readers_with_cards: SharedCardReaders) -> Self { Self { ctx: pcsc::Context::establish(pcsc::Scope::User).unwrap(), readers_with_cards, - last_selected_card_reader_info: Arc::new(Mutex::new(None)), + last_selected_card_reader_info: None, card: None, } } @@ -53,8 +82,8 @@ impl StatusGetter { return; }; - let mut cached_card_details = self.last_selected_card_reader_info.lock().unwrap(); - if let Some((ref name, _)) = cached_card_details.deref() { + let cached_card_details = &self.last_selected_card_reader_info; + if let Some((ref name, _)) = cached_card_details { let same_reader_has_card = readers.iter().any(|reader| reader == name); if same_reader_has_card { @@ -72,10 +101,10 @@ impl StatusGetter { } } - if let Some(card) = self.card.as_ref() { + if let Some(ref card) = self.card { match card.read_card_details() { Ok(card_details_with_auth_info) => { - *cached_card_details = + self.last_selected_card_reader_info = Some((reader_name.to_string(), card_details_with_auth_info)); } Err(e) => { @@ -86,12 +115,20 @@ impl StatusGetter { } } -impl StatusGetterTrait for StatusGetter { +impl SmartcardTrait for Smartcard { /// Gets the current smartcard status. #[allow(dead_code)] #[must_use] - fn get(&self) -> SmartcardStatus { - let readers = self.readers_with_cards.lock().unwrap(); + fn get_status(&self) -> SmartcardStatus { + let inner = match self.0.lock() { + Ok(inner) => inner, + Err(e) => { + tracing::error!("error getting smartcard lock: {e}"); + return SmartcardStatus::NoCard; + } + }; + + let readers = inner.readers_with_cards.lock().unwrap(); if readers.is_empty() { SmartcardStatus::NoCard @@ -102,12 +139,37 @@ impl StatusGetterTrait for StatusGetter { #[allow(dead_code)] #[must_use] - fn get_card_details(&mut self) -> Option { - self.refresh_card_details(); - let Ok(cached_card_details) = self.last_selected_card_reader_info.lock() else { - return None; + fn get_card_details(&self) -> Option { + let mut inner = match self.0.lock() { + Ok(inner) => inner, + Err(e) => { + tracing::error!("error getting smartcard lock: {e}"); + return None; + } + }; + inner.refresh_card_details(); + inner + .last_selected_card_reader_info + .clone() + .map(|(_, details)| details) + } + + #[allow(dead_code)] + fn sign(&self, data: &[u8], pin: Option<&str>) -> Result { + let inner = match self.0.lock() { + Ok(inner) => inner, + Err(e) => { + return Err(format!("error getting smartcard lock: {e}")); + } }; + let card = inner.card.as_ref().ok_or("no card")?; + let (data, public_key) = card + .sign(CARD_VX_ADMIN_CERT, data, pin) + .map_err(|e| format!("error signing: {e}"))?; - cached_card_details.clone().map(|(_, details)| details) + Ok(Signed { + data, + cert_stack: vec![public_key], + }) } } diff --git a/apps/cacvote-jx-terminal/backend/src/sync.rs b/apps/cacvote-jx-terminal/backend/src/sync.rs index 8aecbef06..0f946eef5 100644 --- a/apps/cacvote-jx-terminal/backend/src/sync.rs +++ b/apps/cacvote-jx-terminal/backend/src/sync.rs @@ -110,19 +110,19 @@ mod tests { use crate::{ app, - smartcard::{DynStatusGetter, MockStatusGetterTrait}, + smartcard::{DynSmartcard, MockSmartcardTrait}, }; use super::*; - fn setup(pool: sqlx::PgPool, smartcard_status: DynStatusGetter) -> color_eyre::Result { + fn setup(pool: sqlx::PgPool, smartcard_status: DynSmartcard) -> color_eyre::Result { let listener = TcpListener::bind("0.0.0.0:0")?; let addr = listener.local_addr()?; let cacvote_url: Url = format!("http://{addr}").parse()?; let config = Config { cacvote_url: cacvote_url.clone(), - database_url: "".to_string(), - machine_id: "".to_string(), + database_url: "".to_owned(), + machine_id: "".to_owned(), port: addr.port(), public_dir: None, log_level: Level::DEBUG, @@ -144,9 +144,9 @@ mod tests { async fn test_sync(pool: sqlx::PgPool) -> color_eyre::Result<()> { let mut connection = pool.acquire().await?; - let mut smartcard_status = MockStatusGetterTrait::new(); + let mut smartcard_status = MockSmartcardTrait::new(); smartcard_status - .expect_get() + .expect_get_status() .returning(|| SmartcardStatus::Card); let client = setup(pool, Arc::new(smartcard_status))?; diff --git a/apps/cacvote-jx-terminal/frontend/public/styles.css b/apps/cacvote-jx-terminal/frontend/public/styles.css index e9c217ad3..edc32f0f8 100644 --- a/apps/cacvote-jx-terminal/frontend/public/styles.css +++ b/apps/cacvote-jx-terminal/frontend/public/styles.css @@ -566,6 +566,10 @@ video { display: flex; } +.table { + display: table; +} + .hidden { display: none; } @@ -586,6 +590,14 @@ video { width: 100vw; } +.w-full { + width: 100%; +} + +.table-auto { + table-layout: auto; +} + .flex-col { flex-direction: column; } @@ -660,6 +672,10 @@ video { padding-bottom: 0.5rem; } +.text-left { + text-align: left; +} + .text-center { text-align: center; } @@ -684,6 +700,11 @@ video { line-height: 1.75rem; } +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + .font-bold { font-weight: 700; } diff --git a/apps/cacvote-jx-terminal/frontend/src/layouts/app_layout.rs b/apps/cacvote-jx-terminal/frontend/src/layouts/app_layout.rs index 9f5d31ff0..fe0176d6d 100644 --- a/apps/cacvote-jx-terminal/frontend/src/layouts/app_layout.rs +++ b/apps/cacvote-jx-terminal/frontend/src/layouts/app_layout.rs @@ -2,19 +2,43 @@ use dioxus::prelude::*; use dioxus_router::prelude::*; +use types_rs::cacvote::SessionData; use wasm_bindgen::prelude::*; use web_sys::MessageEvent; use crate::route::Route; pub fn AppLayout(cx: Scope) -> Element { + use_shared_state_provider(cx, SessionData::default); + let session_data = use_shared_state::(cx).unwrap(); + let nav = use_navigator(cx); + use_coroutine(cx, { + to_owned![nav, session_data]; |_rx: UnboundedReceiver| async move { let eventsource = web_sys::EventSource::new("/api/status-stream").unwrap(); let callback = Closure::wrap(Box::new(move |event: MessageEvent| { if let Some(data) = event.data().as_string() { log::info!("received status event: {:?}", data); + match serde_json::from_str::(&data) { + Ok(new_session_data) => { + log::info!("updating session data: {:?}", new_session_data); + + if new_session_data.jurisdiction_code.is_none() { + log::info!("redirecting to machine locked page"); + nav.push(Route::MachineLockedPage); + } else { + log::info!("redirecting to elections page"); + nav.push(Route::ElectionsPage); + } + + *session_data.write() = new_session_data; + } + Err(err) => { + log::error!("failed to parse session data: {:?}", err); + } + } } }) as Box); diff --git a/apps/cacvote-jx-terminal/frontend/src/pages/elections_page.rs b/apps/cacvote-jx-terminal/frontend/src/pages/elections_page.rs index 7df5c6162..a2733701e 100644 --- a/apps/cacvote-jx-terminal/frontend/src/pages/elections_page.rs +++ b/apps/cacvote-jx-terminal/frontend/src/pages/elections_page.rs @@ -3,29 +3,158 @@ use std::sync::Arc; use dioxus::prelude::*; +use dioxus_router::hooks::use_navigator; +use serde::Deserialize; +use types_rs::cacvote::{Election, SessionData}; use ui_rs::FileButton; -use crate::util::file::read_file_as_bytes; +use crate::{ + route::Route, + util::{file::read_file_as_bytes, url::get_url}, +}; pub fn ElectionsPage(cx: Scope) -> Element { + let nav = use_navigator(cx); + let session_data = use_shared_state::(cx).unwrap(); + let session_data = &*session_data.read(); + + let elections = use_state(cx, Vec::new); + + use_effect(cx, (), |()| { + to_owned![elections]; + async move { + #[derive(Deserialize)] + struct GetElectionsResponse { + elections: Vec, + } + + let url = get_url("/api/elections"); + let client = reqwest::Client::new(); + let res = client.get(url).send().await; + + match res { + Ok(res) => { + if res.status().is_success() { + match res.json::().await { + Ok(response) => { + elections.set(response.elections); + } + Err(err) => { + log::error!("failed to parse elections: {err}"); + } + } + log::info!("elections: {elections:?}"); + } else { + log::error!( + "failed to get elections: {:?}", + res.text().await.unwrap_or("unknown error".to_owned()) + ); + } + } + Err(err) => { + log::error!("failed to get elections: {err}"); + } + } + } + }); + + let is_uploading = use_state(cx, || false); + let upload_election = { + to_owned![is_uploading]; + |election_data: Vec| async move { + is_uploading.set(true); + + log::info!( + "election data: {}", + String::from_utf8(election_data.clone()).unwrap() + ); + + let url = get_url("/api/elections"); + let client = reqwest::Client::new(); + let res = client.post(url).body(election_data).send().await; + + is_uploading.set(false); + + Some(res) + } + }; + + use_effect(cx, (session_data,), |(session_data,)| { + to_owned![nav, session_data]; + async move { + if session_data.jurisdiction_code.is_none() { + nav.push(Route::MachineLockedPage); + } + } + }); + render! ( div { - h1 { class: "text-2xl font-bold mb-4", "Elections" } + h1 { class: "text-2xl font-bold mb-4", "Elections" } + if elections.is_empty() { rsx!(div { "No elections found." }) - FileButton { - "Import Election", - class: "mt-4", - onfile: move |file_engine: Arc| { - cx.spawn({ - to_owned![file_engine]; - async move { - if let Some(election_data) = read_file_as_bytes(file_engine).await { - log::info!("uploading election: {election_data:?}"); - }; - } - }); - }, - } + } else { + rsx!( + table { + class: "table-auto w-full", + thead { + tr { + th { class: "px-4 py-2 text-left", "Title" } + } + } + for election in elections.iter() { + tr { + td { class: "border px-4 py-2 text-sm", "{election.election_definition.election.title}" } + } + } + } + ) + } + FileButton { + "Import Election", + class: "mt-4", + onfile: move |file_engine: Arc| { + cx.spawn({ + to_owned![upload_election, file_engine]; + async move { + if let Some(election_data) = read_file_as_bytes(file_engine).await { + match upload_election(election_data).await { + Some(Ok(response)) => { + if !response.status().is_success() { + web_sys::window() + .unwrap() + .alert_with_message( + &format!( + "Failed to upload election: {:?}", + response + .text() + .await + .unwrap_or("unknown error".to_owned()) + ), + ) + .unwrap(); + return; + } + + log::info!("Election uploaded successfully"); + } + Some(Err(err)) => { + log::error!("Failed to upload election: {err}"); + + web_sys::window() + .unwrap() + .alert_with_message(&format!("Failed to upload election: {err}")) + .unwrap(); + } + None => { + log::error!("Invalid election data"); + } + } + }; + } + }); + }, } + } ) } diff --git a/libs/auth-rs/Cargo.toml b/libs/auth-rs/Cargo.toml index e22701ff7..f53e06cbd 100644 --- a/libs/auth-rs/Cargo.toml +++ b/libs/auth-rs/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +clap = { workspace = true, features = ["derive"] } color-eyre = { workspace = true } openssl = { workspace = true } pcsc = { workspace = true } diff --git a/libs/auth-rs/examples/sign_with_card.rs b/libs/auth-rs/examples/sign_with_card.rs new file mode 100644 index 000000000..797c6c68d --- /dev/null +++ b/libs/auth-rs/examples/sign_with_card.rs @@ -0,0 +1,88 @@ +use std::{io::Write, path::PathBuf}; + +use clap::Parser; +use tracing::Level; +use tracing_subscriber::{prelude::*, util::SubscriberInitExt}; + +use auth_rs::{vx_card::CARD_VX_ADMIN_CERT, CardReader, Event, Watcher}; + +#[derive(clap::Parser)] +struct SignWithCardArgs { + #[clap(long)] + no_pin: bool, + + path: PathBuf, +} + +fn main() -> color_eyre::Result<()> { + color_eyre::install()?; + + let args = SignWithCardArgs::parse(); + + let stdout_log = tracing_subscriber::fmt::layer().pretty(); + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::builder() + .with_default_directive( + format!( + "{}={}", + env!("CARGO_PKG_NAME").replace('-', "_"), + Level::INFO + ) + .parse()?, + ) + .from_env_lossy(), + ) + .with(stdout_log) + .init(); + + let ctx = pcsc::Context::establish(pcsc::Scope::User).unwrap(); + let mut watcher = Watcher::watch(); + let mut card_reader: Option = None; + + println!("Insert a card to sign…"); + + for event in watcher.events() { + match event { + Ok(Event::CardInserted { reader_name }) => { + card_reader = Some(CardReader::new(ctx.clone(), reader_name)); + break; + } + Err(error) => { + eprintln!("Error: {}", error); + break; + } + _ => {} + } + } + + if let Some(card_reader) = card_reader { + watcher.stop(); + let card = card_reader.get_card()?; + let pin = if args.no_pin { + None + } else { + print!("Enter the PIN to sign the data: "); + std::io::stdout().flush()?; + let mut pin = String::new(); + std::io::stdin().read_line(&mut pin).unwrap(); + let pin = pin.trim(); + Some(pin.to_owned()) + }; + + match card.sign( + CARD_VX_ADMIN_CERT, + &std::fs::read(args.path)?, + pin.as_deref(), + ) { + Ok((signature, _)) => { + println!("{signature:02x?}"); + } + Err(error) => { + eprintln!("Error: {error}"); + } + } + } + + Ok(()) +} diff --git a/libs/auth-rs/examples/verify_signature.rs b/libs/auth-rs/examples/verify_signature.rs index 6db4e0ca4..681a74114 100644 --- a/libs/auth-rs/examples/verify_signature.rs +++ b/libs/auth-rs/examples/verify_signature.rs @@ -61,7 +61,6 @@ fn main() { match verifier.verify_oneshot(&signature_content, &payload_content) { Err(_) | Ok(false) => { println!("Signature verification failed"); - return; } Ok(true) => { println!("Signature verified successfully"); diff --git a/libs/auth-rs/src/card_command.rs b/libs/auth-rs/src/card_command.rs index 2e4770bf8..915538476 100644 --- a/libs/auth-rs/src/card_command.rs +++ b/libs/auth-rs/src/card_command.rs @@ -125,7 +125,7 @@ impl CardCommand { let pin_bytes = pin.as_bytes(); assert!(pin_bytes.len() <= data.len(), "PIN too long"); data[..pin_bytes.len()].copy_from_slice(pin_bytes); - tracing::debug!("PIN bytes: {:?}", pin_bytes); + tracing::debug!("PIN bytes: {pin_bytes:?}"); Self::new(VERIFY_PIN_INS, VERIFY_PIN_P1, VERIFY_PIN_P2, data.to_vec()) } diff --git a/libs/auth-rs/src/card_reader.rs b/libs/auth-rs/src/card_reader.rs index 2cab0b7b8..292471a87 100644 --- a/libs/auth-rs/src/card_reader.rs +++ b/libs/auth-rs/src/card_reader.rs @@ -1,4 +1,5 @@ use std::{ + fmt, sync::{mpsc, Arc, Mutex}, thread::JoinHandle, time::Duration, @@ -34,6 +35,17 @@ impl CertObject { } } +impl fmt::Debug for CertObject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CertObject") + .field( + "private_key_id", + &format_args!("{:#02x}", self.private_key_id), + ) + .finish() + } +} + pub type SharedCardReaders = Arc>>; #[derive(Debug)] diff --git a/libs/auth-rs/src/vx_card.rs b/libs/auth-rs/src/vx_card.rs index 479395ae7..b701f83c7 100644 --- a/libs/auth-rs/src/vx_card.rs +++ b/libs/auth-rs/src/vx_card.rs @@ -186,96 +186,78 @@ impl VxCard { Ok(()) } - #[tracing::instrument(level = "debug", skip(self, cert_object, data, pin))] + fn check_pin_internal(&self, pin: &str) -> Result<(), CardReaderError> { + let command = CardCommand::verify_pin(pin); + self.transmit(command)?; + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self, data, pin))] pub fn sign( &self, - cert_object: CertObject, + signing_cert: CertObject, data: &[u8], pin: Option<&str>, ) -> Result<(Vec, X509), CardReaderError> { self.select_applet()?; - let cert = self.retrieve_cert(cert_object.object_id())?; + let cert = self.retrieve_cert(signing_cert.object_id())?; let public_key = cert.public_key()?; Ok(( - self.sign_with_keys(cert_object, &public_key, data, pin)?, + self.sign_with_keys(signing_cert, &public_key, data, pin)?, cert, )) } - #[tracing::instrument(level = "debug", skip(self, private_key_cert, data, pin))] + #[tracing::instrument(level = "debug", skip(self, public_key, pin))] + fn verify_card_private_key( + &self, + signing_cert: CertObject, + public_key: &openssl::pkey::PKey, + pin: Option<&str>, + ) -> Result<(), CardReaderError> { + // have the private key sign a "challenge" + let challenge_string = format!( + "VotingWorks/{seconds_since_epoch}/{uuid}", + seconds_since_epoch = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + uuid = Uuid::new_v4() + ); + let challenge = challenge_string.as_bytes(); + self.sign_with_keys(signing_cert, public_key, challenge, pin)?; + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self, public_key, data, pin))] fn sign_with_keys( &self, - private_key_cert: CertObject, + signing_cert: CertObject, public_key: &openssl::pkey::PKey, data: &[u8], pin: Option<&str>, ) -> Result, CardReaderError> { - if let Some(pin) = pin { - self.check_pin_internal(pin)?; - } + self.check_pin_internal(pin.unwrap_or(DEFAULT_PIN))?; let data_hash = openssl::sha::sha256(data); let command = - CardCommand::verify_card_private_key(private_key_cert.private_key_id, &data_hash)?; + CardCommand::verify_card_private_key(signing_cert.private_key_id, &data_hash)?; let response = self.transmit(command)?; - let (remainder, response_tlv) = - Tlv::parse_partial(GENERAL_AUTHENTICATE_DYNAMIC_TEMPLATE_TAG, &response)?; - - if !remainder.is_empty() { - tracing::error!( - "unexpected remainder after parsing GENERAL_AUTHENTICATE_DYNAMIC_TEMPLATE_TAG: {remainder:02x?}", - ); - return Err(CardReaderError::Pcsc(pcsc::Error::InvalidValue)); - } - - let (remainder, general_authenticate_tlv) = - Tlv::parse_partial(GENERAL_AUTHENTICATE_RESPONSE_TAG, response_tlv.value())?; - if !remainder.is_empty() { - tracing::error!( - "unexpected remainder after parsing GENERAL_AUTHENTICATE_RESPONSE_TAG: {remainder:02x?}", - ); - return Err(CardReaderError::Pcsc(pcsc::Error::InvalidValue)); - } - - let signature = general_authenticate_tlv.into_bytes()?; + let response_tlv = Tlv::parse(GENERAL_AUTHENTICATE_DYNAMIC_TEMPLATE_TAG, &response)?; + let general_authenticate_tlv = + Tlv::parse(GENERAL_AUTHENTICATE_RESPONSE_TAG, response_tlv.value())?; + let signature = general_authenticate_tlv.value(); let mut verifier = openssl::sign::Verifier::new(openssl::hash::MessageDigest::sha256(), public_key)?; verifier.update(data)?; - if !verifier.verify(&signature)? { + if !verifier.verify(signature)? { tracing::error!("signature did not verify"); return Err(CardReaderError::Pcsc(pcsc::Error::InvalidValue)); } - Ok(signature) - } - - fn check_pin_internal(&self, pin: &str) -> Result<(), CardReaderError> { - let command = CardCommand::verify_pin(pin); - self.transmit(command)?; - Ok(()) - } - - #[tracing::instrument(level = "debug", skip(self, private_key_cert, public_key, pin))] - pub fn verify_card_private_key( - &self, - private_key_cert: CertObject, - public_key: &openssl::pkey::PKey, - pin: Option<&str>, - ) -> Result<(), CardReaderError> { - // have the private key sign a "challenge" - let challenge_string = format!( - "VotingWorks/{seconds_since_epoch}/{uuid}", - seconds_since_epoch = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - uuid = Uuid::new_v4() - ); - let challenge = challenge_string.as_bytes(); - self.sign_with_keys(private_key_cert, public_key, challenge, pin)?; - Ok(()) + Ok(signature.to_vec()) } #[tracing::instrument(level = "debug", skip(self))] @@ -344,6 +326,7 @@ impl VxCard { Ok(result) } + #[tracing::instrument(level = "debug", skip(self, cert_object_id))] fn retrieve_cert(&self, cert_object_id: impl Into>) -> Result { let cert_object_id = cert_object_id.into(); tracing::debug!("retrieving cert with object ID: {cert_object_id:02x?}"); diff --git a/libs/auth/src/java_card.ts b/libs/auth/src/java_card.ts index 432b62241..8cf9c9771 100644 --- a/libs/auth/src/java_card.ts +++ b/libs/auth/src/java_card.ts @@ -414,7 +414,6 @@ export class JavaCard implements Card { // Verify that the card VotingWorks cert was signed by VotingWorks const cardVxCert = await this.retrieveCert(CARD_VX_CERT.OBJECT_ID); - console.log('cardVxCert', cardVxCert.toString('utf8')); await verifyFirstCertWasSignedBySecondCert( cardVxCert, this.vxCertAuthorityCertPath @@ -427,11 +426,6 @@ export class JavaCard implements Card { const vxAdminCertAuthorityCert = await this.retrieveCert( VX_ADMIN_CERT_AUTHORITY_CERT.OBJECT_ID ); - console.log('cardVxAdminCert', cardVxAdminCert.toString('utf8')); - console.log( - 'vxAdminCertAuthorityCert', - vxAdminCertAuthorityCert.toString('utf8') - ); await verifyFirstCertWasSignedBySecondCert( cardVxAdminCert, vxAdminCertAuthorityCert diff --git a/libs/types-rs/src/cacvote/mod.rs b/libs/types-rs/src/cacvote/mod.rs index 55ebd12cf..6b1510a05 100644 --- a/libs/types-rs/src/cacvote/mod.rs +++ b/libs/types-rs/src/cacvote/mod.rs @@ -1,11 +1,12 @@ +use std::fmt; + use base64_serde::base64_serde_type; -use serde::de::DeserializeOwned; -use serde::de::Error; use serde::Deserialize; use serde::Serialize; use uuid::Uuid; use crate::election::BallotStyleId; +use crate::election::ElectionDefinition; use crate::election::PrecinctId; base64_serde_type!(Base64Standard, base64::engine::general_purpose::STANDARD); @@ -40,6 +41,12 @@ impl TryFrom<&str> for JurisdictionCode { } } +impl fmt::Display for JurisdictionCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + #[cfg(feature = "sqlx")] impl<'r> sqlx::Decode<'r, sqlx::Postgres> for JurisdictionCode where @@ -127,27 +134,28 @@ impl SignedObject { Some(x509) => x509.public_key()?, None => return Ok(false), }; - let digest = openssl::hash::MessageDigest::sha256(); - let mut verifier = openssl::sign::Verifier::new(digest, &public_key)?; + + let mut verifier = + openssl::sign::Verifier::new(openssl::hash::MessageDigest::sha256(), &public_key)?; verifier.update(&self.payload)?; verifier.verify(&self.signature) } #[must_use] pub fn jurisdiction_code(&self) -> Option { - match self.try_to_inner() { - Ok(payload) => payload.jurisdiction_code(), - Err(_) => { - #[cfg(feature = "openssl")] - { - self.jurisdiction_code_from_certificates() - } - - #[cfg(not(feature = "openssl"))] - { - None - } - } + let jurisdiction_code = self + .try_to_inner() + .ok() + .map(|payload| payload.jurisdiction_code()); + + #[cfg(feature = "openssl")] + { + jurisdiction_code.or_else(|| self.jurisdiction_code_from_certificates()) + } + + #[cfg(not(feature = "openssl"))] + { + jurisdiction_code } } @@ -173,60 +181,29 @@ impl SignedObject { } #[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Payload { - pub object_type: String, - #[serde(with = "Base64Standard")] - pub data: Vec, +#[serde(rename_all = "camelCase", tag = "objectType")] +pub enum Payload { + RegistrationRequest(RegistrationRequest), + Registration(Registration), + Election(Election), } impl Payload { - pub fn new(object_type: &str, data: &T) -> color_eyre::Result { - Ok(Self { - object_type: object_type.to_owned(), - data: serde_json::to_vec(data)?, - }) - } - - pub fn try_to_inner(&self) -> Result { - serde_json::from_slice(&self.data) - } - - pub fn try_to_inner_typed(&self) -> Result { - match self.object_type.as_str() { - "RegistrationRequest" => { - Ok(PayloadData::RegistrationRequest(serde_json::from_slice::< - RegistrationRequest, - >( - &self.data - )?)) - } - _ => Err(serde_json::Error::custom(format!( - "Unknown object type: {}", - self.object_type - ))), - } - } - - pub fn jurisdiction_code(&self) -> Option { - match self.try_to_inner_typed() { - Ok(data) => Some(data.jurisdiction_code()), - Err(_) => None, + pub fn object_type(&self) -> &'static str { + match self { + Self::RegistrationRequest(_) => "RegistrationRequest", + Self::Registration(_) => "Registration", + Self::Election(_) => "Election", } } } -#[derive(Debug, Deserialize, Serialize)] -pub enum PayloadData { - RegistrationRequest(RegistrationRequest), - Registration(Registration), -} - -impl JurisdictionScoped for PayloadData { +impl JurisdictionScoped for Payload { fn jurisdiction_code(&self) -> JurisdictionCode { match self { - PayloadData::RegistrationRequest(request) => request.jurisdiction_code(), - PayloadData::Registration(registration) => registration.jurisdiction_code(), + Self::RegistrationRequest(request) => request.jurisdiction_code(), + Self::Registration(registration) => registration.jurisdiction_code(), + Self::Election(election) => election.jurisdiction_code(), } } } @@ -388,3 +365,22 @@ impl JurisdictionScoped for Registration { self.jurisdiction_code.clone() } } + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Election { + pub jurisdiction_code: JurisdictionCode, + pub election_definition: ElectionDefinition, +} + +impl JurisdictionScoped for Election { + fn jurisdiction_code(&self) -> JurisdictionCode { + self.jurisdiction_code.clone() + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SessionData { + pub jurisdiction_code: Option, +} diff --git a/services/cacvote-server/bin/create-object.rs b/services/cacvote-server/bin/create-object.rs index 0d4a8d6e9..99005fe81 100644 --- a/services/cacvote-server/bin/create-object.rs +++ b/services/cacvote-server/bin/create-object.rs @@ -6,7 +6,7 @@ use openssl::{ x509::X509, }; use serde::{Deserialize, Serialize}; -use types_rs::cacvote::{Payload, SignedObject}; +use types_rs::cacvote::{JurisdictionCode, Payload, RegistrationRequest, SignedObject}; use uuid::Uuid; #[derive(Debug, Serialize, Deserialize)] @@ -44,16 +44,12 @@ fn sign_and_verify( #[tokio::main] async fn main() -> color_eyre::Result<()> { - let object = TestObject { - name: "Test Object".to_string(), - description: "This is a test object".to_string(), - value: 42, - }; - - let payload = Payload { - data: serde_json::to_vec(&object)?, - object_type: "TestObject".to_string(), - }; + let payload = Payload::RegistrationRequest(RegistrationRequest { + common_access_card_id: "1234567890".to_owned(), + given_name: "John".to_owned(), + family_name: "Doe".to_owned(), + jurisdiction_code: JurisdictionCode::try_from("st.dev-jurisdiction").unwrap(), + }); let (certificates, public_key, private_key) = load_keypair()?; let payload = serde_json::to_vec(&payload)?; let signature = sign_and_verify(&payload, &private_key, &public_key)?; diff --git a/services/cacvote-server/bin/get-object-by-id.rs b/services/cacvote-server/bin/get-object-by-id.rs index 6105dbd38..e9fd5ea3c 100644 --- a/services/cacvote-server/bin/get-object-by-id.rs +++ b/services/cacvote-server/bin/get-object-by-id.rs @@ -1,5 +1,4 @@ use clap::Parser; -use serde_json::Value; use types_rs::cacvote::Payload; use url::Url; use uuid::Uuid; @@ -38,8 +37,5 @@ async fn main() -> color_eyre::Result<()> { let payload: Payload = serde_json::from_slice(&signed_object.payload)?; println!("object payload: {payload:#?}"); - let data: Value = payload.try_to_inner()?; - println!("payload data: {data:#?}"); - Ok(()) } diff --git a/services/cacvote-server/src/client.rs b/services/cacvote-server/src/client.rs index eb2a57462..083f09d65 100644 --- a/services/cacvote-server/src/client.rs +++ b/services/cacvote-server/src/client.rs @@ -145,10 +145,7 @@ mod tests { sign::{Signer, Verifier}, x509::X509, }; - use serde_json::json; - use types_rs::cacvote::{ - JournalEntryAction, JurisdictionCode, Payload, PayloadData, RegistrationRequest, - }; + use types_rs::cacvote::{JournalEntryAction, JurisdictionCode, Payload, RegistrationRequest}; use super::*; use crate::app; @@ -204,15 +201,12 @@ mod tests { let entries = client.get_journal_entries(None).await?; assert_eq!(entries, vec![]); - let payload = Payload::new( - "RegistrationRequest", - &RegistrationRequest { - common_access_card_id: "1234567890".to_owned(), - given_name: "John".to_owned(), - family_name: "Doe".to_owned(), - jurisdiction_code: JurisdictionCode::try_from("st.dev-jurisdiction").unwrap(), - }, - )?; + let payload = Payload::RegistrationRequest(RegistrationRequest { + common_access_card_id: "1234567890".to_owned(), + given_name: "John".to_owned(), + family_name: "Doe".to_owned(), + jurisdiction_code: JurisdictionCode::try_from("st.dev-jurisdiction").unwrap(), + }); let payload = serde_json::to_vec(&payload)?; let (certificates, public_key, private_key) = load_keypair()?; let signature = sign_and_verify(&payload, &private_key, &public_key)?; @@ -246,10 +240,9 @@ mod tests { // get the object let signed_object = client.get_object_by_id(object_id).await?.unwrap(); - let round_trip_payload = signed_object.try_to_inner()?; - let round_trip_registration_request = match round_trip_payload.try_to_inner_typed()? { - PayloadData::RegistrationRequest(registration_request) => registration_request, - other => panic!("expected RegistrationRequest, got: {:?}", other), + let round_trip_registration_request = match signed_object.try_to_inner()? { + Payload::RegistrationRequest(registration_request) => registration_request, + other => panic!("expected RegistrationRequest, got: {other:?}"), }; assert_eq!(signed_object.certificates, certificates); assert_eq!(signed_object.signature, signature); @@ -271,10 +264,12 @@ mod tests { async fn test_invalid_certificate(pool: sqlx::PgPool) -> color_eyre::Result<()> { let client = setup(pool)?; - let payload = Payload { - data: serde_json::to_vec(&json!({ "hello": "world" }))?, - object_type: "test".to_owned(), - }; + let payload = Payload::RegistrationRequest(RegistrationRequest { + common_access_card_id: "1234567890".to_owned(), + given_name: "John".to_owned(), + family_name: "Doe".to_owned(), + jurisdiction_code: JurisdictionCode::try_from("st.dev-jurisdiction").unwrap(), + }); let payload = serde_json::to_vec(&payload)?; client diff --git a/services/cacvote-server/src/db.rs b/services/cacvote-server/src/db.rs index 5e0b7fe43..a55801d31 100644 --- a/services/cacvote-server/src/db.rs +++ b/services/cacvote-server/src/db.rs @@ -45,7 +45,7 @@ pub async fn create_object( bail!("No jurisdiction found"); }; - let object_type = object.try_to_inner()?.object_type; + let object_type = object.try_to_inner()?.object_type(); let mut txn = connection.begin().await?; diff --git a/vxsuite.code-workspace b/vxsuite.code-workspace index 64bd2bb89..6b6fcefea 100644 --- a/vxsuite.code-workspace +++ b/vxsuite.code-workspace @@ -152,6 +152,7 @@ "cSpell.words": [ "accuvote", "AGPL", + "Apdu", "cacvote", "canonicalization", "canonicalize", @@ -168,6 +169,7 @@ "overvote", "overvoted", "overvotes", + "pcsc", "plustek", "pollworker", "previewable",