cat / goq
clone

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

docs(compat): freeze identity and appliance identifiers

Dami committed 0218797
commit
021879768348974e4424fed4f9d3ea7a10a1ed44
parent
bb4cb785c453c30ae9245131ae9d154a9d0c0a34
author
Dami <dami.etoile@gmail.com>
7 files changed, +145 -28
MAGENTS.md3+
MREADME.md4+-
Mcrates/sigil-host/src/appliance.rs10+++-
Mcrates/sigil-host/src/input.rs23+++++++---
Adocs/compatibility-identifiers.md61++++++++++++++++++++++++++
Msrc-tauri/src/commands/auth.rs70+++++++++++++++++++++++-------
Msrc-tauri/src/commands/state.rs2-
M AGENTS.md +3 -0
M README.md +3 -1
M crates/sigil-host/src/appliance.rs +7 -3
@@ -18,6 +18,9 @@ use crate::server::SessionRegistry;
1818 const LIFECYCLE_LOCK_FILE: &str = "daemon-v1.lock";1919 const GLOBAL_LIFECYCLE_LOCK_FILE: &str = "daemon-global-v1.lock";2020 const RUNTIME_STATUS_FILE: &str = "daemon-status-v1.json";21+// Frozen local-management ABI consumed by Sigil commands and the Decky22+// plugin. A successor needs an explicit migration; never rename this in place.23+const RUNTIME_DIRECTORY_COMPONENT: &str = "sigil-spark";2124 const RUNTIME_STATUS_VERSION: u16 = 2;2225 const MAX_RUNTIME_STATUS_BYTES: u64 = 16 * 1024;2326 const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
@@ -467,7 +470,7 @@ fn configured_runtime_directory() -> Result<Option<PathBuf>> {
467470 ensure!(root.is_absolute(), "XDG_RUNTIME_DIR must be absolute");468471 secure_state::validate_private_directory(&root)469472 .context("validating XDG_RUNTIME_DIR for Sigil appliance status")?;470- Ok(Some(root.join("sigil-spark")))473+ Ok(Some(root.join(RUNTIME_DIRECTORY_COMPONENT)))471474 }472475 473476 pub(crate) fn resolve_management_runtime_directory(
@@ -485,7 +488,7 @@ pub(crate) fn resolve_management_runtime_directory(
485488 "--runtime-dir must have mode 0700"486489 );487490 }488- return Ok(root.join("sigil-spark"));491+ return Ok(root.join(RUNTIME_DIRECTORY_COMPONENT));489492 }490493 configured_runtime_directory()?491494 .context("configured Sigil service requires a valid XDG_RUNTIME_DIR")
@@ -1021,10 +1024,11 @@ mod tests {
10211024 fn explicit_management_runtime_root_is_strict_and_appends_one_child() {10221025 use std::os::unix::fs::{PermissionsExt as _, symlink};10231026 1027+ assert_eq!(RUNTIME_DIRECTORY_COMPONENT, "sigil-spark");10241028 let root = private_directory();10251029 assert_eq!(10261030 resolve_management_runtime_directory(Some(root.path())).unwrap(),1027- root.path().join("sigil-spark")1031+ root.path().join(RUNTIME_DIRECTORY_COMPONENT)10281032 );10291033 assert!(resolve_management_runtime_directory(Some(Path::new("relative"))).is_err());10301034
M crates/sigil-host/src/input.rs +17 -6
@@ -116,9 +116,16 @@ fn validate_single_user_access_acl(
116116 Ok(())117117 }118118 119+#[cfg(any(target_os = "linux", test))]120+// This Linux input identity tuple is an external ABI consumed by Gamescope,121+// udev/libinput rules, and hardware evidence. Preserve it exactly; see122+// docs/compatibility-identifiers.md.123+const UINPUT_BUS_TYPE: u16 = 0x06; // Linux BUS_VIRTUAL119124 #[cfg(any(target_os = "linux", test))]120125 const UINPUT_VENDOR_ID: u16 = 0x5347;121126 #[cfg(any(target_os = "linux", test))]127+const UINPUT_DEVICE_VERSION: u16 = 1;128+#[cfg(any(target_os = "linux", test))]122129 const POINTER_PRODUCT_ID: u16 = 1;123130 #[cfg(any(target_os = "linux", test))]124131 const GAMEPAD_PRODUCT_ID: u16 = 2;
@@ -755,10 +762,10 @@ mod linux {
755762 handle756763 .create(757764 &InputId {758- bustype: input_linux::sys::BUS_VIRTUAL,765+ bustype: UINPUT_BUS_TYPE,759766 vendor: UINPUT_VENDOR_ID,760767 product: POINTER_PRODUCT_ID,761- version: 1,768+ version: UINPUT_DEVICE_VERSION,762769 },763770 POINTER_DEVICE_NAME,764771 0,
@@ -929,10 +936,10 @@ mod linux {
929936 handle930937 .create(931938 &InputId {932- bustype: input_linux::sys::BUS_VIRTUAL,939+ bustype: UINPUT_BUS_TYPE,933940 vendor: UINPUT_VENDOR_ID,934941 product: KEYBOARD_PRODUCT_ID,935- version: 1,942+ version: UINPUT_DEVICE_VERSION,936943 },937944 KEYBOARD_DEVICE_NAME,938945 0,
@@ -1086,10 +1093,10 @@ mod linux {
10861093 handle10871094 .create(10881095 &InputId {1089- bustype: input_linux::sys::BUS_VIRTUAL,1096+ bustype: UINPUT_BUS_TYPE,10901097 vendor: UINPUT_VENDOR_ID,10911098 product: GAMEPAD_PRODUCT_ID,1092- version: 1,1099+ version: UINPUT_DEVICE_VERSION,10931100 },10941101 GAMEPAD_DEVICE_NAME,10951102 0,
@@ -1509,7 +1516,11 @@ mod tests {
15091516 15101517 #[test]15111518 fn virtual_input_topology_has_stable_distinct_identities() {1519+ assert_eq!(UINPUT_BUS_TYPE, 0x06);1520+ #[cfg(target_os = "linux")]1521+ assert_eq!(UINPUT_BUS_TYPE, input_linux::sys::BUS_VIRTUAL);15121522 assert_eq!(UINPUT_VENDOR_ID, 0x5347);1523+ assert_eq!(UINPUT_DEVICE_VERSION, 1);15131524 assert_eq!(POINTER_DEVICE_NAME, b"Sigil Spark Virtual Pointer");15141525 assert_eq!(KEYBOARD_DEVICE_NAME, b"Sigil Spark Virtual Keyboard");15151526 assert_eq!(GAMEPAD_DEVICE_NAME, b"Sigil Spark Virtual Gamepad");
A docs/compatibility-identifiers.md +61 -0
@@ -0,0 +1,61 @@
1+# Frozen compatibility identifiers2+3+goq is the project and public site, Sigil is the Linux host, and Portal is the4+client. Some earlier `sigil` and `sigil-spark` values are nevertheless5+permanent compatibility identifiers. They are not display branding and must6+not be changed as part of a product rename or cleanup.7+8+## Portal enrollment identity9+10+Portal derives one stable Iroh endpoint identity from the FIDO211+`hmac-secret`. Existing Sigil enrollment grants are bound to that endpoint.12+Changing any derivation input silently produces a different endpoint and13+strands the enrollment.14+15+| Identifier | Frozen value |16+| --- | --- |17+| FIDO relying-party ID | `sigil` |18+| HMAC salt message | `sigil-iroh-identity-v1` |19+| Salt SHA-256 | `147ec51fc95a4add3b73f9f366b157fb5dec706a12173151062728dbad1584aa` |20+| Resident user ID | `sigil-user` |21+| Resident user name | `sigil` |22+| Resident user display name | `Sigil` |23+| Portal identity domain bytes | `goq/portal-client-identity/v1\0` |24+25+The fixed non-secret test input consisting of 32 `0x2a` bytes derives the26+secret-key seed27+`78ccce6e04070fff0ac5f74cde6d948ea48042a66f45a8c58a96eb459ca10dd9`28+and Iroh endpoint ID29+`0383aa3774fe624d7a3bc9189c64770e077c43db7315dde1df19085538adc136`.30+Portal has a golden test for the complete derivation. This vector is test data,31+not a deployed credential.32+33+## Sigil local-management runtime34+35+Sigil appends the frozen child `sigil-spark` to the validated36+`XDG_RUNTIME_DIR` or explicit management runtime root. The daemon lifecycle37+lock and status document live below that child, and the Decky plugin and38+offline management commands consume the same path.39+40+## Linux virtual-input ABI41+42+All three virtual devices use Linux `BUS_VIRTUAL`, vendor `0x5347`, and device43+version `1`.44+45+| Device | Product | Name |46+| --- | ---: | --- |47+| Pointer | `1` | `Sigil Spark Virtual Pointer` |48+| Gamepad | `2` | `Sigil Spark Virtual Gamepad` |49+| Keyboard | `3` | `Sigil Spark Virtual Keyboard` |50+51+These values are visible to Gamescope, udev, libinput, games, diagnostics, and52+hardware evidence. Host tests pin the complete tuple.53+54+## Change policy55+56+Never silently edit a frozen value or accept an alternate value at runtime.57+Any intentional successor requires a separately versioned identifier and an58+explicit migration design covering existing FIDO credentials, Sigil59+enrollments, local-management consumers, udev/libinput rules, and rollback.60+The migration must preserve a clear failure mode and must be proven with an61+already-enrolled Portal and an upgraded host before release.
M src-tauri/src/commands/auth.rs +54 -16
@@ -1,4 +1,3 @@
1-use super::state::{RPID, SALT_MESSAGE};21 use anyhow::Context as _;32 use ctap_hid_fido2::fidokey::{43 GetAssertionArgsBuilder, MakeCredentialArgsBuilder, get_assertion::Extension as Gext,
@@ -11,27 +10,33 @@ use serde::Serialize;
1110 use sha2::{Digest, Sha256};1211 use std::time::Duration;1312 13+// These values are enrollment compatibility identifiers, not display14+// branding. Changing any of them in place would derive a different Portal15+// identity or create a second resident credential for existing users. See16+// docs/compatibility-identifiers.md before introducing a versioned successor.17+const FIDO_RP_ID: &str = "sigil";18+const FIDO_HMAC_SALT_MESSAGE: &str = "sigil-iroh-identity-v1";19+const FIDO_RESIDENT_USER_ID: &[u8] = b"sigil-user";20+const FIDO_RESIDENT_USER_NAME: &str = "sigil";21+const FIDO_RESIDENT_USER_DISPLAY_NAME: &str = "Sigil";1422 const PORTAL_CLIENT_IDENTITY_DOMAIN: &[u8] = b"goq/portal-client-identity/v1\0";1523 1624 // ─── FIDO2 HMAC-Secret Derivation ────────────────────────────────────────────1725 26+fn fido_hmac_salt() -> [u8; 32] {27+ Sha256::digest(FIDO_HMAC_SALT_MESSAGE.as_bytes()).into()28+}29+1830 pub fn derive_secret_from_key(pin: &str) -> anyhow::Result<[u8; 32]> {1931 let cfg = Cfg::init();2032 let device = FidoKeyHidFactory::create(&cfg)2133 .context("Security key not found. Make sure it is plugged in.")?;2234 23- let salt: [u8; 32] = {24- let mut hasher = Sha256::new();25- hasher.update(SALT_MESSAGE.as_bytes());26- let result = hasher.finalize();27- let mut s = [0u8; 32];28- s.copy_from_slice(&result);29- s30- };35+ let salt = fido_hmac_salt();3136 3237 // Try resident key first3338 let challenge = verifier::create_challenge();34- let get_args = GetAssertionArgsBuilder::new(RPID, &challenge)39+ let get_args = GetAssertionArgsBuilder::new(FIDO_RP_ID, &challenge)3540 .pin(pin)3641 .extensions(&[Gext::HmacSecret(Some(salt))])3742 .build();
@@ -41,11 +46,14 @@ pub fn derive_secret_from_key(pin: &str) -> anyhow::Result<[u8; 32]> {
4146 }4247 4348 // No resident key — create one44- let user_entity =45- PublicKeyCredentialUserEntity::new(Some(b"sigil-user"), Some("sigil"), Some("Sigil"));49+ let user_entity = PublicKeyCredentialUserEntity::new(50+ Some(FIDO_RESIDENT_USER_ID),51+ Some(FIDO_RESIDENT_USER_NAME),52+ Some(FIDO_RESIDENT_USER_DISPLAY_NAME),53+ );4654 4755 let challenge = verifier::create_challenge();48- let make_args = MakeCredentialArgsBuilder::new(RPID, &challenge)56+ let make_args = MakeCredentialArgsBuilder::new(FIDO_RP_ID, &challenge)4957 .pin(pin)5058 .user_entity(&user_entity)5159 .resident_key()
@@ -56,14 +64,14 @@ pub fn derive_secret_from_key(pin: &str) -> anyhow::Result<[u8; 32]> {
5664 .make_credential_with_args(&make_args)5765 .context("make_credential failed")?;5866 59- let verify_result = verifier::verify_attestation(RPID, &challenge, &attestation);67+ let verify_result = verifier::verify_attestation(FIDO_RP_ID, &challenge, &attestation);6068 if !verify_result.is_success {6169 anyhow::bail!("Attestation verification failed");6270 }6371 let credential_id = verify_result.credential_id;6472 6573 let challenge2 = verifier::create_challenge();66- let get_args = GetAssertionArgsBuilder::new(RPID, &challenge2)74+ let get_args = GetAssertionArgsBuilder::new(FIDO_RP_ID, &challenge2)6775 .pin(pin)6876 .credential_id(&credential_id)6977 .extensions(&[Gext::HmacSecret(Some(salt))])
@@ -246,11 +254,41 @@ mod tests {
246254 use super::*;247255 248256 #[test]249- fn portal_identity_derivation_is_stable_and_domain_separated() {257+ fn portal_identity_compatibility_identifiers_are_golden() {258+ assert_eq!(FIDO_RP_ID, "sigil");259+ assert_eq!(FIDO_HMAC_SALT_MESSAGE, "sigil-iroh-identity-v1");260+ assert_eq!(FIDO_RESIDENT_USER_ID, b"sigil-user");261+ assert_eq!(FIDO_RESIDENT_USER_NAME, "sigil");262+ assert_eq!(FIDO_RESIDENT_USER_DISPLAY_NAME, "Sigil");263+ assert_eq!(264+ fido_hmac_salt(),265+ [266+ 0x14, 0x7e, 0xc5, 0x1f, 0xc9, 0x5a, 0x4a, 0xdd, 0x3b, 0x73, 0xf9, 0xf3, 0x66, 0xb1,267+ 0x57, 0xfb, 0x5d, 0xec, 0x70, 0x6a, 0x12, 0x17, 0x31, 0x51, 0x06, 0x27, 0x28, 0xdb,268+ 0xad, 0x15, 0x84, 0xaa,269+ ]270+ );271+ assert_eq!(272+ PORTAL_CLIENT_IDENTITY_DOMAIN,273+ b"goq/portal-client-identity/v1\0"274+ );275+250276 let root = [0x2a; 32];251277 let first = portal_client_secret_from_hmac(root);252278 let second = portal_client_secret_from_hmac(root);253279 assert_eq!(first.to_bytes(), second.to_bytes());280+ assert_eq!(281+ first.to_bytes(),282+ [283+ 0x78, 0xcc, 0xce, 0x6e, 0x04, 0x07, 0x0f, 0xff, 0x0a, 0xc5, 0xf7, 0x4c, 0xde, 0x6d,284+ 0x94, 0x8e, 0xa4, 0x80, 0x42, 0xa6, 0x6f, 0x45, 0xa8, 0xc5, 0x8a, 0x96, 0xeb, 0x45,285+ 0x9c, 0xa1, 0x0d, 0xd9,286+ ]287+ );288+ assert_eq!(289+ first.public().to_string(),290+ "0383aa3774fe624d7a3bc9189c64770e077c43db7315dde1df19085538adc136"291+ );254292 assert_ne!(first.to_bytes(), root);255293 assert_ne!(256294 first.to_bytes(),
M src-tauri/src/commands/state.rs +0 -2