cat / goq
clone

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

feat: add offline Sigil enrollment reset (#28)

FelineStateMachine committed 81b3725
Add a fingerprint-confirmed appliance reset that refuses live daemons, atomically advances the authorization epoch, preserves host identity and replay history, and emits redacted versioned JSON. Gate the legacy revoke spelling identically, acquire daemon lifecycle locks before loading identity, and extend integration and operator documentation.
commit
81b3725e647dc9f1619fdf248983466996b36efe
parent
fae73f2c9fce7094ad036af30842d228c019c562
author
FelineStateMachine <dami.etoile@gmail.com>
7 files changed, +250 -15
MREADME.md2+
Mcrates/sigil-host/src/appliance.rs50++++++++++++++++++-
Mcrates/sigil-host/src/authorization.rs20++++++-
Mcrates/sigil-host/src/main.rs81+++++++++++++++++++++++++++---
Mdocs/fresh-bazzite-host.md9+++-
Adocs/sigil-appliance-enrollment-reset-v1.md48++++++++++++++++++
Mscripts/tests/sigil-appliance-status.sh55++++++++++++++++++++-
M README.md +2 -0
M crates/sigil-host/src/appliance.rs +49 -1
@@ -138,6 +138,17 @@ pub struct ApplianceStatusV1 {
138138 runtime: RuntimeStatus,139139 }140140 141+#[derive(Debug, Serialize)]142+pub struct EnrollmentResetV1 {143+ schema_version: u16,144+ operation: &'static str,145+ host_fingerprint: String,146+ had_enrollment: bool,147+ previous_epoch: u64,148+ current_epoch: u64,149+ invitations_invalidated: bool,150+}151+141152 #[derive(Debug, Serialize)]142153 struct ConfigStatus {143154 state: &'static str,
@@ -405,6 +416,43 @@ pub fn collect_status(config_path: &Path) -> Result<ApplianceStatusV1> {
405416 Ok(assemble_status(host, authorization, runtime))406417 }407418 419+pub fn reset_enrollment(420+ config_path: &Path,421+ expected_host_fingerprint: &str,422+) -> Result<EnrollmentResetV1> {423+ let config = HostConfig::load(config_path)?;424+ config.ensure_runtime_directory()?;425+ let _lifecycle = LifecycleGuard::acquire(&config.state_path, true)?;426+ let secret = crate::identity::load(&config.identity_path)?;427+ let host = secret.public();428+ let host_fingerprint = fingerprint(host);429+ ensure!(430+ expected_host_fingerprint == host_fingerprint,431+ "expected host fingerprint does not match this Sigil appliance"432+ );433+434+ let store = AuthorizationStore::open(config.state_path, host)?;435+ let outcome = store.revoke_with_outcome(crate::authorization::unix_timestamp_now()?)?;436+ ensure!(437+ outcome.current_epoch438+ == outcome439+ .previous_epoch440+ .checked_add(1)441+ .context("authorization epoch exhausted")?,442+ "enrollment reset did not advance the authorization epoch"443+ );444+445+ Ok(EnrollmentResetV1 {446+ schema_version: 1,447+ operation: "enrollment_reset",448+ host_fingerprint,449+ had_enrollment: outcome.had_enrollment,450+ previous_epoch: outcome.previous_epoch,451+ current_epoch: outcome.current_epoch,452+ invitations_invalidated: true,453+ })454+}455+408456 fn assemble_status(409457 host: EndpointId,410458 authorization: AuthorizationInspection,
@@ -625,7 +673,7 @@ fn unix_timestamp_millis() -> Result<u64> {
625673 .unwrap_or(u64::MAX))626674 }627675 628-fn fingerprint(id: EndpointId) -> String {676+pub(crate) fn fingerprint(id: EndpointId) -> String {629677 let full = id.to_string();630678 debug_assert_eq!(full.len(), 64);631679 format!("{}…{}", &full[..8], &full[full.len() - 8..])
M crates/sigil-host/src/authorization.rs +16 -4
@@ -48,6 +48,13 @@ pub struct AuthorizationInspection {
4848 pub storage_present: bool,4949 }5050 51+#[derive(Clone, Copy, Debug, PartialEq, Eq)]52+pub struct RevocationOutcome {53+ pub had_enrollment: bool,54+ pub previous_epoch: u64,55+ pub current_epoch: u64,56+}57+5158 #[derive(Debug, Serialize, Deserialize)]5259 #[serde(deny_unknown_fields)]5360 struct AuthorizationState {
@@ -400,11 +407,12 @@ impl AuthorizationStore {
400407 })401408 }402409 403- pub fn revoke(&self, now: u64) -> Result<bool> {410+ pub fn revoke_with_outcome(&self, now: u64) -> Result<RevocationOutcome> {404411 let lock = self.open_lock()?;405412 self.lock_exclusive(&lock)?;406413 let mut state = self.load_state()?;407414 state.validate(self.host)?;415+ let previous_epoch = state.enrollment_epoch;408416 let had_enrollment = state.enrollment.take().is_some();409417 state.enrollment_epoch = state410418 .enrollment_epoch
@@ -412,7 +420,11 @@ impl AuthorizationStore {
412420 .context("authorization epoch exhausted")?;413421 state.prune_expired(now);414422 self.write_state(&state)?;415- Ok(had_enrollment)423+ Ok(RevocationOutcome {424+ had_enrollment,425+ previous_epoch,426+ current_epoch: state.enrollment_epoch,427+ })416428 }417429 418430 fn state_path(&self) -> PathBuf {
@@ -777,7 +789,7 @@ mod tests {
777789 [4; 32],778790 InvitationGrants::VIEW,779791 );780- assert!(!store.revoke(1_001).unwrap());792+ assert!(!store.revoke_with_outcome(1_001).unwrap().had_enrollment);781793 assert!(782794 store783795 .authorize_or_redeem(peer.public(), Some(&token), 1_002)
@@ -832,7 +844,7 @@ mod tests {
832844 store833845 .authorize_or_redeem(peer.public(), Some(&first), 1_001)834846 .unwrap();835- assert!(store.revoke(1_002).unwrap());847+ assert!(store.revoke_with_outcome(1_002).unwrap().had_enrollment);836848 837849 let reopened =838850 AuthorizationStore::open(store.state_directory.clone(), host.public()).unwrap();
M crates/sigil-host/src/main.rs +73 -8
@@ -100,6 +100,17 @@ enum ApplianceCommand {
100100 #[arg(long, required = true)]101101 json: bool,102102 },103+ /// Revoke the enrolled Portal and invalidate every outstanding invitation.104+ EnrollmentReset {105+ #[arg(long)]106+ config: PathBuf,107+ /// Redacted host fingerprint from `appliance status`.108+ #[arg(long)]109+ expected_host_fingerprint: String,110+ /// Emit the stable machine-readable schema.111+ #[arg(long, required = true)]112+ json: bool,113+ },103114 }104115 105116 #[derive(Debug, Subcommand)]
@@ -164,6 +175,12 @@ enum EnrollmentCommand {
164175 Revoke {165176 #[arg(long)]166177 config: PathBuf,178+ /// Redacted host fingerprint from `appliance status`.179+ #[arg(long)]180+ expected_host_fingerprint: String,181+ /// Emit the stable machine-readable schema.182+ #[arg(long, required = true)]183+ json: bool,167184 },168185 }169186
@@ -257,6 +274,14 @@ fn appliance_command(command: ApplianceCommand) -> Result<()> {
257274 let status = appliance::collect_status(&config)?;258275 println!("{}", serde_json::to_string(&status)?);259276 }277+ ApplianceCommand::EnrollmentReset {278+ config,279+ expected_host_fingerprint,280+ json: _,281+ } => {282+ let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)?;283+ println!("{}", serde_json::to_string(&result)?);284+ }260285 }261286 Ok(())262287 }
@@ -327,12 +352,13 @@ fn enrollment_command(command: EnrollmentCommand) -> Result<()> {
327352 _ => unreachable!("validated authorization state is internally consistent"),328353 }329354 }330- EnrollmentCommand::Revoke { config } => {331- let (_config, _secret, store) = authorization_store_from_config(&config)?;332- let revoked = store.revoke(unix_timestamp_now()?)?;333- let snapshot = store.snapshot()?;334- println!("revoked={revoked}");335- println!("enrollment_epoch={}", snapshot.epoch);355+ EnrollmentCommand::Revoke {356+ config,357+ expected_host_fingerprint,358+ json: _,359+ } => {360+ let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)?;361+ println!("{}", serde_json::to_string(&result)?);336362 }337363 }338364 Ok(())
@@ -600,9 +626,9 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
600626 let mut config = load_serve_config(&args)?;601627 config.validate()?;602628 config.ensure_runtime_directory()?;603- let secret = identity::load(&config.identity_path)?;604629 let configured_service = args.config.is_some();605630 let _lifecycle = appliance::LifecycleGuard::acquire(&config.state_path, configured_service)?;631+ let secret = identity::load(&config.identity_path)?;606632 let sessions = Arc::new(SessionRegistry::default());607633 let publisher = appliance::RuntimePublisher::start(608634 secret.public(),
@@ -993,10 +1019,23 @@ mod tests {
9931019 "enrollment",9941020 "revoke",9951021 "--config",996- "/tmp/host.toml"1022+ "/tmp/host.toml",1023+ "--expected-host-fingerprint",1024+ "aaaaaaaa…bbbbbbbb",1025+ "--json",9971026 ])9981027 .is_ok()9991028 );1029+ assert!(1030+ Cli::try_parse_from([1031+ "sigil",1032+ "enrollment",1033+ "revoke",1034+ "--config",1035+ "/tmp/host.toml"1036+ ])1037+ .is_err()1038+ );10001039 }10011040 10021041 #[test]
@@ -1018,6 +1057,32 @@ mod tests {
10181057 );10191058 }10201059 1060+ #[test]1061+ fn appliance_enrollment_reset_requires_confirmation_and_json() {1062+ let valid = [1063+ "sigil",1064+ "appliance",1065+ "enrollment-reset",1066+ "--config",1067+ "/tmp/host.toml",1068+ "--expected-host-fingerprint",1069+ "aaaaaaaa…bbbbbbbb",1070+ "--json",1071+ ];1072+ assert!(Cli::try_parse_from(valid).is_ok());1073+ assert!(1074+ Cli::try_parse_from([1075+ "sigil",1076+ "appliance",1077+ "enrollment-reset",1078+ "--config",1079+ "/tmp/host.toml",1080+ "--json",1081+ ])1082+ .is_err()1083+ );1084+ }1085+10211086 #[test]10221087 fn invitation_file_uses_create_new_semantics() {10231088 let directory = tempfile::tempdir().unwrap();
M docs/fresh-bazzite-host.md +8 -1
A docs/sigil-appliance-enrollment-reset-v1.md +48 -0
@@ -0,0 +1,48 @@
1+# Sigil appliance enrollment reset v12+3+`sigil appliance enrollment-reset` is the local mutation used by appliance4+management surfaces to revoke the one enrolled Portal without rotating the5+Sigil host identity:6+7+```bash8+sigil appliance enrollment-reset \9+ --config "$HOME/.config/sigil-spark/host.toml" \10+ --expected-host-fingerprint '12345678…90abcdef' \11+ --json12+```13+14+The caller must first stop `sigil-host.service` and wait for it to become15+inactive. The command acquires both daemon-lifetime locks without waiting and16+fails before changing authorization state if a daemon still owns either lock.17+It never stops or starts systemd itself. This keeps service policy in the Decky18+backend and prevents a reset from leaving a live remote session attached.19+20+The expected fingerprint is the redacted value returned by21+`sigil appliance status`. It is an accidental-target confirmation interlock,22+not a new authentication factor. Existing same-user file ownership and mode23+checks remain the authority boundary.24+25+Success writes exactly one JSON object:26+27+```json28+{29+ "schema_version": 1,30+ "operation": "enrollment_reset",31+ "host_fingerprint": "12345678…90abcdef",32+ "had_enrollment": true,33+ "previous_epoch": 4,34+ "current_epoch": 5,35+ "invitations_invalidated": true36+}37+```38+39+Every successful invocation advances the authorization epoch, even when no40+Portal is enrolled, because an unredeemed invitation may still exist outside41+the host. The host identity stays byte-for-byte unchanged. Nonexpired replay42+records remain intact, and all invitations from earlier epochs become stale.43+A retry after a lost success response may advance the epoch again; that is a44+safe additional invalidation.45+46+The legacy `sigil enrollment revoke` spelling is a strict alias. It also47+requires `--expected-host-fingerprint` and `--json`, so there is no48+confirmation-free or live-daemon bypass.
M scripts/tests/sigil-appliance-status.sh +54 -1
@@ -44,6 +44,7 @@ host_log="$temp_root/host.log"
4444 second_log="$temp_root/second.log"4545 live_status="$temp_root/live-status.json"4646 stopped_status="$temp_root/stopped-status.json"47+reset_status="$temp_root/reset-status.json"4748 4849 mkdir -m 0700 "$identity_directory" "$state_directory" "$runtime_directory"4950 "$sigil" identity init --output "$identity_path" >"$temp_root/identity.log"
@@ -131,17 +132,57 @@ if len(fingerprint) != 17 or "…" not in fingerprint:
131132 raise SystemExit("host fingerprint is not bounded and redacted")132133 PY133134 135+host_fingerprint="$(python3 - "$live_status" <<'PY'136+import json137+import pathlib138+import sys139+140+print(json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["identity"]["host_fingerprint"])141+PY142+)"143+144+if XDG_RUNTIME_DIR="$runtime_directory" \145+ "$sigil" appliance enrollment-reset \146+ --config "$config_path" \147+ --expected-host-fingerprint "$host_fingerprint" \148+ --json \149+ >"$temp_root/live-reset.log" 2>&1; then150+ printf 'enrollment reset unexpectedly mutated a live Sigil daemon\n' >&2151+ exit 1152+fi153+grep -Fq 'another Sigil daemon already owns this state directory' \154+ "$temp_root/live-reset.log"155+134156 wait "$host_pid"135157 host_pid=''158+[[ ! -e "$state_directory/authorization-v1.json" ]]159+if XDG_RUNTIME_DIR="$runtime_directory" \160+ "$sigil" appliance enrollment-reset \161+ --config "$config_path" \162+ --expected-host-fingerprint '00000000…00000000' \163+ --json \164+ >"$temp_root/mismatch-reset.log" 2>&1; then165+ printf 'enrollment reset accepted the wrong host fingerprint\n' >&2166+ exit 1167+fi168+[[ ! -e "$state_directory/authorization-v1.json" ]]169+XDG_RUNTIME_DIR="$runtime_directory" \170+ "$sigil" appliance enrollment-reset \171+ --config "$config_path" \172+ --expected-host-fingerprint "$host_fingerprint" \173+ --json >"$reset_status"136174 XDG_RUNTIME_DIR="$runtime_directory" \137175 "$sigil" appliance status --config "$config_path" --json >"$stopped_status"138176 139-python3 - "$stopped_status" <<'PY'177+python3 - "$stopped_status" "$reset_status" "$host_node_id" <<'PY'140178 import json141179 import pathlib142180 import sys143181 144182 status = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))183+reset_raw = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8")184+reset = json.loads(reset_raw)185+host_node_id = sys.argv[3]145186 runtime = status.get("runtime", {})146187 if runtime.get("daemon") != "stopped":147188 raise SystemExit(f"clean shutdown did not publish stopped: {runtime}")
@@ -149,6 +190,18 @@ if runtime.get("session") != "inactive":
149190 raise SystemExit("stopped daemon did not report an inactive session")150191 if runtime.get("uptime_ms") is not None:151192 raise SystemExit("stopped daemon retained a live uptime")193+if reset.get("schema_version") != 1 or reset.get("operation") != "enrollment_reset":194+ raise SystemExit("unexpected enrollment reset schema")195+if reset.get("had_enrollment") is not False:196+ raise SystemExit("empty enrollment reset reported an enrolled Portal")197+if reset.get("current_epoch") != reset.get("previous_epoch") + 1:198+ raise SystemExit("enrollment reset did not advance exactly one epoch")199+if reset.get("invitations_invalidated") is not True:200+ raise SystemExit("enrollment reset did not report invitation invalidation")201+if host_node_id in reset_raw:202+ raise SystemExit("enrollment reset leaked the complete host node ID")203+if status.get("enrollment", {}).get("epoch") != reset.get("current_epoch"):204+ raise SystemExit("status does not reflect the enrollment reset epoch")152205 PY153206 154207 if env -u XDG_RUNTIME_DIR \