mirror of
https://github.com/tonhowtf/omniget.git
synced 2026-10-02 10:24:56 +08:00
fix(omnidisc): stop the keychain prompt storm, and stop shipping a test password
Two problems, one root: development builds cannot hold a keychain grant. tauri dev produces an unsigned binary that changes on every rebuild, and macOS binds Always Allow to the binary's identity, so the grant is void the next time you press run — the prompt returned on every launch and every read no matter how many times it was answered. Debug builds now use the encrypted file store and only signed release builds touch the keyring; OMNIGET_OMNIDISC_KEYRING=1 forces the keyring back on. Secrets are also cached per process, so one launch asks the OS once per secret instead of once per gateway reconnect and request. The cache key carries the store it came from, because the tests run two identities in one process by switching session directories. The live tests also registered real accounts using a password written in the repository, which is a working credential for every account they leave behind. It is now random per run.
This commit is contained in:
@@ -16,6 +16,28 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// A password nobody can guess from the repository.
|
||||
///
|
||||
/// These tests register real accounts on whatever instance `OMNIDISC_TEST_URL`
|
||||
/// points at, and a constant in a public repo is a working credential for every
|
||||
/// account they ever left behind. One random value per process keeps the run
|
||||
/// self-consistent without publishing a key.
|
||||
fn test_password() -> &'static str {
|
||||
static PASSWORD: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
PASSWORD.get_or_init(|| {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"od-{}-{:x}-{:x}",
|
||||
std::process::id(),
|
||||
nanos,
|
||||
nanos.rotate_left(29)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn unique(prefix: &str) -> String {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -32,7 +54,7 @@ fn unique(prefix: &str) -> String {
|
||||
async fn register(http: &reqwest::Client, base: &str, name: &str) -> (String, String) {
|
||||
let res: Value = http
|
||||
.post(format!("{base}/api/auth/register"))
|
||||
.json(&json!({ "username": name, "password": "correct horse battery" }))
|
||||
.json(&json!({ "username": name, "password": test_password() }))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request")
|
||||
|
||||
@@ -16,6 +16,28 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// A password nobody can guess from the repository.
|
||||
///
|
||||
/// These tests register real accounts on whatever instance `OMNIDISC_TEST_URL`
|
||||
/// points at, and a constant in a public repo is a working credential for every
|
||||
/// account they ever left behind. One random value per process keeps the run
|
||||
/// self-consistent without publishing a key.
|
||||
fn test_password() -> &'static str {
|
||||
static PASSWORD: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
PASSWORD.get_or_init(|| {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"od-{}-{:x}-{:x}",
|
||||
std::process::id(),
|
||||
nanos,
|
||||
nanos.rotate_left(29)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn unique(prefix: &str) -> String {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -32,7 +54,7 @@ fn unique(prefix: &str) -> String {
|
||||
async fn register(http: &reqwest::Client, base: &str, name: &str) -> (String, String) {
|
||||
let res: Value = http
|
||||
.post(format!("{base}/api/auth/register"))
|
||||
.json(&json!({ "username": name, "password": "correct horse battery" }))
|
||||
.json(&json!({ "username": name, "password": test_password() }))
|
||||
.send()
|
||||
.await
|
||||
.expect("register request")
|
||||
|
||||
@@ -13,6 +13,28 @@ use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// A password nobody can guess from the repository.
|
||||
///
|
||||
/// These tests register real accounts on whatever instance `OMNIDISC_TEST_URL`
|
||||
/// points at, and a constant in a public repo is a working credential for every
|
||||
/// account they ever left behind. One random value per process keeps the run
|
||||
/// self-consistent without publishing a key.
|
||||
fn test_password() -> &'static str {
|
||||
static PASSWORD: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
PASSWORD.get_or_init(|| {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"od-{}-{:x}-{:x}",
|
||||
std::process::id(),
|
||||
nanos,
|
||||
nanos.rotate_left(29)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
enum Ev {
|
||||
Dispatch(String, Value),
|
||||
Status(Status, Option<String>),
|
||||
@@ -73,7 +95,7 @@ async fn register_gateway_ready_send_receive() {
|
||||
|
||||
let alice_name = unique("alice");
|
||||
let bob_name = unique("bob");
|
||||
let password = "correct horse battery";
|
||||
let password = test_password();
|
||||
|
||||
let alice = auth::register(&base, &alice_name, password, Some("Alice"), None)
|
||||
.await
|
||||
|
||||
@@ -22,6 +22,28 @@ use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A password nobody can guess from the repository.
|
||||
///
|
||||
/// These tests register real accounts on whatever instance `OMNIDISC_TEST_URL`
|
||||
/// points at, and a constant in a public repo is a working credential for every
|
||||
/// account they ever left behind. One random value per process keeps the run
|
||||
/// self-consistent without publishing a key.
|
||||
fn test_password() -> &'static str {
|
||||
static PASSWORD: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
PASSWORD.get_or_init(|| {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"od-{}-{:x}-{:x}",
|
||||
std::process::id(),
|
||||
nanos,
|
||||
nanos.rotate_left(29)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
struct Side {
|
||||
name: String,
|
||||
dir: PathBuf,
|
||||
@@ -68,7 +90,7 @@ async fn register_side(base: &str, name_prefix: &str) -> Side {
|
||||
let dir = workspace(name_prefix);
|
||||
std::env::set_var(store::SESSION_DIR_ENV, &dir);
|
||||
let username = unique(name_prefix);
|
||||
let user = auth::register(base, &username, "correct horse battery", None, None)
|
||||
let user = auth::register(base, &username, test_password(), None, None)
|
||||
.await
|
||||
.expect("register");
|
||||
let token = store::load_token(base).expect("store").expect("token");
|
||||
|
||||
@@ -22,6 +22,7 @@ use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
pub const SERVICE: &str = "wtf.tonho.omniget.omnidisc";
|
||||
const SESSIONS_FILE: &str = "sessions.bin";
|
||||
@@ -34,6 +35,55 @@ type Dec = cbc::Decryptor<aes::Aes256>;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub const SESSION_DIR_ENV: &str = "OMNIGET_OMNIDISC_SESSION_DIR";
|
||||
const KEYRING_ENV: &str = "OMNIGET_OMNIDISC_KEYRING";
|
||||
|
||||
/// Whether to put secrets in the OS keyring at all.
|
||||
///
|
||||
/// A development build cannot hold a keychain grant: `tauri dev` produces an
|
||||
/// unsigned binary that is rebuilt on every change, and macOS binds "Always
|
||||
/// Allow" to the binary's identity, so the grant is void the next time you press
|
||||
/// run. The prompt then returns on every launch and every read, no matter how
|
||||
/// many times it is answered. Debug builds therefore use the encrypted file
|
||||
/// store, and only release builds — signed, and stable across launches — use the
|
||||
/// keyring. `OMNIGET_OMNIDISC_KEYRING=1` forces it on to exercise that path.
|
||||
fn use_keyring() -> bool {
|
||||
match std::env::var(KEYRING_ENV) {
|
||||
Ok(v) => matches!(v.trim(), "1" | "true" | "yes"),
|
||||
Err(_) => !cfg!(debug_assertions),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-through cache so one launch asks the OS once per secret instead of once
|
||||
/// per call. Every gateway reconnect and every authenticated request would
|
||||
/// otherwise reach for the token again.
|
||||
fn cache() -> &'static Mutex<HashMap<String, Option<String>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// The key carries the store the value came from, not just the account. The
|
||||
/// integration tests give each side its own `OMNIGET_OMNIDISC_SESSION_DIR` and
|
||||
/// switch between them in one process, so an account-only key would hand one
|
||||
/// side the other's device key.
|
||||
fn cache_key(account: &str) -> String {
|
||||
match forced_dir() {
|
||||
Some(dir) => format!("{}|{}", dir.display(), account),
|
||||
None => format!("<default>|{account}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_put(account: &str, value: Option<String>) {
|
||||
if let Ok(mut c) = cache().lock() {
|
||||
c.insert(cache_key(account), value);
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_get(account: &str) -> Option<Option<String>> {
|
||||
cache()
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|c| c.get(&cache_key(account)).cloned())
|
||||
}
|
||||
|
||||
fn forced_dir() -> Option<PathBuf> {
|
||||
std::env::var(SESSION_DIR_ENV)
|
||||
@@ -63,6 +113,7 @@ pub fn secret_account(url: &str, kind: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn save_secret(account: &str, value: &str) -> Result<(), String> {
|
||||
cache_put(account, Some(value.to_string()));
|
||||
if let Some(dir) = forced_dir() {
|
||||
return FileStore::new(dir).set(account, value);
|
||||
}
|
||||
@@ -75,22 +126,31 @@ pub fn save_secret(account: &str, value: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
pub fn load_secret(account: &str) -> Result<Option<String>, String> {
|
||||
if let Some(hit) = cache_get(account) {
|
||||
return Ok(hit);
|
||||
}
|
||||
if let Some(dir) = forced_dir() {
|
||||
return FileStore::new(dir).get(account);
|
||||
let found = FileStore::new(dir).get(account)?;
|
||||
cache_put(account, found.clone());
|
||||
return Ok(found);
|
||||
}
|
||||
match keyring_get(account) {
|
||||
Some(Ok(found)) => {
|
||||
if found.is_some() {
|
||||
cache_put(account, found.clone());
|
||||
return Ok(found);
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => tracing::warn!("[omnidisc] keyring read failed, using file store: {}", e),
|
||||
None => {}
|
||||
}
|
||||
FileStore::default_dir()?.get(account)
|
||||
let found = FileStore::default_dir()?.get(account)?;
|
||||
cache_put(account, found.clone());
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub fn delete_secret(account: &str) -> Result<(), String> {
|
||||
cache_put(account, None);
|
||||
if let Some(dir) = forced_dir() {
|
||||
return FileStore::new(dir).remove(account);
|
||||
}
|
||||
@@ -119,11 +179,17 @@ fn keyring_entry(url: &str) -> Result<keyring::Entry, String> {
|
||||
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
fn keyring_set(url: &str, token: &str) -> Option<Result<(), String>> {
|
||||
if !use_keyring() {
|
||||
return None;
|
||||
}
|
||||
Some(keyring_entry(url).and_then(|e| e.set_password(token).map_err(|e| e.to_string())))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
fn keyring_get(url: &str) -> Option<Result<Option<String>, String>> {
|
||||
if !use_keyring() {
|
||||
return None;
|
||||
}
|
||||
Some(keyring_entry(url).and_then(|e| match e.get_password() {
|
||||
Ok(t) => Ok(Some(t)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
@@ -133,6 +199,9 @@ fn keyring_get(url: &str) -> Option<Result<Option<String>, String>> {
|
||||
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
fn keyring_delete(url: &str) -> Option<Result<(), String>> {
|
||||
if !use_keyring() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
keyring_entry(url).and_then(|e| match e.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
|
||||
Reference in New Issue
Block a user