Add an explicitly insecure configured-host authorization mode for local hardware testing, with debug/feature build containment and automatic expiry. Keep product packages on no-default-features builds and attest that the bypass is absent from release manifests.
- commit
- 9399b667ecc0299771b142ad348325d91725c54f
- parent
- 97a6bdb05c1a6c0b879a1da6e217dc9bc6c6265a
- author
- Dami <dami.etoile@gmail.com>
@@ -113,8 +113,9 @@ jobs:
113113 test "$(jq -r .release_kind "$manifest")" = development114114 test "$(jq -r .release_tag "$manifest")" = development115115 test "$(jq -r .asset_name "$manifest")" = "$(basename -- "$archive")"116+ test "$(jq -r .demo_auth_bypass "$manifest")" = false116117 test "$(jq -r .demo_direct_node "$manifest")" = false117- test "$(jq -c .features "$manifest")" = '["default","in-process-gstreamer"]'118+ test "$(jq -c .features "$manifest")" = '["in-process-gstreamer"]'118119 printf '%s\n' \119120 "repository=$GITHUB_REPOSITORY" \120121 "source_commit=$UAT_COMMIT" \@@ -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 @@ -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+ 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 872893 fn invitation_clock_boundaries_are_inclusive_and_fail_closed_outside_skew() {873894 let (_directory, host, peer, expiry_store) = store();@@ -286,6 +286,9 @@ struct ServeArgs {
286286 /// Strict TOML host configuration.287287 )]288288 config: Option<PathBuf>,289+ /// INSECURE: admit an un-enrolled Portal peer for configured-host testing.290+ 291+ dev_allow_unauthorized: bool,289292 /// Host identity for direct proof-mode configuration.290293 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!(any(debug_assertions, 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 a debug build or the explicit demo-auth-bypass feature; ordinary release 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 10511105 {@@ -1127,6 +1181,7 @@ fn parse_size(value: &str) -> std::result::Result<(u32, u32), String> {
11271181 11281182 mod tests {11291183 use super::*;1184+ use std::fs;11301185 11311186 use std::os::unix::fs::PermissionsExt;11321187 @@ -1168,6 +1223,94 @@ mod tests {
11681223 );11691224 }11701225 1226+ 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+ 1273+ fn ordinary_release_excludes_configured_host_auth_bypass() {1274+ if cfg!(not(debug_assertions)) && 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+ 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+ 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+ 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+ 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 11721315 fn invitation_and_enrollment_commands_parse_strictly() {11731316 let peer = iroh::SecretKey::from_bytes(&[19; 32]).public().to_string();@@ -67,6 +67,16 @@ indicator while the bypass is active. Build the optimized temporary client
6767 with `cargo tauri build --features demo-direct-node`; do not ship that feature6868 in a production package.6969 70+`--dev-connect` alone does not authorize its ephemeral Portal identity. For an71+explicit configured-host hardware test without a security key, run a debug72+Sigil build with `serve --config <host.toml> --dev-allow-unauthorized`, or build73+the optimized test binary with `--features demo-auth-bypass`. This host flag74+admits any peer that knows the node ID, grants all session capabilities, prints75+a startup warning, and deliberately avoids reading or mutating enrollment76+state. It requires `--max-runtime-seconds` between 1 and 3600 so the exposure77+expires automatically. Stop that process immediately after the bounded test.78+Ordinary release builds and shipped Sigil packages reject the flag.79+7080 ### Current security boundary7181 7282 Portal derives a stable client Iroh identity from the security key. Sigil signs@@ -269,7 +269,8 @@ expected = {
269269 "compatibility_executable": "sigil-host",270270 "target": "x86_64-unknown-linux-gnu.2.17",271271 "profile": "release",272- "features": ["default", "in-process-gstreamer"],272+ "features": ["in-process-gstreamer"],273+ "demo_auth_bypass": False,273274 "demo_direct_node": False,274275 }275276 for key, value in expected.items():@@ -203,7 +203,8 @@ else
203203 PKG_CONFIG_ALLOW_CROSS=1 PKG_CONFIG_ALLOW_SYSTEM_LIBS=1 \204204 CARGO_TARGET_DIR="$build_target" \205205 cargo zigbuild --locked -p sigil-host --bins \206- --target "$linux_target" --release --features in-process-gstreamer206+ --target "$linux_target" --release --no-default-features \207+ --features in-process-gstreamer207208 )208209 host_binary="$build_target/$linux_output_target/release/sigil"209210 probe_binary="$build_target/$linux_output_target/release/sigil-probe"@@ -284,7 +285,8 @@ manifest = {
284285 "version": version,285286 "target": target,286287 "profile": "release",287- "features": ["default", "in-process-gstreamer"],288+ "features": ["in-process-gstreamer"],289+ "demo_auth_bypass": False,288290 "demo_direct_node": False,289291 "git_commit": commit,290292 "git_dirty": dirty == "true",@@ -363,7 +363,8 @@ jq -e --arg commit "$uat_commit" '
363363 and .release_kind == "development"364364 and .release_tag == "development"365365 and .target == "x86_64-unknown-linux-gnu.2.17"366- and .features == ["default", "in-process-gstreamer"]366+ and .features == ["in-process-gstreamer"]367+ and .demo_auth_bypass == false367368 and .demo_direct_node == false368369 ' "$manifest" >/dev/null369370 [[ -x "$sigil" && -x "$probe" ]]@@ -76,7 +76,8 @@ manifest = {
7676 "version": tag[1:],7777 "target": "x86_64-unknown-linux-gnu.2.17",7878 "profile": "release",79- "features": ["default", "in-process-gstreamer"],79+ "features": ["in-process-gstreamer"],80+ "demo_auth_bypass": False,8081 "demo_direct_node": False,8182 "git_commit": commit,8283 "git_dirty": False,@@ -46,7 +46,9 @@ printf '%s\n' \
4646 'test "${1:-}" = zigbuild' \4747 'test "${PKG_CONFIG_ALLOW_CROSS:-}" = 1' \4848 'test "${PKG_CONFIG_ALLOW_SYSTEM_LIBS:-}" = 1' \49+ 'case " $* " in *" --no-default-features "*) ;; *) exit 63 ;; esac' \4950 'case " $* " in *" --features in-process-gstreamer "*) ;; *) exit 64 ;; esac' \51+ 'case " $* " in *"demo-auth-bypass"*) exit 65 ;; esac' \5052 'output="$CARGO_TARGET_DIR/x86_64-unknown-linux-gnu/release"' \5153 'install -d -m 0700 "$output"' \5254 'printf "#!/usr/bin/env bash\\nexit 0\\n" >"$output/sigil"' \@@ -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@@ -130,5 +130,13 @@ cargo test --locked -p portal --release --features demo-direct-node \
130130 commands::state::tests::app_state_accepts_direct_node_only_in_debug_builds \131131 -- --exact132132 133+cargo test --locked -p sigil-host --release \134+ tests::ordinary_release_excludes_configured_host_auth_bypass \135+ -- --exact136+137+cargo test --locked -p sigil-host --release --features demo-auth-bypass \138+ tests::configured_host_auth_bypass_is_explicitly_build_contained \139+ -- --exact140+133141 git diff --check134142 echo 'demo_build_preflight=ok'@@ -252,7 +252,8 @@ expected_values = {
252252 "version": release_tag[1:],253253 "target": "x86_64-unknown-linux-gnu.2.17",254254 "profile": "release",255- "features": ["default", "in-process-gstreamer"],255+ "features": ["in-process-gstreamer"],256+ "demo_auth_bypass": False,256257 "demo_direct_node": False,257258 "git_dirty": False,258259 "binary_provenance": "self-built-clean-head",