cat / goq
clone

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

Merge pull request #101 from FelineStateMachine/feat/dev-auth-bypass

FelineStateMachine committed 4f77397
Add a bounded configured-host development auth bypass
commit
4f77397944a19a8f3853296095a173126b368a4d
parent
97a6bdb05c1a6c0b879a1da6e217dc9bc6c6265a
parent
26e145fcaa2af4911f0f56c62978a154a5d8907d
author
FelineStateMachine <dami.etoile@gmail.com>
15 files changed, +320 -57
M.github/workflows/sigil-hardware-uat.yml3+-
Mcrates/sigil-host/Cargo.toml3+
Mcrates/sigil-host/src/authorization.rs25++++-
Mcrates/sigil-host/src/bin/sigil-probe.rs106+++++++++++++++-----
Mcrates/sigil-host/src/main.rs155+++++++++++++++++++++++++++++-
Mdocs/fresh-bazzite-host.md16+++
Mscripts/install-bazzite-package.sh3+-
Mscripts/package-bazzite-release.sh6+-
Mscripts/run-bazzite-hardware-uat.sh3+-
Mscripts/tests/bazzite-package-install-e2e.sh3+-
Mscripts/tests/package-bazzite-product-contract.sh2+
Mscripts/tests/sigil-appliance-status.sh33++++---
Mscripts/tests/sigil-release-verifier.sh8+-
Mscripts/verify-demo-build.sh8++
Mscripts/verify-sigil-release.sh3+-
M .github/workflows/sigil-hardware-uat.yml +2 -1
M crates/sigil-host/Cargo.toml +3 -0
M crates/sigil-host/src/authorization.rs +23 -2
@@ -32,6 +32,9 @@ pub enum AuthorizationPolicy {
3232 /// Explicit direct `sigil serve --identity ... --source test-pattern`3333 /// proof mode. Configured hosts never select this branch.3434 TestPatternProof,35+ /// Explicitly insecure configured-host mode for development hardware tests.36+ /// Ordinary release builds cannot select this branch.37+ DevelopmentBypass,3538 }3639 3740 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -600,10 +603,10 @@ impl AuthorizationPolicy {
600603 ) -> Result<InvitationGrants> {601604 match self {602605 Self::Required(store) => store.authorize_or_redeem(remote, invitation_token, now),603- Self::TestPatternProof => {606+ Self::TestPatternProof | Self::DevelopmentBypass => {604607 ensure!(605608 invitation_token.is_none(),606- "proof mode does not accept invitations"609+ "unrestricted development modes do not accept invitations"607610 );608611 Ok(InvitationGrants::ALL)609612 }
@@ -868,6 +871,24 @@ mod tests {
868871 assert!(snapshot.peer.is_none());869872 }870873 874+ #[test]875+ fn development_bypass_grants_all_without_touching_persistent_state() {876+ let directory = tempfile::tempdir().unwrap();877+ let remote = SecretKey::from_bytes(&[29; 32]).public();878+ let policy = AuthorizationPolicy::DevelopmentBypass;879+880+ assert_eq!(881+ policy.authorize_or_redeem(remote, None, 1_000).unwrap(),882+ InvitationGrants::ALL883+ );884+ assert!(885+ policy886+ .authorize_or_redeem(remote, Some("not-an-invitation"), 1_000)887+ .is_err()888+ );889+ assert!(fs::read_dir(directory.path()).unwrap().next().is_none());890+ }891+871892 #[test]872893 fn invitation_clock_boundaries_are_inclusive_and_fail_closed_outside_skew() {873894 let (_directory, host, peer, expiry_store) = store();
M crates/sigil-host/src/bin/sigil-probe.rs +80 -26
@@ -2636,6 +2636,20 @@ fn load_invitation_file(
26362636 expected_host: EndpointId,26372637 expected_peer: EndpointId,26382638 required_grants: InvitationGrants,2639+) -> Result<String> {2640+ let now = SystemTime::now()2641+ .duration_since(UNIX_EPOCH)2642+ .context("system clock is before the Unix epoch")?2643+ .as_secs();2644+ load_invitation_file_at(path, expected_host, expected_peer, required_grants, now)2645+}2646+2647+fn load_invitation_file_at(2648+ path: &Path,2649+ expected_host: EndpointId,2650+ expected_peer: EndpointId,2651+ required_grants: InvitationGrants,2652+ now: u64,26392653 ) -> Result<String> {26402654 let mut options = OpenOptions::new();26412655 options.read(true);
@@ -2713,10 +2727,6 @@ fn load_invitation_file(
27132727 || invitation.claims.grants.contains(InvitationGrants::GAMEPAD),27142728 "invitation must grant pointer/keyboard or gamepad for the probe input acknowledgment"27152729 );2716- let now = SystemTime::now()2717- .duration_since(UNIX_EPOCH)2718- .context("system clock is before the Unix epoch")?2719- .as_secs();27202730 ensure!(27212731 now >= invitation27222732 .claims
@@ -3398,34 +3408,54 @@ mod tests {
33983408 33993409 write_private_invitation(&path, &token, b"\n");34003410 assert_eq!(3401- load_invitation_file(3411+ load_invitation_file_at(34023412 &path,34033413 host.public(),34043414 peer.public(),34053415 InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),3416+ now,34063417 )34073418 .unwrap(),34083419 token34093420 );34103421 write_private_invitation(&path, &token, b"\r\n");34113422 assert!(3412- load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)3413- .is_ok()3423+ load_invitation_file_at(3424+ &path,3425+ host.public(),3426+ peer.public(),3427+ InvitationGrants::VIEW,3428+ now,3429+ )3430+ .is_ok()34143431 );34153432 assert!(3416- load_invitation_file(&path, other.public(), peer.public(), InvitationGrants::VIEW,)3417- .is_err()3433+ load_invitation_file_at(3434+ &path,3435+ other.public(),3436+ peer.public(),3437+ InvitationGrants::VIEW,3438+ now,3439+ )3440+ .is_err()34183441 );34193442 assert!(3420- load_invitation_file(&path, host.public(), other.public(), InvitationGrants::VIEW,)3421- .is_err()3443+ load_invitation_file_at(3444+ &path,3445+ host.public(),3446+ other.public(),3447+ InvitationGrants::VIEW,3448+ now,3449+ )3450+ .is_err()34223451 );34233452 assert!(3424- load_invitation_file(3453+ load_invitation_file_at(34253454 &path,34263455 host.public(),34273456 peer.public(),34283457 InvitationGrants::POINTER_KEYBOARD,3458+ now,34293459 )34303460 .is_err()34313461 );
@@ -3439,10 +3469,16 @@ mod tests {
34393469 );34403470 write_private_invitation(&path, &view_only, b"\n");34413471 assert!(3442- load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)3443- .unwrap_err()3444- .to_string()3445- .contains("input acknowledgment")3472+ load_invitation_file_at(3473+ &path,3474+ host.public(),3475+ peer.public(),3476+ InvitationGrants::VIEW,3477+ now,3478+ )3479+ .unwrap_err()3480+ .to_string()3481+ .contains("input acknowledgment")34463482 );34473483 34483484 let expired = test_invitation(
@@ -3454,15 +3490,27 @@ mod tests {
34543490 );34553491 write_private_invitation(&path, &expired, b"\n");34563492 assert!(3457- load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)3458- .unwrap_err()3459- .to_string()3460- .contains("expired")3493+ load_invitation_file_at(3494+ &path,3495+ host.public(),3496+ peer.public(),3497+ InvitationGrants::VIEW,3498+ now,3499+ )3500+ .unwrap_err()3501+ .to_string()3502+ .contains("expired")34613503 );34623504 write_private_invitation(&path, &token, b"\n\n");34633505 assert!(3464- load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)3465- .is_err()3506+ load_invitation_file_at(3507+ &path,3508+ host.public(),3509+ peer.public(),3510+ InvitationGrants::VIEW,3511+ now,3512+ )3513+ .is_err()34663514 );34673515 let future = test_invitation(34683516 &host,
@@ -3473,10 +3521,16 @@ mod tests {
34733521 );34743522 write_private_invitation(&path, &future, b"\n");34753523 assert!(3476- load_invitation_file(&path, host.public(), peer.public(), InvitationGrants::VIEW,)3477- .unwrap_err()3478- .to_string()3479- .contains("future")3524+ load_invitation_file_at(3525+ &path,3526+ host.public(),3527+ peer.public(),3528+ InvitationGrants::VIEW,3529+ now,3530+ )3531+ .unwrap_err()3532+ .to_string()3533+ .contains("future")34803534 );34813535 }34823536
M crates/sigil-host/src/main.rs +149 -6
@@ -286,6 +286,9 @@ struct ServeArgs {
286286 /// Strict TOML host configuration.287287 #[arg(long, conflicts_with_all = ["identity", "source", "state_path"])]288288 config: Option<PathBuf>,289+ /// INSECURE: admit an un-enrolled Portal peer for configured-host testing.290+ #[arg(long, requires = "config", conflicts_with = "identity")]291+ dev_allow_unauthorized: bool,289292 /// Host identity for direct proof-mode configuration.290293 #[arg(long, requires = "source")]291294 identity: Option<PathBuf>,
@@ -308,6 +311,8 @@ struct ServeArgs {
308311 max_runtime_seconds: Option<u64>,309312 }310313 314+const MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS: u64 = 3_600;315+311316 enum CliFailure {312317 General(anyhow::Error),313318 Management(config_management::ManagementError),
@@ -786,6 +791,7 @@ fn validate_capture_probe_args(args: &CaptureProbeArgs, configured_framerate: u3
786791 }787792 788793 async fn serve_command(args: ServeArgs) -> Result<()> {794+ validate_development_auth_bypass(args.dev_allow_unauthorized, args.max_runtime_seconds)?;789795 let configured_service = args.config.is_some();790796 let (config, loaded_config_revision, _lifecycle) = if let Some(config_path) = &args.config {791797 let (loaded, lifecycle) =
@@ -806,12 +812,21 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
806812 configured_service,807813 loaded_config_revision,808814 )?;809- let authorization_result = if configured_service {810- AuthorizationStore::open(config.state_path.clone(), secret.public())811- .map(AuthorizationPolicy::Required)812- } else {813- Ok(AuthorizationPolicy::TestPatternProof)814- };815+ let authorization_result = select_authorization_policy(816+ configured_service,817+ args.dev_allow_unauthorized,818+ &config.state_path,819+ secret.public(),820+ );821+ if args.dev_allow_unauthorized {822+ warn!(823+ max_runtime_seconds = args.max_runtime_seconds,824+ "DEVELOPMENT AUTHORIZATION BYPASS ACTIVE: any peer with this host node ID may request a session"825+ );826+ eprintln!(827+ "warning=development-authorization-bypass-active; do not expose or ship this host"828+ );829+ }815830 let authorization = match authorization_result {816831 Ok(authorization) => authorization,817832 Err(error) => {
@@ -1046,6 +1061,45 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10461061 Ok(())10471062 }10481063 1064+const fn development_auth_bypass_available() -> bool {1065+ cfg!(feature = "demo-auth-bypass")1066+}1067+1068+fn validate_development_auth_bypass(1069+ requested: bool,1070+ max_runtime_seconds: Option<u64>,1071+) -> Result<()> {1072+ ensure!(1073+ !requested || development_auth_bypass_available(),1074+ "--dev-allow-unauthorized requires the explicit demo-auth-bypass feature; ordinary builds always require enrollment"1075+ );1076+ if requested {1077+ ensure!(1078+ matches!(1079+ max_runtime_seconds,1080+ Some(1..=MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS)1081+ ),1082+ "--dev-allow-unauthorized requires --max-runtime-seconds between 1 and {MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS}"1083+ );1084+ }1085+ Ok(())1086+}1087+1088+fn select_authorization_policy(1089+ configured_service: bool,1090+ development_bypass: bool,1091+ state_path: &Path,1092+ host: EndpointId,1093+) -> Result<AuthorizationPolicy> {1094+ if development_bypass {1095+ Ok(AuthorizationPolicy::DevelopmentBypass)1096+ } else if configured_service {1097+ AuthorizationStore::open(state_path.to_owned(), host).map(AuthorizationPolicy::Required)1098+ } else {1099+ Ok(AuthorizationPolicy::TestPatternProof)1100+ }1101+}1102+10491103 async fn wait_for_shutdown_signal() -> Result<&'static str> {10501104 #[cfg(unix)]10511105 {
@@ -1127,6 +1181,7 @@ fn parse_size(value: &str) -> std::result::Result<(u32, u32), String> {
11271181 #[cfg(test)]11281182 mod tests {11291183 use super::*;1184+ use std::fs;11301185 #[cfg(unix)]11311186 use std::os::unix::fs::PermissionsExt;11321187
@@ -1168,6 +1223,94 @@ mod tests {
11681223 );11691224 }11701225 1226+ #[test]1227+ fn development_auth_bypass_requires_configured_host_syntax() {1228+ assert!(1229+ Cli::try_parse_from([1230+ "sigil",1231+ "serve",1232+ "--config",1233+ "/tmp/host.toml",1234+ "--dev-allow-unauthorized",1235+ "--max-runtime-seconds",1236+ "600",1237+ ])1238+ .is_ok()1239+ );1240+ assert!(1241+ Cli::try_parse_from([1242+ "sigil",1243+ "serve",1244+ "--identity",1245+ "/tmp/host.key",1246+ "--source",1247+ "test-pattern",1248+ "--dev-allow-unauthorized",1249+ "--max-runtime-seconds",1250+ "600",1251+ ])1252+ .is_err()1253+ );1254+ assert!(1255+ Cli::try_parse_from([1256+ "sigil",1257+ "serve",1258+ "--config",1259+ "/tmp/host.toml",1260+ "--dev-allow-unauthorized",1261+ ])1262+ .is_ok()1263+ );1264+ assert!(validate_development_auth_bypass(true, None).is_err());1265+ assert!(validate_development_auth_bypass(true, Some(0)).is_err());1266+ assert!(1267+ validate_development_auth_bypass(true, Some(MAX_DEVELOPMENT_AUTH_BYPASS_SECONDS + 1))1268+ .is_err()1269+ );1270+ }1271+1272+ #[test]1273+ fn ordinary_release_excludes_configured_host_auth_bypass() {1274+ if cfg!(not(feature = "demo-auth-bypass")) {1275+ assert!(!development_auth_bypass_available());1276+ assert!(validate_development_auth_bypass(true, Some(60)).is_err());1277+ }1278+ }1279+1280+ #[test]1281+ fn configured_host_auth_bypass_is_explicitly_build_contained() {1282+ assert_eq!(1283+ validate_development_auth_bypass(true, Some(60)).is_ok(),1284+ development_auth_bypass_available()1285+ );1286+ }1287+1288+ #[test]1289+ fn development_auth_bypass_does_not_open_persistent_authorization_state() {1290+ let directory = tempfile::tempdir().unwrap();1291+ let state_path = directory.path();1292+ let state_file = state_path.join("authorization-v1.json");1293+ fs::write(&state_file, b"malformed-sentinel").unwrap();1294+ #[cfg(unix)]1295+ fs::set_permissions(&state_file, fs::Permissions::from_mode(0o600)).unwrap();1296+ let before = fs::read(&state_file).unwrap();1297+ let host = iroh::SecretKey::from_bytes(&[31; 32]).public();1298+1299+ assert!(matches!(1300+ select_authorization_policy(true, true, state_path, host).unwrap(),1301+ AuthorizationPolicy::DevelopmentBypass1302+ ));1303+ assert_eq!(fs::read(&state_file).unwrap(), before);1304+ assert!(!state_path.join("authorization-v1.lock").exists());1305+1306+ let required_directory = tempfile::tempdir().unwrap();1307+ let required_state = required_directory.path().join("authorization-v1.json");1308+ fs::write(&required_state, b"malformed-sentinel").unwrap();1309+ #[cfg(unix)]1310+ fs::set_permissions(&required_state, fs::Permissions::from_mode(0o600)).unwrap();1311+ assert!(select_authorization_policy(true, false, required_directory.path(), host).is_err());1312+ }1313+11711314 #[test]11721315 fn invitation_and_enrollment_commands_parse_strictly() {11731316 let peer = iroh::SecretKey::from_bytes(&[19; 32]).public().to_string();
M docs/fresh-bazzite-host.md +16 -0
M scripts/install-bazzite-package.sh +2 -1
M scripts/package-bazzite-release.sh +4 -2
M scripts/run-bazzite-hardware-uat.sh +2 -1
M scripts/tests/bazzite-package-install-e2e.sh +2 -1
M scripts/tests/package-bazzite-product-contract.sh +2 -0
M scripts/tests/sigil-appliance-status.sh +19 -14
@@ -48,8 +48,7 @@ config_path="$config_directory/host.toml"
4848 unsafe_config_path="$unsafe_config_directory/host.toml"4949 gamescope_probe_config="$config_directory/gamescope-probe.toml"5050 fake_pw_dump="$temp_root/fake-pw-dump"51-fake_gst_inspect="$temp_root/fake-gst-inspect"52-gamescope_preflight_marker="$temp_root/gamescope-preflight-ran"51+fake_render_node="$temp_root/renderD999"5352 host_log="$temp_root/host.log"5453 second_log="$temp_root/second.log"5554 live_status="$temp_root/live-status.json"
@@ -94,12 +93,6 @@ printf '%s\n' \
9493 "printf '[]\\n'" \9594 >"$fake_pw_dump"9695 chmod 0700 "$fake_pw_dump"97-printf '%s\n' \98- '#!/usr/bin/env bash' \99- "touch '$gamescope_preflight_marker'" \100- 'exit 1' \101- >"$fake_gst_inspect"102-chmod 0700 "$fake_gst_inspect"10396 false_path="/usr/bin/false"10497 [[ -x "$false_path" ]]10598 printf '%s\n' \
@@ -119,9 +112,9 @@ printf '%s\n' \
119112 'xwayland_display = ":0"' \120113 "pw_dump_path = \"$fake_pw_dump\"" \121114 "gst_launch_path = \"$false_path\"" \122- "gst_inspect_path = \"$fake_gst_inspect\"" \115+ "gst_inspect_path = \"$false_path\"" \123116 'vaapi_encoder = "vah264enc"' \124- 'vaapi_render_node = "/dev/dri/renderD128"' \117+ "vaapi_render_node = \"$fake_render_node\"" \125118 'rate_control = "cqp"' \126119 'quantizer = 24' \127120 >"$gamescope_probe_config"
@@ -154,7 +147,6 @@ PY
154147 XDG_RUNTIME_DIR="$runtime_directory" \155148 "$sigil" serve \156149 --config "$config_path" \157- --max-runtime-seconds 4 \158150 >"$host_log" 2>&1 &159151 host_pid=$!160152
@@ -239,7 +231,10 @@ if XDG_RUNTIME_DIR="$runtime_directory" \
239231 fi240232 grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \241233 "$temp_root/contended-capture.error"242-[[ ! -e "$gamescope_preflight_marker" ]]234+if grep -Fq 'inspecting VAAPI render node' "$temp_root/contended-capture.error"; then235+ printf 'contended capture reached hardware preflight before acquiring the lifecycle lock\n' >&2236+ exit 1237+fi243238 244239 python3 - "$live_status" "$host_node_id" <<'PY'245240 import json
@@ -308,7 +303,16 @@ fi
308303 grep -Fq 'another Sigil daemon or capture probe already owns this lifecycle scope' \309304 "$temp_root/live-reset.log"310305 311-wait "$host_pid"306+if ! kill -TERM "$host_pid" 2>/dev/null; then307+ printf 'Sigil exited before the appliance status proof requested shutdown\n' >&2308+ sed -n '1,160p' "$host_log" >&2309+ exit 1310+fi311+if ! wait "$host_pid"; then312+ printf 'Sigil did not shut down cleanly during the appliance status proof\n' >&2313+ sed -n '1,160p' "$host_log" >&2314+ exit 1315+fi312316 host_pid=''313317 if XDG_RUNTIME_DIR="$runtime_directory" \314318 "$sigil" capture probe \
@@ -320,7 +324,8 @@ if XDG_RUNTIME_DIR="$runtime_directory" \
320324 printf 'fake Gamescope capture unexpectedly completed\n' >&2321325 exit 1322326 fi323-[[ -e "$gamescope_preflight_marker" ]]327+grep -Fq "inspecting VAAPI render node $fake_render_node" \328+ "$temp_root/released-capture.error"324329 XDG_RUNTIME_DIR="$runtime_directory" \325330 "$sigil" serve \326331 --identity "$identity_path" \
M scripts/tests/sigil-release-verifier.sh +6 -2
@@ -49,7 +49,8 @@ manifest = {
4949 "version": tag[1:],5050 "target": "x86_64-unknown-linux-gnu.2.17",5151 "profile": "release",52- "features": ["default", "in-process-gstreamer"],52+ "features": ["in-process-gstreamer"],53+ "demo_auth_bypass": False,5354 "demo_direct_node": False,5455 "git_commit": commit,5556 "git_dirty": False,
@@ -64,6 +65,8 @@ manifest = {
6465 }6566 if variant == "default-features":6667 manifest["features"] = ["default"]68+if variant == "demo-auth-bypass":69+ manifest["demo_auth_bypass"] = True6770 binary = b"fixture executable\n"6871 release = {6972 "sigil": binary,
@@ -178,7 +181,7 @@ printf '%064d %s\n' 0 "$(basename -- "$bad_archive")" >"$bad_archive.sha256"
178181 assert_rejected bad-checksum 'checksum declaration does not exactly match' \179182 "$verifier" --tag "$release_tag" --archive "$bad_archive" --candidate180183 181-for variant in symlink duplicate unexpected default-features; do184+for variant in symlink duplicate unexpected default-features demo-auth-bypass; do182185 fixture_dir="$temp_root/$variant"183186 make_fixture "$fixture_dir" "$variant"184187 fixture_archive="$fixture_dir/sigil-$release_tag-bazzite-x86_64.tar.gz"
@@ -187,6 +190,7 @@ for variant in symlink duplicate unexpected default-features; do
187190 duplicate) expected='duplicate members' ;;188191 unexpected) expected='do not match the package allowlist' ;;189192 default-features) expected='field features does not match the product contract' ;;193+ demo-auth-bypass) expected='field demo_auth_bypass does not match the product contract' ;;190194 esac191195 assert_rejected "$variant" "$expected" \192196 "$verifier" --tag "$release_tag" --archive "$fixture_archive" --candidate
M scripts/verify-demo-build.sh +8 -0
M scripts/verify-sigil-release.sh +2 -1