Site password derivation

This commit is contained in:
Jakub Hlusička 2026-02-08 21:04:28 +01:00
parent 4a5ada0bb0
commit 1e2d43a628
7 changed files with 465 additions and 161 deletions

View file

@ -1,4 +1,8 @@
use slint::SharedString; use alloc::rc::Rc;
use slint::{SharedString, VecModel};
use spectre_api_sys::SpectreUserKey;
use crate::ui::storage::SpectreSite;
pub enum CallbackMessage { pub enum CallbackMessage {
/// The escape key was pressed. /// The escape key was pressed.
@ -9,12 +13,24 @@ pub enum CallbackMessage {
UserSites(CallbackMessageUserSites), UserSites(CallbackMessageUserSites),
} }
pub enum LoginResult {
Failure,
Success {
user_key: SpectreUserKey,
sites: Rc<VecModel<SpectreSite>>,
},
}
pub enum CallbackMessageLogin { pub enum CallbackMessageLogin {
PwAccepted { PwAccepted {
user_index: i32, user_index: i32,
username: SharedString, username: SharedString,
password: SharedString, password: SharedString,
}, },
LoginResult {
username: SharedString,
result: LoginResult,
},
} }
pub enum CallbackMessageUsers { pub enum CallbackMessageUsers {
@ -24,8 +40,11 @@ pub enum CallbackMessageUsers {
pub enum CallbackMessageUserEdit { pub enum CallbackMessageUserEdit {
ComputeIdenticon { password: SharedString }, ComputeIdenticon { password: SharedString },
ComputeKeyId { key: SharedString }, ComputeKeyId { key: SharedString },
ConfirmRequest { encrypted_key: SharedString }, ConfirmRequest,
ConfirmProcessed, ConfirmProcessed,
} }
pub enum CallbackMessageUserSites {} pub enum CallbackMessageUserSites {
SiteNameEdited { query: SharedString },
SiteNameAccepted { site_list_index: i32 },
}

View file

@ -1,27 +1,46 @@
// #![cfg_attr(not(feature = "simulator"), no_main)] // #![cfg_attr(not(feature = "simulator"), no_main)]
use core::{cell::RefCell, ffi::CStr, ops::DerefMut}; use core::{cell::RefCell, ffi::CStr, ops::DerefMut, pin::Pin};
use alloc::{boxed::Box, ffi::CString, format, rc::Rc, vec}; use alloc::{
borrow::Cow,
boxed::Box,
ffi::CString,
format,
rc::Rc,
string::{String, ToString},
vec,
};
use embassy_time::Instant; use embassy_time::Instant;
use hex::FromHexError; use hex::FromHexError;
use i_slint_core::model::{ModelChangeListener, ModelChangeListenerContainer};
use log::{error, info, warn}; use log::{error, info, warn};
use password_hash::Key; use password_hash::Key;
use slint::{Model, ModelExt, ModelRc, SharedString, VecModel}; use slint::{
use spectre_api_sys::{SpectreAlgorithm, SpectreKeyID, SpectreUserKey}; Model, ModelExt, ModelNotify, ModelRc, ModelTracker, SharedString, StandardListViewItem,
VecModel,
};
use spectre_api_sys::{
SpectreAlgorithm, SpectreCounter, SpectreKeyID, SpectreKeyPurpose, SpectreResultType,
SpectreSiteKey, SpectreUserKey,
};
#[cfg(feature = "limit-fps")] #[cfg(feature = "limit-fps")]
use crate::FRAME_DURATION_MIN; use crate::FRAME_DURATION_MIN;
use crate::{ use crate::{
PSRAM_ALLOCATOR, SIGNAL_LCD_SUBMIT, SIGNAL_UI_RENDER, PSRAM_ALLOCATOR, SIGNAL_LCD_SUBMIT, SIGNAL_UI_RENDER,
db::{AcidDatabase, DbKey, DbPathSpectreUsers, PartitionAcid, ReadTransactionExt}, db::{
AcidDatabase, DbKey, DbPathSpectreUserSites, DbPathSpectreUsers, PartitionAcid,
ReadTransactionExt,
},
ffi::{alloc::__spre_free, crypto::ACTIVE_ENCRYPTED_USER_KEY}, ffi::{alloc::__spre_free, crypto::ACTIVE_ENCRYPTED_USER_KEY},
ui::{ ui::{
backend::SlintBackend, backend::SlintBackend,
messages::{ messages::{
CallbackMessage, CallbackMessageLogin, CallbackMessageUserEdit, CallbackMessageUsers, CallbackMessage, CallbackMessageLogin, CallbackMessageUserEdit,
CallbackMessageUserSites, CallbackMessageUsers, LoginResult,
}, },
storage::{SpectreUserConfig, SpectreUsersConfig}, storage::{SpectreSite, SpectreSiteConfig, SpectreUserConfig, SpectreUsersConfig},
}, },
util::DurationExt, util::DurationExt,
}; };
@ -66,95 +85,30 @@ fn spectre_derive_user_key(
} }
} }
fn spectre_derive_site_password(user_key: &SpectreUserKey, site_name: &CStr) -> String {
unsafe {
let site_password_c = &*spectre_api_sys::spectre_site_result(
user_key as *const SpectreUserKey,
site_name.as_ptr(),
SpectreResultType::SpectreResultDefaultResult,
core::ptr::null(),
SpectreCounter::Initial,
SpectreKeyPurpose::Authentication,
core::ptr::null(),
);
let site_password = CStr::from_ptr(site_password_c)
.to_str()
.unwrap()
.to_string();
__spre_free(site_password_c as *const _ as *mut _);
site_password
}
}
#[embassy_executor::task] #[embassy_executor::task]
pub async fn run_renderer_task(backend: SlintBackend, flash_part_acid: PartitionAcid) { pub async fn run_renderer_task(backend: SlintBackend, flash_part_acid: PartitionAcid) {
let db = AcidDatabase::mount(flash_part_acid).await; let db = AcidDatabase::mount(flash_part_acid).await;
// let value = SpectreUsersConfig {
// users: vec![SpectreUserConfig {
// username: "test".to_string(),
// encrypted_key: [0; _],
// key_id: [0; _],
// }],
// };
// {
// let mut write = db.write_transaction().await;
// write
// .write(
// &DbKey::new(DbPathSpectreUserSite {
// user_sites: DbPathSpectreUserSites {
// username: "test".into(),
// },
// site: "example.org".into(),
// }),
// &postcard::to_allocvec(&SpectreSiteConfig::default()).unwrap(),
// )
// .await
// .unwrap();
// write
// .write(
// &DbKey::new(DbPathSpectreUserSite {
// user_sites: DbPathSpectreUserSites {
// username: "test".into(),
// },
// site: "sub.example.org".into(),
// }),
// &postcard::to_allocvec(&SpectreSiteConfig::default()).unwrap(),
// )
// .await
// .unwrap();
// write
// .write(
// &DbKey::new(DbPathSpectreUsers),
// &postcard::to_allocvec(&value).unwrap(),
// )
// .await
// .unwrap();
// write.commit().await.unwrap();
// }
// let read_value = {
// let read = db.read_transaction().await;
// // TODO: https://github.com/embassy-rs/ekv/issues/20
// let mut buffer = vec![0; 256];
// let slice = read
// .read_to_vec(&DbKey::new(DbPathSpectreUsers), &mut buffer)
// .await
// .unwrap();
// let read_value = postcard::from_bytes::<SpectreUsersConfig>(&slice).unwrap();
// let key_sites = DbKey::new(DbPathSpectreUserSites {
// username: "test".into(),
// });
// let mut key_buffer = [0_u8; ekv::config::MAX_KEY_SIZE];
// let mut cursor = read
// .read_range(key_sites.range_of_children())
// .await
// .unwrap();
// while let Some((key_len, value_len)) =
// cursor.next(&mut key_buffer, &mut buffer).await.unwrap()
// {
// let key = DbKey::from_raw(key_buffer[..key_len].into());
// let mut key_segments = key.segments();
// let value = &buffer[..value_len];
// let site_config = postcard::from_bytes::<SpectreSiteConfig>(&value).unwrap();
// let site = SpectreSite {
// config: site_config,
// username: key_segments.nth(2).unwrap().into(),
// site_name: key_segments.nth(1).unwrap().into(),
// };
// info!("site = {:#?}", site);
// }
// read_value
// };
// info!("read_value = {:#?}", read_value);
// assert_eq!(value, read_value, "values do not match");
// TODO: // TODO:
// * Store a config as a versioned postcard-serialized struct // * Store a config as a versioned postcard-serialized struct
// * Store accounts and sites as ranges in the DB // * Store accounts and sites as ranges in the DB
@ -269,11 +223,31 @@ impl State {
main.on_user_edit_confirm({ main.on_user_edit_confirm({
let state = state.clone(); let state = state.clone();
move |encrypted_key| { move |_encrypted_key| {
State::process_callback_message( State::process_callback_message(
&state, &state,
CallbackMessage::UserEdit(CallbackMessageUserEdit::ConfirmRequest { CallbackMessage::UserEdit(CallbackMessageUserEdit::ConfirmRequest),
encrypted_key, );
}
});
main.on_user_sites_site_name_edited({
let state = state.clone();
move |query| {
State::process_callback_message(
&state,
CallbackMessage::UserSites(CallbackMessageUserSites::SiteNameEdited { query }),
);
}
});
main.on_user_sites_site_name_accepted({
let state = state.clone();
move |site_list_index| {
State::process_callback_message(
&state,
CallbackMessage::UserSites(CallbackMessageUserSites::SiteNameAccepted {
site_list_index,
}), }),
); );
} }
@ -315,6 +289,44 @@ impl State {
write.commit().await.unwrap(); write.commit().await.unwrap();
} }
fn make_site_list(
sites: &Rc<VecModel<SpectreSite>>,
query: &SharedString,
) -> Rc<VecModel<SiteListEntry>> {
let site_list = Rc::new(VecModel::default());
for site in sites.iter() {
site_list.push(SiteListEntry::Existing(site.site_name));
}
if !query.is_empty() {
site_list.push(SiteListEntry::New(query.clone()))
}
site_list
}
fn update_site_list(&mut self) {
if let Some(StateUserSites {
sites,
query,
site_list,
..
}) = self.state_user_sites.as_mut()
{
*site_list = State::make_site_list(sites, query);
self.window
.set_user_sites_sites(ModelRc::from(Rc::new(site_list.clone().map(|site| {
let mut item = StandardListViewItem::default();
item.text = site.to_string();
item
}))));
} else {
self.window.invoke_user_sites_site_name_clear();
self.window.set_user_sites_sites(Default::default());
}
}
fn process_callback_message(state_rc: &Rc<RefCell<State>>, message: CallbackMessage) { fn process_callback_message(state_rc: &Rc<RefCell<State>>, message: CallbackMessage) {
let view = state_rc.borrow().view; let view = state_rc.borrow().view;
match view { match view {
@ -376,49 +388,149 @@ struct StateLogin {}
impl AppViewTrait for StateLogin { impl AppViewTrait for StateLogin {
fn process_callback_message(state_rc: &Rc<RefCell<State>>, message: CallbackMessage) { fn process_callback_message(state_rc: &Rc<RefCell<State>>, message: CallbackMessage) {
let mut state = state_rc.borrow_mut(); let mut state = state_rc.borrow_mut();
if let CallbackMessage::Login(CallbackMessageLogin::PwAccepted { match message {
user_index, CallbackMessage::Login(CallbackMessageLogin::PwAccepted {
username: _, user_index,
password, username: _,
}) = message password,
{ }) => {
let Some(user) = state.users.users.row_data(user_index as usize) else { let Some(user) = state.users.users.row_data(user_index as usize) else {
error!("Failed to find a user with index {user_index}."); error!("Failed to find a user with index {user_index}.");
return; return;
}; };
let username_c = let username_c = CString::new(&*user.username)
CString::new(&*user.username).expect("Username cannot be converted to a C string."); .expect("Username cannot be converted to a C string.");
let password_c = let password_c =
CString::new(&*password).expect("Password cannot be converted to a C string."); CString::new(&*password).expect("Password cannot be converted to a C string.");
let user_key = let user_key =
spectre_derive_user_key(&username_c, &password_c, Some(user.encrypted_key)); spectre_derive_user_key(&username_c, &password_c, Some(user.encrypted_key));
// let site_key_start = Instant::now(); // {
// let site_key = &*spectre_api_sys::spectre_site_key( // let mut write = db.write_transaction().await;
// user_key as *const SpectreUserKey, // write
// c"example.org".as_ptr(), // .write(
// SpectreCounter::Initial, // &DbKey::new(DbPathSpectreUserSite {
// SpectreKeyPurpose::Authentication, // user_sites: DbPathSpectreUserSites {
// c"".as_ptr(), // username: "test".into(),
// ); // },
// let site_key_duration = Instant::now().duration_since(site_key_start); // site: "example.org".into(),
// warn!( // }),
// "Site key derived in {} seconds:\n{site_key:02x?}", // &postcard::to_allocvec(&SpectreSiteConfig::default()).unwrap(),
// site_key_duration.display_as_secs() // )
// ); // .await
// TODO: Erase memory before freeing // .unwrap();
// __spre_free(site_key as *const _ as *mut _); // write
// .write(
// &DbKey::new(DbPathSpectreUserSite {
// user_sites: DbPathSpectreUserSites {
// username: "test".into(),
// },
// site: "sub.example.org".into(),
// }),
// &postcard::to_allocvec(&SpectreSiteConfig::default()).unwrap(),
// )
// .await
// .unwrap();
// write
// .write(
// &DbKey::new(DbPathSpectreUsers),
// &postcard::to_allocvec(&value).unwrap(),
// )
// .await
// .unwrap();
// write.commit().await.unwrap();
// }
if user.key_id == user_key.keyID.bytes { if user.key_id != user_key.keyID.bytes {
info!("Correct password entered for user {:?}.", user.username); State::process_callback_message(
state.state_user_sites = Some(StateUserSites { &state_rc,
username: user.username, CallbackMessage::Login(CallbackMessageLogin::LoginResult {
user_key, username: user.username,
}); result: LoginResult::Failure,
state.set_view(AppState::UserSites, true, false); }),
} else { );
warn!("Incorrect password entered for user {:?}.", user.username); return;
}
slint::spawn_local({
let state_rc = state_rc.clone();
let username = user.username.clone();
let db = state.db.clone();
async move {
let read = db.read_transaction().await;
// TODO: https://github.com/embassy-rs/ekv/issues/20
let mut buffer = vec![0; 256];
// let slice = read
// .read_to_vec(&DbKey::new(DbPathSpectreUsers), &mut buffer)
// .await
// .unwrap();
// let read_value = postcard::from_bytes::<SpectreUsersConfig>(&slice).unwrap();
let key_sites = DbKey::new(DbPathSpectreUserSites {
username: Cow::Borrowed(&username),
});
let mut key_buffer = [0_u8; ekv::config::MAX_KEY_SIZE];
let mut cursor = read
.read_range(key_sites.range_of_children())
.await
.unwrap();
let sites = VecModel::default();
while let Some((key_len, value_len)) =
cursor.next(&mut key_buffer, &mut buffer).await.unwrap()
{
let key = DbKey::from_raw(key_buffer[..key_len].into());
let mut key_segments = key.segments();
let value = &buffer[..value_len];
let site_config =
postcard::from_bytes::<SpectreSiteConfig>(&value).unwrap();
let site = SpectreSite {
config: site_config,
username: key_segments.nth(2).unwrap().as_ref().into(),
site_name: key_segments.nth(1).unwrap().as_ref().into(),
};
info!("site = {:#?}", site);
sites.push(site);
}
State::process_callback_message(
&state_rc,
CallbackMessage::Login(CallbackMessageLogin::LoginResult {
username: user.username,
result: LoginResult::Success {
user_key,
sites: Rc::new(sites),
},
}),
);
}
})
.unwrap();
} }
CallbackMessage::Login(CallbackMessageLogin::LoginResult {
username,
result: LoginResult::Success { user_key, sites },
}) => {
info!("Correct password entered for user {:?}.", username);
state.state_user_sites = Some(StateUserSites {
username,
user_key,
query: SharedString::new(),
sites: sites.clone(),
site_list: Default::default(),
});
state.update_site_list();
state.set_view(AppState::UserSites, true, false);
}
CallbackMessage::Login(CallbackMessageLogin::LoginResult {
username,
result: LoginResult::Failure,
}) => {
warn!("Incorrect password entered for user {:?}.", username);
}
_ => (),
} }
} }
} }
@ -538,9 +650,7 @@ impl AppViewTrait for StateUserEdit {
); );
state.state_user_edit.encrypted_key = Some((key, user_key)); state.state_user_edit.encrypted_key = Some((key, user_key));
} }
CallbackMessage::UserEdit(CallbackMessageUserEdit::ConfirmRequest { CallbackMessage::UserEdit(CallbackMessageUserEdit::ConfirmRequest) => {
encrypted_key: _,
}) => {
let Some(( let Some((
encrypted_key, encrypted_key,
SpectreUserKey { SpectreUserKey {
@ -580,6 +690,7 @@ impl AppViewTrait for StateUserEdit {
let state_rc = state_rc.clone(); let state_rc = state_rc.clone();
let db = state.db.clone(); let db = state.db.clone();
let users = state.users.clone(); let users = state.users.clone();
async move { async move {
State::save_users(&db, &users).await; State::save_users(&db, &users).await;
State::process_callback_message( State::process_callback_message(
@ -602,6 +713,153 @@ impl AppViewTrait for StateUserEdit {
struct StateUserSites { struct StateUserSites {
username: SharedString, username: SharedString,
user_key: SpectreUserKey, user_key: SpectreUserKey,
query: SharedString,
sites: Rc<VecModel<SpectreSite>>,
site_list: Rc<VecModel<SiteListEntry>>,
} }
impl AppViewTrait for StateUserSites {} impl AppViewTrait for StateUserSites {
fn process_callback_message(state_rc: &Rc<RefCell<State>>, message: CallbackMessage) {
let state = state_rc.clone();
let mut state = state.borrow_mut();
match message {
CallbackMessage::Escape => {
state.set_view(AppState::Login, true, false);
}
CallbackMessage::UserSites(CallbackMessageUserSites::SiteNameEdited { query }) => {
if let Some(user_sites) = state.state_user_sites.as_mut() {
user_sites.query = query;
state.update_site_list();
}
}
CallbackMessage::UserSites(CallbackMessageUserSites::SiteNameAccepted {
site_list_index,
}) => {
let Some(user_sites) = state.state_user_sites.as_mut() else {
error!("User sites uninitialized.");
return;
};
let Some(site_list_entry) = user_sites.site_list.row_data(site_list_index as usize)
else {
error!("Invalid site list entry index: {site_list_index}");
return;
};
warn!("Site name accepted: {site_list_entry:?}");
let site_name = match site_list_entry {
SiteListEntry::New(site_name) => site_name,
SiteListEntry::Existing(site_name) => site_name,
};
let site_name_c = CString::new(&*site_name).unwrap();
let site_password =
spectre_derive_site_password(&user_sites.user_key, &site_name_c);
warn!("Site password: {site_password:?}");
}
_ => (),
}
}
}
pub struct SitesModel<M>(Pin<Box<ModelChangeListenerContainer<SitesModelInner<M>>>>)
where
M: Model + 'static;
struct SitesModelInner<M>
where
M: Model + 'static,
{
wrapped_model: M,
notify: ModelNotify,
}
impl<M> ModelChangeListener for SitesModelInner<M>
where
M: Model + 'static,
{
fn row_changed(self: Pin<&Self>, row: usize) {
self.notify
.row_changed(self.wrapped_model.row_count() - 1 - row);
}
fn row_added(self: Pin<&Self>, index: usize, count: usize) {
let row_count = self.wrapped_model.row_count();
let old_row_count = row_count - count;
let index = old_row_count - index;
self.notify.row_added(index, count);
}
fn row_removed(self: Pin<&Self>, index: usize, count: usize) {
let row_count = self.wrapped_model.row_count();
self.notify.row_removed(row_count - index, count);
}
fn reset(self: Pin<&Self>) {
self.notify.reset()
}
}
impl<M> SitesModel<M>
where
M: Model + 'static,
{
pub fn new(wrapped_model: M) -> Self {
let inner = SitesModelInner {
wrapped_model,
notify: Default::default(),
};
let container = Box::pin(ModelChangeListenerContainer::new(inner));
container
.wrapped_model
.model_tracker()
.attach_peer(container.as_ref().model_peer());
Self(container)
}
/// Returns a reference to the inner model
pub fn source_model(&self) -> &M {
&self.0.as_ref().get().get_ref().wrapped_model
}
}
impl<M> Model for SitesModel<M>
where
M: Model + 'static,
{
type Data = M::Data;
fn row_count(&self) -> usize {
self.0.wrapped_model.row_count()
}
fn row_data(&self, row: usize) -> Option<Self::Data> {
let count = self.0.wrapped_model.row_count();
self.0.wrapped_model.row_data(count.checked_sub(row + 1)?)
}
fn set_row_data(&self, row: usize, data: Self::Data) {
let count = self.0.as_ref().wrapped_model.row_count();
self.0.wrapped_model.set_row_data(count - row - 1, data);
}
fn model_tracker(&self) -> &dyn ModelTracker {
&self.0.notify
}
fn as_any(&self) -> &dyn core::any::Any {
self
}
}
#[derive(Clone, Debug)]
pub enum SiteListEntry {
New(SharedString),
Existing(SharedString),
}
impl SiteListEntry {
pub fn to_string(&self) -> SharedString {
match self {
SiteListEntry::New(site) => slint::format!("{site} <Add new site>"),
SiteListEntry::Existing(site) => site.clone(),
}
}
}

View file

@ -51,9 +51,12 @@ export component AppWindow inherits Window {
callback user_edit_compute_key_id <=> user_edit_view.compute_key_id; callback user_edit_compute_key_id <=> user_edit_view.compute_key_id;
callback user_edit_confirm <=> user_edit_view.confirm; callback user_edit_confirm <=> user_edit_view.confirm;
// User Sites View // User Sites View
in property <[StandardListViewItem]> sites <=> user_sites_view.model; in property <[StandardListViewItem]> user_sites_sites <=> user_sites_view.model;
callback site_pw_edited <=> user_sites_view.pw_edited; callback user_sites_site_name_edited <=> user_sites_view.site_name_edited;
callback site_pw_accepted <=> user_sites_view.pw_accepted; callback user_sites_site_name_accepted <=> user_sites_view.site_name_accepted;
public function user_sites_site_name_clear() {
user_sites_view.site_name_clear();
}
focus-scope := FocusScope { focus-scope := FocusScope {
key-pressed(event) => { key-pressed(event) => {
if event.text == "\u{1b}" { if event.text == "\u{1b}" {

View file

@ -7,24 +7,32 @@ export component UserSitesView inherits HorizontalLayout {
spacing: Style.spacing; spacing: Style.spacing;
in property <[StandardListViewItem]> model <=> list_view_sites.model; in property <[StandardListViewItem]> model <=> list_view_sites.model;
in-out property <int> current-item <=> list_view_sites.current-item; in-out property <int> current-item <=> list_view_sites.current-item;
callback pw_edited <=> line_edit_site_pw.edited; callback site_name_edited <=> line_edit_site_name.edited;
callback pw_accepted <=> line_edit_site_pw.accepted; callback site_name_accepted(site_list_index: int);
VerticalLayout { public function site_name_clear() {
spacing: Style.spacing; line_edit_site_name.text = "";
Text { }
text: "Send password for:"; FocusScope {
key-pressed(event) => {
if event.text == "\n" {
site_name_accepted(list_view_sites.current-item);
EventResult.accept
} else {
EventResult.reject
}
} }
VerticalLayout {
spacing: Style.spacing;
Text {
text: "Send password for:";
}
line_edit_site_pw := LineEdit { line_edit_site_name := LineEdit {
input-type: InputType.text; input-type: InputType.text;
placeholder-text: "example.org"; placeholder-text: "example.org";
} }
list_view_sites := StandardListView { list_view_sites := StandardListView { }
model: [
{ text: "Test" },
{ text: "Test" },
];
} }
} }

View file

@ -0,0 +1,2 @@
[env] # These must be kept in sync with /.zed/settings.json
LIBSODIUM_INSTALL_DIR = "../libsodium/install-host"

View file

@ -1,3 +1,17 @@
# Compiling # Compiling
Compile on Linux or in WSL. Compile on Linux or in WSL.
Compile libsodium for the host:
```bash
cd ../libsodium
./configure --disable-shared --enable-static --prefix="$PWD/install-host"
make -j
make install
```
Then compile and run password-hash:
```bash
cd ../password-hash
cargo run --release [--target x86_64-unknown-linux-gnu]
```

View file

@ -29,7 +29,7 @@ fn main() {
libsodium_install_dir.join("lib/libsodium.a").display() libsodium_install_dir.join("lib/libsodium.a").display()
); );
} else { } else {
println!("cargo:warn=Environment variable `LIBSODIUM_INSTALL_DIR` missing!"); panic!("Environment variable `LIBSODIUM_INSTALL_DIR` missing!");
} }
} }
} }