docs(compat): freeze identity and appliance identifiers
- commit
- cbae9bbc741a13e5ebb0807987d2996d70bf32f2
- parent
- 92ca5e5ef25ecd7074b59f16447c0b613fdbd589
- parent
- a3d85e10e5effd87d073dc22a7abadc1c236ab1a
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | AGENTS.md | 9 | +++- |
| M | README.md | 4 | +- |
| M | crates/ | 10 | +++- |
| M | crates/ | 27 | ++++++++--- |
| A | docs/ | 73 | ++++++++++++++++++++++++++++++ |
| A | scripts/ | 69 | ++++++++++++++++++++++++++++ |
| M | scripts/ | 8 | +++ |
| M | src-tauri/ | 70 | ++++++++++++++++++++++------- |
| M | src-tauri/ | 2 | - |
@@ -96,6 +96,9 @@ Sigil host.
9696 escalation must fail closed.9797 - The authorization state and replay ledger are security-sensitive host state;9898 preserve their owner-only permissions and atomic update rules.99+- `docs/compatibility-identifiers.md` freezes Portal's FIDO/Iroh derivation,100+ Sigil's local runtime child, and its Linux virtual-input identity tuple.101+ Never rename one in place or add a silent alternate acceptance path.99102 100103 ## Release and installation boundary101104 @@ -202,7 +205,11 @@ Sigil host.
202205 decoding, A/V, input, or session behavior requires a real Portal -> UMPC203206 Gamescope connection from the exact candidate commit. A successful config204207 check, capture probe, headless probe, or loopback is supporting evidence, not205- a substitute for that end-to-end session. Fresh exact-commit builds are not208+ a substitute for that end-to-end session. Starting a bypass-enabled daemon209+ and observing that it listens is also not an end-to-end test: Portal must210+ connect to that exact daemon, the host must admit the session, live media211+ diagnostics must advance, and the affected interaction or recovery path must212+ be exercised from Portal. Fresh exact-commit builds are not206213 expected to be enrolled. This also applies when authentication or identity207214 code changed: do not skip the connection because ordinary authentication208215 blocks it. For development hardware testing, build Sigil with@@ -78,7 +78,9 @@ Steam and games.
7878 short-lived Sigil invitation enrolls that peer once with exact view,7979 pointer/keyboard, and gamepad grants; ordinary reconnects remain8080 **PIN -> tap -> play**. Debug builds retain a visibly labeled routing bypass,81- while release builds reject it.81+ while release builds reject it. The82+ [identity and appliance compatibility identifiers](docs/compatibility-identifiers.md)83+ are frozen independently from display branding.8284 - Controller-first client navigation includes a D-pad PIN pad, negotiated8385 latest-state gamepad routing, and a one-second Back+Start escape chord.8486 - Portal's proven relative-pointer capture is macOS-only. Default non-macOS@@ -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 @@ -116,9 +116,16 @@ fn validate_single_user_access_acl(
116116 Ok(())117117 }118118 119+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 120125 const UINPUT_VENDOR_ID: u16 = 0x5347;121126 127+const UINPUT_DEVICE_VERSION: u16 = 1;128+122129 const POINTER_PRODUCT_ID: u16 = 1;123130 124131 const GAMEPAD_PRODUCT_ID: u16 = 2;@@ -781,8 +788,8 @@ mod linux {
781788 ALL_MAPPED_KEYS, GAMEPAD_DEVICE_NAME, GAMEPAD_PRODUCT_ID, GamepadAxis, GamepadButton,782789 InputDeviceClass, KEYBOARD_DEVICE_NAME, KEYBOARD_PRODUCT_ID, MappedKey,783790 POINTER_DEVICE_NAME, POINTER_PRODUCT_ID, POSIX_ACL_REQUIRED_BYTES, PointerReportEvent,784- UINPUT_VENDOR_ID, input_device_class, map_gamepad_state, map_key,785- pointer_position_sync_report, validate_single_user_access_acl,791+ UINPUT_BUS_TYPE, UINPUT_DEVICE_VERSION, UINPUT_VENDOR_ID, input_device_class,792+ map_gamepad_state, map_key, pointer_position_sync_report, validate_single_user_access_acl,786793 };787794 use crate::config::UinputConfig;788795 @@ -873,10 +880,10 @@ mod linux {
873880 handle874881 .create(875882 &InputId {876- bustype: input_linux::sys::BUS_VIRTUAL,883+ bustype: UINPUT_BUS_TYPE,877884 vendor: UINPUT_VENDOR_ID,878885 product: POINTER_PRODUCT_ID,879- version: 1,886+ version: UINPUT_DEVICE_VERSION,880887 },881888 POINTER_DEVICE_NAME,882889 0,@@ -1047,10 +1054,10 @@ mod linux {
10471054 handle10481055 .create(10491056 &InputId {1050- bustype: input_linux::sys::BUS_VIRTUAL,1057+ bustype: UINPUT_BUS_TYPE,10511058 vendor: UINPUT_VENDOR_ID,10521059 product: KEYBOARD_PRODUCT_ID,1053- version: 1,1060+ version: UINPUT_DEVICE_VERSION,10541061 },10551062 KEYBOARD_DEVICE_NAME,10561063 0,@@ -1204,10 +1211,10 @@ mod linux {
12041211 handle12051212 .create(12061213 &InputId {1207- bustype: input_linux::sys::BUS_VIRTUAL,1214+ bustype: UINPUT_BUS_TYPE,12081215 vendor: UINPUT_VENDOR_ID,12091216 product: GAMEPAD_PRODUCT_ID,1210- version: 1,1217+ version: UINPUT_DEVICE_VERSION,12111218 },12121219 GAMEPAD_DEVICE_NAME,12131220 0,@@ -1648,7 +1655,11 @@ mod tests {
16481655 16491656 16501657 fn virtual_input_topology_has_stable_distinct_identities() {1658+ assert_eq!(UINPUT_BUS_TYPE, 0x06);1659+ 1660+ assert_eq!(UINPUT_BUS_TYPE, input_linux::sys::BUS_VIRTUAL);16511661 assert_eq!(UINPUT_VENDOR_ID, 0x5347);1662+ assert_eq!(UINPUT_DEVICE_VERSION, 1);16521663 assert_eq!(POINTER_DEVICE_NAME, b"Sigil Spark Virtual Pointer");16531664 assert_eq!(KEYBOARD_DEVICE_NAME, b"Sigil Spark Virtual Keyboard");16541665 assert_eq!(GAMEPAD_DEVICE_NAME, b"Sigil Spark Virtual Gamepad");@@ -0,0 +1,73 @@
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 host filesystem namespace34+35+The following host paths are frozen because the installer, service, rollback36+tooling, Decky plugin, and recovery documentation consume them independently:37+38+| Purpose | Frozen path |39+| --- | --- |40+| Installed releases and current link | `~/.local/libexec/sigil-spark` |41+| Host configuration | `~/.config/sigil-spark/host.toml` |42+| Identity and package-owned shared data | `~/.local/share/sigil-spark` |43+| Authorization and configuration state | `~/.local/state/sigil-spark` |44+| Volatile daemon management runtime | `$XDG_RUNTIME_DIR/sigil-spark` |45+46+For an explicit management runtime root, Sigil also appends the frozen child47+`sigil-spark`. The daemon lifecycle lock and status document live below that48+child. A repository gate keeps the independent Decky, systemd, installer,49+activation, staging, and Rust consumers synchronized.50+51+## Linux virtual-input ABI52+53+All three virtual devices use Linux `BUS_VIRTUAL`, vendor `0x5347`, and device54+version `1`.55+56+| Device | Product | Name |57+| --- | ---: | --- |58+| Pointer | `1` | `Sigil Spark Virtual Pointer` |59+| Gamepad | `2` | `Sigil Spark Virtual Gamepad` |60+| Keyboard | `3` | `Sigil Spark Virtual Keyboard` |61+62+These values are visible to Gamescope, udev, libinput, games, diagnostics, and63+hardware evidence. Host tests pin the complete tuple, and a package test64+requires the shipped udev rule to match its checked-in frozen source exactly.65+66+## Change policy67+68+Never silently edit a frozen value or accept an alternate value at runtime.69+Any intentional successor requires a separately versioned identifier and an70+explicit migration design covering existing FIDO credentials, Sigil71+enrollments, local-management consumers, udev/libinput rules, and rollback.72+The migration must preserve a clear failure mode and must be proven with an73+already-enrolled Portal and an upgraded host before release.@@ -0,0 +1,69 @@
1+2+3+set -euo pipefail4+5+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"6+repo_dir="$(CDPATH='' cd -- "$script_dir/../.." && pwd -P)"7+8+require_exact_line() {9+ local relative_path="$1"10+ local expected="$2"11+ if ! grep -Fqx -- "$expected" "$repo_dir/$relative_path"; then12+ printf 'compatibility identifier drifted in %s: %s\n' \13+ "$relative_path" "$expected" >&214+ exit 115+ fi16+}17+18+# Portal enrollment identity. These source assertions complement the Rust19+# derivation vector by preventing a second call-site literal from drifting.20+require_exact_line src-tauri/src/commands/auth.rs \21+ 'const FIDO_RP_ID: &str = "sigil";'22+require_exact_line src-tauri/src/commands/auth.rs \23+ 'const FIDO_HMAC_SALT_MESSAGE: &str = "sigil-iroh-identity-v1";'24+require_exact_line src-tauri/src/commands/auth.rs \25+ 'const FIDO_RESIDENT_USER_ID: &[u8] = b"sigil-user";'26+require_exact_line src-tauri/src/commands/auth.rs \27+ 'const PORTAL_CLIENT_IDENTITY_DOMAIN: &[u8] = b"goq/portal-client-identity/v1\0";'28+29+# Canonical host install, configuration, persistent data, and volatile runtime30+# namespaces. Decky and the installed service are independent consumers.31+require_exact_line crates/sigil-host/src/appliance.rs \32+ 'const RUNTIME_DIRECTORY_COMPONENT: &str = "sigil-spark";'33+require_exact_line decky/py_modules/goq_sigil/runner.py \34+ ' self.sigil = self.home / ".local/libexec/sigil-spark/current/sigil"'35+require_exact_line decky/py_modules/goq_sigil/runner.py \36+ ' self.config = self.home / ".config/sigil-spark/host.toml"'37+require_exact_line scripts/install-bazzite-package.sh \38+ "install_root=\"\$HOME/.local/libexec/sigil-spark\""39+require_exact_line scripts/sigil-host.service \40+ 'ConditionPathExists=%h/.config/sigil-spark/host.toml'41+require_exact_line scripts/sigil-host.service \42+ 'ExecStart=%h/.local/libexec/sigil-spark/current/sigil-host serve --config %h/.config/sigil-spark/host.toml'43+require_exact_line docs/sigil-host-activation.md \44+ "identity=\"\$HOME/.local/share/sigil-spark/identity/host.key\""45+require_exact_line docs/sigil-host-activation.md \46+ "state_path = \"\$HOME/.local/state/sigil-spark/runtime\""47+require_exact_line scripts/stage-bazzite-release.sh \48+ " \"\$HOME/.local/share/sigil-spark/package-assets/70-sigil-remote-input.rules\""49+50+# Linux virtual-input producer and the packaged udev consumer must remain51+# byte-for-byte synchronized around the names that udev matches.52+require_exact_line crates/sigil-host/src/input.rs \53+ 'const UINPUT_BUS_TYPE: u16 = 0x06; // Linux BUS_VIRTUAL'54+require_exact_line crates/sigil-host/src/input.rs \55+ 'const UINPUT_VENDOR_ID: u16 = 0x5347;'56+require_exact_line crates/sigil-host/src/input.rs \57+ 'const UINPUT_DEVICE_VERSION: u16 = 1;'58+require_exact_line crates/sigil-host/src/input.rs \59+ 'const POINTER_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Pointer";'60+require_exact_line crates/sigil-host/src/input.rs \61+ 'const KEYBOARD_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Keyboard";'62+require_exact_line crates/sigil-host/src/input.rs \63+ 'const GAMEPAD_DEVICE_NAME: &[u8] = b"Sigil Spark Virtual Gamepad";'64+require_exact_line scripts/70-sigil-remote-input.rules \65+ 'ACTION=="add|change", SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="Sigil Spark Virtual Pointer", GROUP="sigil-uinput", MODE="0660", TAG+="uaccess"'66+require_exact_line scripts/70-sigil-remote-input.rules \67+ 'ACTION=="add|change", SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="Sigil Spark Virtual Keyboard", GROUP="sigil-uinput", MODE="0660", TAG+="uaccess"'68+69+printf 'compatibility_identifier_tests=ok\n'@@ -72,6 +72,14 @@ archive="$temp_root/output/sigil-v0.1.0-linux-glibc2.17-x86_64.tar.gz"
7272 PATH="$temp_root/bin:$PATH" "$fixture_repo/scripts/package-bazzite-release.sh" \7373 --release-tag v0.1.0 --output "$archive" >"$temp_root/product.log"7474 grep -Fq 'publisher_signature=pending-offline' "$temp_root/product.log"75+packaged_input_rules="$temp_root/output/70-sigil-remote-input.rules"76+tar -xOzf "$archive" payload/release/assets/70-sigil-remote-input.rules \77+ >"$packaged_input_rules"78+cmp -s "$repo_dir/scripts/70-sigil-remote-input.rules" "$packaged_input_rules" \79+ || {80+ printf 'packaged virtual-input rules differ from the frozen source ABI\n' >&281+ exit 182+ }7583 "$repo_dir/scripts/verify-sigil-release.sh" \7684 --tag v0.1.0 --archive "$archive" --source-commit "$source_commit" --candidate \7785 | grep -Fq 'sigil_release_verification=ok'@@ -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))])@@ -315,11 +323,41 @@ mod tests {
315323 }316324 317325 318- fn portal_identity_derivation_is_stable_and_domain_separated() {326+ fn portal_identity_compatibility_identifiers_are_golden() {327+ assert_eq!(FIDO_RP_ID, "sigil");328+ assert_eq!(FIDO_HMAC_SALT_MESSAGE, "sigil-iroh-identity-v1");329+ assert_eq!(FIDO_RESIDENT_USER_ID, b"sigil-user");330+ assert_eq!(FIDO_RESIDENT_USER_NAME, "sigil");331+ assert_eq!(FIDO_RESIDENT_USER_DISPLAY_NAME, "Sigil");332+ assert_eq!(333+ fido_hmac_salt(),334+ [335+ 0x14, 0x7e, 0xc5, 0x1f, 0xc9, 0x5a, 0x4a, 0xdd, 0x3b, 0x73, 0xf9, 0xf3, 0x66, 0xb1,336+ 0x57, 0xfb, 0x5d, 0xec, 0x70, 0x6a, 0x12, 0x17, 0x31, 0x51, 0x06, 0x27, 0x28, 0xdb,337+ 0xad, 0x15, 0x84, 0xaa,338+ ]339+ );340+ assert_eq!(341+ PORTAL_CLIENT_IDENTITY_DOMAIN,342+ b"goq/portal-client-identity/v1\0"343+ );344+319345 let root = [0x2a; 32];320346 let first = portal_client_secret_from_hmac(root);321347 let second = portal_client_secret_from_hmac(root);322348 assert_eq!(first.to_bytes(), second.to_bytes());349+ assert_eq!(350+ first.to_bytes(),351+ [352+ 0x78, 0xcc, 0xce, 0x6e, 0x04, 0x07, 0x0f, 0xff, 0x0a, 0xc5, 0xf7, 0x4c, 0xde, 0x6d,353+ 0x94, 0x8e, 0xa4, 0x80, 0x42, 0xa6, 0x6f, 0x45, 0xa8, 0xc5, 0x8a, 0x96, 0xeb, 0x45,354+ 0x9c, 0xa1, 0x0d, 0xd9,355+ ]356+ );357+ assert_eq!(358+ first.public().to_string(),359+ "0383aa3774fe624d7a3bc9189c64770e077c43db7315dde1df19085538adc136"360+ );323361 assert_ne!(first.to_bytes(), root);324362 assert_ne!(325363 first.to_bytes(),