cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

feat: keyring persistence — register host once, start without Titan

dami etoile committed 8b4d4e9
- Add  crate for OS keyring (GNOME Keyring on Linux, Keychain on macOS)
- : derives identity from Titan, stores 32 bytes in keyring
- : checks if keyring has a stored identity
- : clears keyring entry
- : reads from keyring instead of Titan (no tap needed)
- Frontend: Register Key button (one-time), Start Hosting (no key needed)
- Client connect unchanged: always requires Titan (physical presence to dial in)
commit
8b4d4e9f52f479ba0d88e3e68a7bf7beb0042105
parent
85b3879726e6e73af5321a7544f1a945b3d413a1
author
dami etoile <dami.etoile@gmail.com>
5 files changed, +189 -12
Msrc-tauri/Cargo.lock11+++
Msrc-tauri/Cargo.toml1+
Msrc-tauri/src/commands.rs108++++++++++++++++++++++++++++--
Msrc-tauri/src/main.rs3+
Msrc/ui/index.html78++++++++++++++++++++--
M src-tauri/Cargo.lock +11 -0
M src-tauri/Cargo.toml +1 -0
@@ -30,3 +30,4 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
3030 base64 = "0.22"3131 enigo = { version = "0.2", features = ["serde"] }3232 sha2 = "0.10"33+keyring = "3"
M src-tauri/src/commands.rs +102 -6
@@ -36,6 +36,45 @@ const INPUT_ALPN: &[u8] = b"keyhome/input-stream/0";
3636 const RPID: &str = "keyhome";3737 const SALT_MESSAGE: &str = "keyhome-iroh-identity-v1";3838 const N0_RELAY: &str = "https://usw1-1.relay.n0.iroh.link./";39+const KEYRING_SERVICE: &str = "keyhome";40+const KEYRING_ENTRY: &str = "host-identity";41+42+// ─── Keyring Persistence ─────────────────────────────────────────────────────43+44+fn store_identity_in_keyring(secret: &[u8; 32]) -> anyhow::Result<()> {45+ let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ENTRY)46+ .context("Failed to create keyring entry")?;47+ entry48+ .set_secret(secret)49+ .context("Failed to store identity in keyring")50+}51+52+fn load_identity_from_keyring() -> anyhow::Result<Option<[u8; 32]>> {53+ let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ENTRY)54+ .context("Failed to create keyring entry")?;55+ match entry.get_secret() {56+ Ok(bytes) => {57+ if bytes.len() == 32 {58+ let mut arr = [0u8; 32];59+ arr.copy_from_slice(&bytes);60+ Ok(Some(arr))61+ } else {62+ anyhow::bail!("Keyring entry has wrong length: {}", bytes.len())63+ }64+ }65+ Err(keyring::Error::NoEntry) => Ok(None),66+ Err(e) => anyhow::bail!("Failed to read keyring: {:?}", e),67+ }68+}69+70+fn clear_identity_from_keyring() -> anyhow::Result<()> {71+ let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ENTRY)72+ .context("Failed to create keyring entry")?;73+ match entry.delete_credential() {74+ Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),75+ Err(e) => anyhow::bail!("Failed to clear keyring: {:?}", e),76+ }77+}3978 4079 // ─── Titan HMAC-Secret Derivation ────────────────────────────────────────────4180
@@ -414,23 +453,80 @@ pub fn titan_derive_identity(pin: String) -> TitanIdentity {
414453 }415454 }416455 417-// ─── Iroh Host ───────────────────────────────────────────────────────────────456+// ─── Host Registration (keyring) ─────────────────────────────────────────────418457 419458 #[derive(Serialize)]420-pub struct HostStatus {421- pub running: bool,459+pub struct RegistrationStatus {460+ pub registered: bool,422461 pub node_id: Option<String>,423462 pub error: Option<String>,424463 }425464 426465 #[tauri::command]427-pub async fn iroh_host_start(state: State<'_, AppState>, pin: String) -> Result<HostStatus, String> {428- // Derive identity from Titan (blocking — runs in Tauri's async pool)429- let secret = tokio::task::spawn_blocking(move || derive_iroh_secret_from_titan(&pin))466+pub fn host_registration_status() -> RegistrationStatus {467+ match load_identity_from_keyring() {468+ Ok(Some(bytes)) => {469+ let secret = SecretKey::from_bytes(&bytes);470+ RegistrationStatus {471+ registered: true,472+ node_id: Some(secret.public().to_string()),473+ error: None,474+ }475+ }476+ Ok(None) => RegistrationStatus {477+ registered: false,478+ node_id: None,479+ error: None,480+ },481+ Err(e) => RegistrationStatus {482+ registered: false,483+ node_id: None,484+ error: Some(format!("{:?}", e)),485+ },486+ }487+}488+489+#[tauri::command]490+pub async fn titan_register_host(pin: String) -> Result<RegistrationStatus, String> {491+ let secret = tokio::task::spawn_blocking(move || derive_secret_from_titan(&pin))430492 .await431493 .map_err(|e| format!("Titan derivation task failed: {}", e))?432494 .map_err(|e| format!("Titan derivation failed: {:?}", e))?;433495 496+ let node_id = SecretKey::from_bytes(&secret).public().to_string();497+498+ store_identity_in_keyring(&secret).map_err(|e| format!("Keyring store failed: {:?}", e))?;499+500+ Ok(RegistrationStatus {501+ registered: true,502+ node_id: Some(node_id),503+ error: None,504+ })505+}506+507+#[tauri::command]508+pub fn host_unregister() -> Result<bool, String> {509+ clear_identity_from_keyring().map_err(|e| format!("Keyring clear failed: {:?}", e))?;510+ Ok(true)511+}512+513+// ─── Iroh Host ───────────────────────────────────────────────────────────────514+515+#[derive(Serialize)]516+pub struct HostStatus {517+ pub running: bool,518+ pub node_id: Option<String>,519+ pub error: Option<String>,520+}521+522+#[tauri::command]523+pub async fn iroh_host_start(state: State<'_, AppState>) -> Result<HostStatus, String> {524+ // Read identity from keyring — no Titan needed525+ let secret_bytes = load_identity_from_keyring()526+ .map_err(|e| format!("Keyring read failed: {:?}", e))?527+ .ok_or_else(|| "Not registered. Click 'Register Key' first.".to_string())?;528+529+ let secret = SecretKey::from_bytes(&secret_bytes);434530 let node_id = secret.public().to_string();435531 436532 let endpoint = Endpoint::builder(presets::N0)
M src-tauri/src/main.rs +3 -0
M src/ui/index.html +72 -6
@@ -166,6 +166,10 @@
166166 <!-- Host Card -->167167 <div class="card">168168 <h2>Host (Server)</h2>169+ <div class="row">170+ <span class="label">Registration</span>171+ <span id="reg-status" class="status-badge status-pending">Not registered</span>172+ </div>169173 <div class="row">170174 <span class="label">Status</span>171175 <span id="host-status" class="status-badge status-pending">Not started</span>
@@ -175,10 +179,13 @@
175179 <span id="host-id" class="value">—</span>176180 </div>177181 <div class="btn-bar">178- <button class="btn btn-primary" onclick="startHost()">Start hosting</button>182+ <button class="btn btn-primary" onclick="registerKey()">Register Key</button>183+ <button class="btn" onclick="startHost()">Start Hosting</button>184+ <button class="btn" onclick="unregister()" style="margin-left:auto; font-size:11px; opacity:0.6;">Unregister</button>179185 </div>180186 <div class="titan-hint">181- Tap Titan when it blinks. Identity derived from key — no address to share.187+ Register: one-time Titan tap, stores identity in OS keyring.188+ Start Hosting: no key needed.182189 </div>183190 </div>184191
@@ -254,19 +261,75 @@
254261 }255262 }256263 257- // ─── Host ──────────────────────────────────────────────────────────258- async function startHost() {264+ // ─── Host Registration ─────────────────────────────────────────────265+ async function checkRegistration() {266+ try {267+ const reg = await invoke('host_registration_status');268+ const badge = document.getElementById('reg-status');269+ if (reg.registered) {270+ badge.className = 'status-badge status-ok';271+ badge.textContent = 'Registered';272+ document.getElementById('host-id').textContent =273+ reg.node_id.substring(0, 16) + '...';274+ } else if (reg.error) {275+ badge.className = 'status-badge status-err';276+ badge.textContent = 'Error';277+ } else {278+ badge.className = 'status-badge status-pending';279+ badge.textContent = 'Not registered';280+ }281+ } catch (e) {282+ console.error('registration check failed:', e);283+ }284+ }285+286+ async function registerKey() {259287 const pin = document.getElementById('pin-input').value.trim();260288 if (!pin) {261289 alert('Enter Titan PIN first');262290 return;263291 }264292 293+ const badge = document.getElementById('reg-status');294+ badge.className = 'status-badge status-pending';295+ badge.innerHTML = '<span class="spinner"></span> Tap Titan...';296+ try {297+ const result = await invoke('titan_register_host', { pin });298+ if (result.registered) {299+ badge.className = 'status-badge status-ok';300+ badge.textContent = 'Registered';301+ document.getElementById('host-id').textContent =302+ result.node_id.substring(0, 16) + '...';303+ } else {304+ badge.className = 'status-badge status-err';305+ badge.textContent = 'Failed';306+ }307+ } catch (e) {308+ badge.className = 'status-badge status-err';309+ badge.textContent = 'Error: ' + e;310+ }311+ }312+313+ async function unregister() {314+ if (!confirm('Remove registered identity from keyring?')) return;315+ try {316+ await invoke('host_unregister');317+ const badge = document.getElementById('reg-status');318+ badge.className = 'status-badge status-pending';319+ badge.textContent = 'Not registered';320+ document.getElementById('host-id').textContent = '—';321+ } catch (e) {322+ alert('Unregister failed: ' + e);323+ }324+ }325+326+ // ─── Host ──────────────────────────────────────────────────────────327+ async function startHost() {265328 const status = document.getElementById('host-status');266329 status.className = 'status-badge status-pending';267- status.innerHTML = '<span class="spinner"></span> Tap Titan...';330+ status.innerHTML = '<span class="spinner"></span> Starting...';268331 try {269- const result = await invoke('iroh_host_start', { pin });332+ const result = await invoke('iroh_host_start');270333 if (result.running) {271334 status.className = 'status-badge status-ok';272335 status.textContent = 'Hosting';
@@ -476,6 +539,9 @@
476539 sendInput({ t: 'tx', s: e.key });477540 }478541 });542+543+ // ─── Init ──────────────────────────────────────────────────────────544+ checkRegistration();479545 </script>480546 </body>481547 </html>