cat / goq
clone

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

Merge remote-tracking branch 'origin/main' into feat/freeze-compatibility-identifiers

Dami committed a3d85e1
# Conflicts:
#	AGENTS.md
commit
a3d85e10e5effd87d073dc22a7abadc1c236ab1a
parent
d681500effbbddf2d91e8b81c78d764a97e584fd
parent
2d0a9111e43ae54adda626587fe2fe202cb9d380
author
Dami <dami.etoile@gmail.com>
5 files changed, +374 -117
MAGENTS.md24+-
Mcrates/sigil-host/src/authorization.rs410+++++++++++++++++++++++-------
Mcrates/sigil-host/src/main.rs28+-
Mdocs/sigil-appliance-enrollment-reset-v1.md17+
Mscripts/tests/authenticated-probe-proof.sh12+-
M AGENTS.md +13 -11
@@ -208,22 +208,24 @@ Sigil host.
208208 and observing that it listens is also not an end-to-end test: Portal must209209 connect to that exact daemon, the host must admit the session, live media210210 diagnostics must advance, and the affected interaction or recovery path must211- be exercised from Portal.212-- Fresh exact-commit builds are not expected to be enrolled. When authentication213- is not the behavior under test, use the development bypass proactively rather214- than skipping the connection, attempting ordinary auth, or reporting the215- daemon-only result as hardware proof. Build Sigil with `demo-auth-bypass`, run216- it with211+ be exercised from Portal. Fresh exact-commit builds are not212+ expected to be enrolled. This also applies when authentication or identity213+ code changed: do not skip the connection because ordinary authentication214+ blocks it. For development hardware testing, build Sigil with215+ `demo-auth-bypass`, run it with217216 `--dev-allow-unauthorized` and a bounded `--max-runtime-seconds`, and connect218217 a debug Portal with `--dev-connect`. Verify the affected live diagnostics219218 and interaction path, then stop the bypass process, undo temporary Gamescope220219 controls, and restore and verify the ordinary authenticated221220 `sigil-host.service` before calling the test complete.222-- When authentication, enrollment, identity, grants, invitations, or reconnect223- authorization is the behavior under test, the bypass is not evidence. Use224- the real `LA_key` flow, complete an authenticated Portal -> UMPC session, and225- verify a ticket-free reconnect where applicable without exposing the PIN or226- credential material.221+- The bypass proves the exact runtime connection, not authorization semantics.222+ For authentication, enrollment, identity, grants, invitation, and reconnect223+ changes, supplement that session with deterministic FIDO-response tests and224+ a persistent software Iroh identity using signed invitations and225+ authenticated probes. Cover the relevant grant, replay, expiry, peer/host226+ binding, and ticket-free reconnect behavior. A physical security-key PIN or227+ tap is not required unless the operator explicitly requests authenticator228+ hardware acceptance.227229 228230 - On Linux with NVIDIA, set `WEBKIT_DISABLE_DMABUF_RENDERER=1` for Portal.229231 - Treat `experimental-non-macos-pointer-capture` as an explicit non-release
M crates/sigil-host/src/authorization.rs +318 -92
@@ -1,7 +1,8 @@
11 use std::fs::{self, File, OpenOptions};22 use std::io::{Read, Write};33 use std::path::{Path, PathBuf};4-use std::time::{SystemTime, UNIX_EPOCH};4+use std::sync::{Arc, Mutex, MutexGuard, TryLockError};5+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};56 67 use anyhow::{Context, Result, ensure};78 use iroh::EndpointId;
@@ -19,13 +20,30 @@ const STATE_FILE_NAME: &str = "authorization-v1.json";
1920 const LOCK_FILE_NAME: &str = "authorization-v1.lock";2021 const MAX_STATE_FILE_LEN: u64 = 64 * 1024;2122 const MAX_CONSUMED_INVITATIONS: usize = 64;23+const WRITER_LOCK_TIMEOUT: Duration = Duration::from_millis(250);24+const WRITER_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(10);2225 2326 #[derive(Clone, Debug)]2427 pub struct AuthorizationStore {28+ location: AuthorizationLocation,29+ writer: Arc<AuthorizationWriter>,30+}31+32+#[derive(Clone, Debug)]33+struct AuthorizationLocation {2534 state_directory: PathBuf,2635 host: EndpointId,2736 }2837 38+#[derive(Debug)]39+struct AuthorizationWriter {40+ state: Mutex<AuthorizationState>,41+ /// The process that owns this descriptor is the only durable writer.42+ /// Configured service and offline recovery paths acquire their lifecycle43+ /// lease before this authorization lease.44+ _lease: File,45+}46+2947 #[derive(Clone, Debug)]3048 pub enum AuthorizationPolicy {3149 Required(AuthorizationStore),
@@ -58,7 +76,26 @@ pub struct RevocationOutcome {
5876 pub current_epoch: u64,5977 }6078 61-#[derive(Debug, Serialize, Deserialize)]79+impl AuthorizationSnapshot {80+ fn from_state(state: &AuthorizationState) -> Result<Self> {81+ let (peer, grants, enrolled_at_unix) = match &state.enrollment {82+ Some(enrollment) => (83+ Some(enrollment.peer_node_id.parse::<EndpointId>()?),84+ Some(enrollment.grants),85+ Some(enrollment.enrolled_at_unix),86+ ),87+ None => (None, None, None),88+ };89+ Ok(Self {90+ epoch: state.enrollment_epoch,91+ peer,92+ grants,93+ enrolled_at_unix,94+ })95+ }96+}97+98+#[derive(Clone, Debug, Serialize, Deserialize)]6299 #[serde(deny_unknown_fields)]63100 struct AuthorizationState {64101 version: u16,
@@ -68,7 +105,7 @@ struct AuthorizationState {
68105 consumed_invitations: Vec<ConsumedInvitation>,69106 }70107 71-#[derive(Debug, Serialize, Deserialize)]108+#[derive(Clone, Debug, Serialize, Deserialize)]72109 #[serde(deny_unknown_fields)]73110 struct Enrollment {74111 peer_node_id: String,
@@ -76,7 +113,7 @@ struct Enrollment {
76113 enrolled_at_unix: u64,77114 }78115 79-#[derive(Debug, Serialize, Deserialize)]116+#[derive(Clone, Debug, Serialize, Deserialize)]80117 #[serde(deny_unknown_fields)]81118 struct ConsumedInvitation {82119 nonce: [u8; 32],
@@ -151,25 +188,56 @@ impl AuthorizationState {
151188 152189 impl AuthorizationStore {153190 pub fn open(state_directory: impl Into<PathBuf>, host: EndpointId) -> Result<Self> {154- let store = Self {191+ let location = AuthorizationLocation {155192 state_directory: state_directory.into(),156193 host,157194 };158- store.ensure_state_directory()?;159- let lock = store.open_lock()?;160- store.lock_exclusive(&lock)?;161- let state = store.load_state()?;195+ location.ensure_state_directory()?;196+ let lock = location.open_lock()?;197+ location.lock_exclusive_bounded(&lock, WRITER_LOCK_TIMEOUT)?;198+ let state = location.load_state()?;162199 state.validate(host)?;163- Ok(store)200+ Ok(Self {201+ location,202+ writer: Arc::new(AuthorizationWriter {203+ state: Mutex::new(state),204+ _lease: lock,205+ }),206+ })164207 }165208 166- pub fn issue_claims(209+ #[cfg(test)]210+ fn issue_claims(167211 &self,168212 peer: EndpointId,169213 grants: InvitationGrants,170214 ttl_seconds: u64,171215 now: u64,172216 nonce: [u8; 32],217+ ) -> Result<InvitationClaims> {218+ let state = self.lock_state()?;219+ state.validate(self.location.host)?;220+ Self::issue_claims_from_snapshot(221+ self.location.host,222+ AuthorizationSnapshot::from_state(&state)?,223+ peer,224+ grants,225+ ttl_seconds,226+ now,227+ nonce,228+ )229+ }230+231+ /// Issue claims from one validated atomic read. Invitation creation is232+ /// read-only: the daemon may remain the sole writer while it is running.233+ pub fn issue_claims_from_snapshot(234+ host: EndpointId,235+ snapshot: AuthorizationSnapshot,236+ peer: EndpointId,237+ grants: InvitationGrants,238+ ttl_seconds: u64,239+ now: u64,240+ nonce: [u8; 32],173241 ) -> Result<InvitationClaims> {174242 grants.validate()?;175243 ensure!(
@@ -180,21 +248,17 @@ impl AuthorizationStore {
180248 (1..=MAX_INVITATION_TTL_SECS).contains(&ttl_seconds),181249 "invitation TTL must be between 1 and {MAX_INVITATION_TTL_SECS} seconds"182250 );183- let lock = self.open_lock()?;184- self.lock_exclusive(&lock)?;185- let state = self.load_state()?;186- state.validate(self.host)?;187251 ensure!(188- state.enrollment.is_none(),252+ snapshot.peer.is_none() && snapshot.grants.is_none(),189253 "a Portal peer is already enrolled; revoke it before issuing another invitation"190254 );191255 InvitationClaims::new(192- *self.host.as_bytes(),256+ *host.as_bytes(),193257 *peer.as_bytes(),194258 now,195259 now.checked_add(ttl_seconds)196260 .context("invitation expiry overflow")?,197- state.enrollment_epoch,261+ snapshot.epoch,198262 nonce,199263 grants,200264 )
@@ -207,15 +271,17 @@ impl AuthorizationStore {
207271 invitation_token: Option<&str>,208272 now: u64,209273 ) -> Result<InvitationGrants> {210- let lock = self.open_lock()?;211- self.lock_exclusive(&lock)?;212- let mut state = self.load_state()?;213- state.validate(self.host)?;214- let mut changed = state.prune_expired(now);274+ // Admission never waits for another in-process authorization operation.275+ // A concurrent redemption or durable write fails this handshake closed276+ // and lets the client retry instead of growing accept-path latency.277+ let mut state = self.lock_state()?;278+ state.validate(self.location.host)?;279+ let mut candidate = state.clone();280+ let mut changed = candidate.prune_expired(now);215281 216282 let result = match invitation_token {217283 None => {218- let enrollment = state284+ let enrollment = candidate219285 .enrollment220286 .as_ref()221287 .context("Portal peer is not enrolled")?;
@@ -227,14 +293,14 @@ impl AuthorizationStore {
227293 }228294 Some(token) => {229295 ensure!(230- state.enrollment.is_none(),296+ candidate.enrollment.is_none(),231297 "an invitation cannot be used after enrollment"232298 );233299 let invitation = SignedInvitation::decode(token)234300 .context("decoding and verifying enrollment invitation")?;235301 let claims = invitation.claims;236302 ensure!(237- claims.host_node_id == *self.host.as_bytes(),303+ claims.host_node_id == *self.location.host.as_bytes(),238304 "invitation belongs to a different Sigil host"239305 );240306 ensure!(
@@ -242,7 +308,7 @@ impl AuthorizationStore {
242308 "invitation is bound to a different Portal peer"243309 );244310 ensure!(245- claims.enrollment_epoch == state.enrollment_epoch,311+ claims.enrollment_epoch == candidate.enrollment_epoch,246312 "invitation enrollment epoch is stale"247313 );248314 ensure!(
@@ -255,22 +321,22 @@ impl AuthorizationStore {
255321 "invitation does not grant view permission"256322 );257323 ensure!(258- !state324+ !candidate259325 .consumed_invitations260326 .iter()261327 .any(|consumed| consumed.nonce == claims.nonce),262328 "invitation has already been consumed"263329 );264330 ensure!(265- state.consumed_invitations.len() < MAX_CONSUMED_INVITATIONS,331+ candidate.consumed_invitations.len() < MAX_CONSUMED_INVITATIONS,266332 "authorization replay ledger is full"267333 );268- state.enrollment = Some(Enrollment {334+ candidate.enrollment = Some(Enrollment {269335 peer_node_id: remote.to_string(),270336 grants: claims.grants,271337 enrolled_at_unix: now,272338 });273- state.consumed_invitations.push(ConsumedInvitation {339+ candidate.consumed_invitations.push(ConsumedInvitation {274340 nonce: claims.nonce,275341 expires_at_unix: claims.expires_at_unix,276342 });
@@ -280,30 +346,17 @@ impl AuthorizationStore {
280346 };281347 282348 if changed {283- self.write_state(&state)?;349+ self.location.write_state(&candidate)?;350+ *state = candidate;284351 }285352 Ok(result)286353 }287354 288- pub fn snapshot(&self) -> Result<AuthorizationSnapshot> {289- let lock = self.open_lock()?;290- self.lock_exclusive(&lock)?;291- let state = self.load_state()?;292- state.validate(self.host)?;293- let (peer, grants, enrolled_at_unix) = match state.enrollment {294- Some(enrollment) => (295- Some(enrollment.peer_node_id.parse::<EndpointId>()?),296- Some(enrollment.grants),297- Some(enrollment.enrolled_at_unix),298- ),299- None => (None, None, None),300- };301- Ok(AuthorizationSnapshot {302- epoch: state.enrollment_epoch,303- peer,304- grants,305- enrolled_at_unix,306- })355+ #[cfg(test)]356+ fn snapshot(&self) -> Result<AuthorizationSnapshot> {357+ let state = self.lock_state()?;358+ state.validate(self.location.host)?;359+ AuthorizationSnapshot::from_state(&state)307360 }308361 309362 /// Inspect durable enrollment without creating the state directory or lock.
@@ -313,11 +366,11 @@ impl AuthorizationStore {
313366 state_directory: impl Into<PathBuf>,314367 host: EndpointId,315368 ) -> Result<AuthorizationInspection> {316- let store = Self {369+ let location = AuthorizationLocation {317370 state_directory: state_directory.into(),318371 host,319372 };320- let directory_metadata = match fs::symlink_metadata(&store.state_directory) {373+ let directory_metadata = match fs::symlink_metadata(&location.state_directory) {321374 Ok(metadata) => metadata,322375 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {323376 return Ok(AuthorizationInspection {
@@ -352,7 +405,7 @@ impl AuthorizationStore {
352405 );353406 }354407 355- let path = store.state_path();408+ let path = location.state_path();356409 let mut options = OpenOptions::new();357410 options.read(true);358411 #[cfg(unix)]
@@ -372,7 +425,7 @@ impl AuthorizationStore {
372425 }373426 Err(error) => return Err(error).context("opening authorization state"),374427 };375- store.validate_secure_file(&file, &path)?;428+ location.validate_secure_file(&file, &path)?;376429 let metadata = file.metadata()?;377430 ensure!(378431 metadata.len() <= MAX_STATE_FILE_LEN,
@@ -390,20 +443,7 @@ impl AuthorizationStore {
390443 let state: AuthorizationState =391444 serde_json::from_slice(&bytes).context("parsing authorization state")?;392445 state.validate(host)?;393- let snapshot = match state.enrollment {394- Some(enrollment) => AuthorizationSnapshot {395- epoch: state.enrollment_epoch,396- peer: Some(enrollment.peer_node_id.parse::<EndpointId>()?),397- grants: Some(enrollment.grants),398- enrolled_at_unix: Some(enrollment.enrolled_at_unix),399- },400- None => AuthorizationSnapshot {401- epoch: state.enrollment_epoch,402- peer: None,403- grants: None,404- enrolled_at_unix: None,405- },406- };446+ let snapshot = AuthorizationSnapshot::from_state(&state)?;407447 Ok(AuthorizationInspection {408448 snapshot,409449 storage_present: true,
@@ -411,18 +451,18 @@ impl AuthorizationStore {
411451 }412452 413453 pub fn revoke_with_outcome(&self, now: u64) -> Result<RevocationOutcome> {414- let lock = self.open_lock()?;415- self.lock_exclusive(&lock)?;416- let mut state = self.load_state()?;417- state.validate(self.host)?;418- let previous_epoch = state.enrollment_epoch;419- let had_enrollment = state.enrollment.take().is_some();420- state.enrollment_epoch = state454+ let mut state = self.lock_state()?;455+ state.validate(self.location.host)?;456+ let mut candidate = state.clone();457+ let previous_epoch = candidate.enrollment_epoch;458+ let had_enrollment = candidate.enrollment.take().is_some();459+ candidate.enrollment_epoch = candidate421460 .enrollment_epoch422461 .checked_add(1)423462 .context("authorization epoch exhausted")?;424- state.prune_expired(now);425- self.write_state(&state)?;463+ candidate.prune_expired(now);464+ self.location.write_state(&candidate)?;465+ *state = candidate;426466 Ok(RevocationOutcome {427467 had_enrollment,428468 previous_epoch,
@@ -430,6 +470,20 @@ impl AuthorizationStore {
430470 })431471 }432472 473+ fn lock_state(&self) -> Result<MutexGuard<'_, AuthorizationState>> {474+ match self.writer.state.try_lock() {475+ Ok(state) => Ok(state),476+ Err(TryLockError::WouldBlock) => {477+ anyhow::bail!("authorization writer is busy; retry the operation")478+ }479+ Err(TryLockError::Poisoned(_)) => {480+ anyhow::bail!("authorization writer state is poisoned")481+ }482+ }483+ }484+}485+486+impl AuthorizationLocation {433487 fn state_path(&self) -> PathBuf {434488 self.state_directory.join(STATE_FILE_NAME)435489 }
@@ -489,16 +543,31 @@ impl AuthorizationStore {
489543 Ok(file)490544 }491545 492- fn lock_exclusive(&self, file: &File) -> Result<()> {546+ fn lock_exclusive_bounded(&self, file: &File, timeout: Duration) -> Result<()> {493547 #[cfg(unix)]494548 {495549 use std::os::fd::AsRawFd;496- let status = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };497- ensure!(498- status == 0,499- "locking authorization state failed: {}",500- std::io::Error::last_os_error()501- );550+ let started = Instant::now();551+ loop {552+ let status =553+ unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };554+ if status == 0 {555+ break;556+ }557+ let error = std::io::Error::last_os_error();558+ if error.kind() != std::io::ErrorKind::WouldBlock {559+ return Err(error).context("locking authorization state failed");560+ }561+ if started.elapsed() >= timeout {562+ anyhow::bail!(563+ "authorization writer is busy after {} ms; stop or terminate the current writer and retry",564+ timeout.as_millis()565+ );566+ }567+ std::thread::sleep(568+ WRITER_LOCK_RETRY_INTERVAL.min(timeout.saturating_sub(started.elapsed())),569+ );570+ }502571 }503572 Ok(())504573 }
@@ -640,6 +709,8 @@ mod tests {
640709 use sigil_protocol::SignedInvitation;641710 #[cfg(unix)]642711 use std::os::unix::fs::PermissionsExt;712+ #[cfg(unix)]713+ use std::process::Command;643714 644715 fn store() -> (tempfile::TempDir, SecretKey, SecretKey, AuthorizationStore) {645716 let directory = tempfile::tempdir().unwrap();
@@ -690,8 +761,9 @@ mod tests {
690761 .contains("after enrollment")691762 );692763 693- let reopened =694- AuthorizationStore::open(store.state_directory.clone(), host.public()).unwrap();764+ let state_directory = store.location.state_directory.clone();765+ drop(store);766+ let reopened = AuthorizationStore::open(state_directory, host.public()).unwrap();695767 assert_eq!(696768 reopened697769 .authorize_or_redeem(peer.public(), None, 1_003)
@@ -820,7 +892,8 @@ mod tests {
820892 .unwrap();821893 let other_host = SecretKey::from_bytes(&[12; 32]);822894 assert!(823- AuthorizationStore::open(store.state_directory.clone(), other_host.public()).is_err()895+ AuthorizationStore::open(store.location.state_directory.clone(), other_host.public())896+ .is_err()824897 );825898 let snapshot = store.snapshot().unwrap();826899 assert!(
@@ -849,8 +922,9 @@ mod tests {
849922 .unwrap();850923 assert!(store.revoke_with_outcome(1_002).unwrap().had_enrollment);851924 852- let reopened =853- AuthorizationStore::open(store.state_directory.clone(), host.public()).unwrap();925+ let state_directory = store.location.state_directory.clone();926+ drop(store);927+ let reopened = AuthorizationStore::open(state_directory, host.public()).unwrap();854928 let replay = token(855929 &reopened,856930 &host,
@@ -859,13 +933,16 @@ mod tests {
859933 nonce,860934 InvitationGrants::VIEW,861935 );862- let state_before = fs::read(reopened.state_path()).unwrap();936+ let state_before = fs::read(reopened.location.state_path()).unwrap();863937 let error = reopened864938 .authorize_or_redeem(peer.public(), Some(&replay), 1_004)865939 .unwrap_err();866940 867941 assert!(error.to_string().contains("already been consumed"));868- assert_eq!(fs::read(reopened.state_path()).unwrap(), state_before);942+ assert_eq!(943+ fs::read(reopened.location.state_path()).unwrap(),944+ state_before945+ );869946 let snapshot = reopened.snapshot().unwrap();870947 assert_eq!(snapshot.epoch, 2);871948 assert!(snapshot.peer.is_none());
@@ -935,4 +1012,153 @@ mod tests {
9351012 .unwrap_err();9361013 assert!(error.to_string().contains("too far in the future"));9371014 }1015+1016+ #[cfg(unix)]1017+ #[test]1018+ fn writer_contention_is_bounded_and_recovers_after_release() {1019+ let (directory, host, _peer, store) = store();1020+ let started = Instant::now();1021+ let error = AuthorizationStore::open(directory.path(), host.public()).unwrap_err();1022+ let elapsed = started.elapsed();1023+1024+ assert!(error.to_string().contains("authorization writer is busy"));1025+ assert!(elapsed >= WRITER_LOCK_TIMEOUT);1026+ assert!(elapsed < Duration::from_secs(2));1027+1028+ drop(store);1029+ AuthorizationStore::open(directory.path(), host.public()).unwrap();1030+ }1031+1032+ #[test]1033+ fn admission_fails_fast_while_in_process_writer_is_hung() {1034+ let (_directory, _host, peer, store) = store();1035+ let _hung_writer = store.writer.state.lock().unwrap();1036+ let started = Instant::now();1037+1038+ let error = store1039+ .authorize_or_redeem(peer.public(), None, 1_000)1040+ .unwrap_err();1041+1042+ assert!(error.to_string().contains("authorization writer is busy"));1043+ assert!(started.elapsed() < Duration::from_millis(50));1044+ }1045+1046+ #[test]1047+ fn read_only_invitation_creation_remains_available_with_live_writer() {1048+ let (directory, host, peer, _store) = store();1049+ let inspection =1050+ AuthorizationStore::inspect_existing(directory.path(), host.public()).unwrap();1051+ let claims = AuthorizationStore::issue_claims_from_snapshot(1052+ host.public(),1053+ inspection.snapshot,1054+ peer.public(),1055+ InvitationGrants::ALL,1056+ 600,1057+ 1_000,1058+ [42; 32],1059+ )1060+ .unwrap();1061+1062+ assert_eq!(claims.enrollment_epoch, 1);1063+ assert_eq!(claims.intended_peer_id, *peer.public().as_bytes());1064+ }1065+1066+ #[cfg(unix)]1067+ #[test]1068+ fn crashed_writer_releases_lease_and_preserves_durable_state() {1069+ const CHILD_DIRECTORY: &str = "SIGIL_AUTHORIZATION_CRASH_TEST_DIRECTORY";1070+ let directory = tempfile::tempdir().unwrap();1071+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1072+ let marker = directory.path().join("writer-ready");1073+ let mut child = Command::new(std::env::current_exe().unwrap())1074+ .args([1075+ "--exact",1076+ "authorization::tests::writer_crash_helper",1077+ "--nocapture",1078+ ])1079+ .env(CHILD_DIRECTORY, directory.path())1080+ .spawn()1081+ .unwrap();1082+1083+ let deadline = Instant::now() + Duration::from_secs(5);1084+ while !marker.exists() {1085+ assert!(1086+ Instant::now() < deadline,1087+ "writer crash helper did not acquire its lease"1088+ );1089+ assert!(1090+ child.try_wait().unwrap().is_none(),1091+ "writer crash helper exited before acquiring its lease"1092+ );1093+ std::thread::sleep(Duration::from_millis(10));1094+ }1095+1096+ let host = SecretKey::from_bytes(&[7; 32]);1097+ assert!(1098+ AuthorizationStore::open(directory.path(), host.public())1099+ .unwrap_err()1100+ .to_string()1101+ .contains("authorization writer is busy")1102+ );1103+ child.kill().unwrap();1104+ child.wait().unwrap();1105+1106+ let recovered = AuthorizationStore::open(directory.path(), host.public()).unwrap();1107+ assert_eq!(recovered.snapshot().unwrap().epoch, 2);1108+ }1109+1110+ #[cfg(unix)]1111+ #[test]1112+ fn writer_crash_helper() {1113+ const CHILD_DIRECTORY: &str = "SIGIL_AUTHORIZATION_CRASH_TEST_DIRECTORY";1114+ let Ok(directory) = std::env::var(CHILD_DIRECTORY) else {1115+ return;1116+ };1117+ let host = SecretKey::from_bytes(&[7; 32]);1118+ let store = AuthorizationStore::open(&directory, host.public()).unwrap();1119+ store.revoke_with_outcome(1_000).unwrap();1120+ fs::write(Path::new(&directory).join("writer-ready"), b"ready").unwrap();1121+ std::thread::sleep(Duration::from_secs(60));1122+ }1123+1124+ #[cfg(unix)]1125+ #[test]1126+ fn adversarial_lock_files_and_crash_temps_fail_closed_or_recover_atomically() {1127+ use std::os::unix::fs::symlink;1128+1129+ let host = SecretKey::from_bytes(&[7; 32]);1130+ let symlink_directory = tempfile::tempdir().unwrap();1131+ fs::set_permissions(symlink_directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1132+ let target = symlink_directory.path().join("target");1133+ fs::write(&target, b"target").unwrap();1134+ symlink(&target, symlink_directory.path().join(LOCK_FILE_NAME)).unwrap();1135+ assert!(AuthorizationStore::open(symlink_directory.path(), host.public()).is_err());1136+1137+ let permissive_directory = tempfile::tempdir().unwrap();1138+ fs::set_permissions(1139+ permissive_directory.path(),1140+ fs::Permissions::from_mode(0o700),1141+ )1142+ .unwrap();1143+ let permissive_lock = permissive_directory.path().join(LOCK_FILE_NAME);1144+ fs::write(&permissive_lock, b"").unwrap();1145+ fs::set_permissions(&permissive_lock, fs::Permissions::from_mode(0o644)).unwrap();1146+ assert!(AuthorizationStore::open(permissive_directory.path(), host.public()).is_err());1147+1148+ let recovery_directory = tempfile::tempdir().unwrap();1149+ fs::set_permissions(recovery_directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1150+ let store = AuthorizationStore::open(recovery_directory.path(), host.public()).unwrap();1151+ store.revoke_with_outcome(1_000).unwrap();1152+ drop(store);1153+ fs::write(1154+ recovery_directory1155+ .path()1156+ .join(".authorization-v1.json.crashed.tmp"),1157+ b"{not valid json",1158+ )1159+ .unwrap();1160+1161+ let recovered = AuthorizationStore::open(recovery_directory.path(), host.public()).unwrap();1162+ assert_eq!(recovered.snapshot().unwrap().epoch, 2);1163+ }9381164 }
M crates/sigil-host/src/main.rs +20 -8
@@ -459,14 +459,19 @@ fn appliance_command(command: ApplianceCommand) -> CliResult<()> {
459459 Ok(())460460 }461461 462-fn authorization_store_from_config(462+fn authorization_snapshot_from_config(463463 path: &Path,464-) -> Result<(HostConfig, iroh::SecretKey, AuthorizationStore)> {464+) -> Result<(465+ HostConfig,466+ iroh::SecretKey,467+ authorization::AuthorizationSnapshot,468+)> {465469 let config = HostConfig::load(path)?;466470 config.ensure_runtime_directory()?;467471 let secret = identity::load(&config.identity_path)?;468- let store = AuthorizationStore::open(config.state_path.clone(), secret.public())?;469- Ok((config, secret, store))472+ let inspection =473+ AuthorizationStore::inspect_existing(config.state_path.clone(), secret.public())?;474+ Ok((config, secret, inspection.snapshot))470475 }471476 472477 fn invitation_command(command: InvitationCommand) -> Result<()> {
@@ -480,7 +485,7 @@ fn invitation_command(command: InvitationCommand) -> Result<()> {
480485 output,481486 print_deep_link,482487 } => {483- let (_config, secret, store) = authorization_store_from_config(&config)?;488+ let (_config, secret, snapshot) = authorization_snapshot_from_config(&config)?;484489 let mut grants = InvitationGrants::VIEW;485490 if pointer_keyboard {486491 grants = grants.union(InvitationGrants::POINTER_KEYBOARD);
@@ -491,7 +496,15 @@ fn invitation_command(command: InvitationCommand) -> Result<()> {
491496 let mut nonce = [0_u8; 32];492497 getrandom::fill(&mut nonce).context("generating invitation nonce")?;493498 let now = unix_timestamp_now()?;494- let claims = store.issue_claims(peer, grants, expires_in_seconds, now, nonce)?;499+ let claims = AuthorizationStore::issue_claims_from_snapshot(500+ secret.public(),501+ snapshot,502+ peer,503+ grants,504+ expires_in_seconds,505+ now,506+ nonce,507+ )?;495508 let expires_at = claims.expires_at_unix;496509 let invitation = SignedInvitation::issue(claims, &secret.to_bytes())?;497510 let token = invitation.encode();
@@ -512,8 +525,7 @@ fn invitation_command(command: InvitationCommand) -> Result<()> {
512525 fn enrollment_command(command: EnrollmentCommand) -> Result<()> {513526 match command {514527 EnrollmentCommand::Show { config } => {515- let (_config, _secret, store) = authorization_store_from_config(&config)?;516- let snapshot = store.snapshot()?;528+ let (_config, _secret, snapshot) = authorization_snapshot_from_config(&config)?;517529 println!("enrollment_epoch={}", snapshot.epoch);518530 match (snapshot.peer, snapshot.grants) {519531 (Some(peer), Some(grants)) => {
M docs/sigil-appliance-enrollment-reset-v1.md +17 -0
@@ -22,6 +22,23 @@ daemon for that state still blocks the operation. The command never stops or
2222 starts systemd itself. This keeps service policy in the Decky backend and2323 prevents a reset from leaving a live remote session attached.2424 25+Authorization writers have an explicit second level in that hierarchy:26+`daemon-v1.lock` (and the global lifecycle lock when configured) is acquired27+before `<state_path>/authorization-v1.lock`. The daemon holds the authorization28+writer lease for its complete lifetime and serves handshakes from its validated29+in-memory state. An offline reset temporarily becomes the sole writer only30+after it owns the lifecycle scope. Writer-lease acquisition uses nonblocking31+`flock` with a fixed 250 ms bound, so an abandoned or hung management process32+cannot stall daemon startup or recovery indefinitely; terminating a crashed33+owner releases the kernel lease and a retry loads the last complete atomic34+state document. Live handshake contention fails closed immediately and may be35+retried by Portal.36+37+Status, `sigil enrollment show`, and invitation creation remain atomic38+read-only operations and do not take the writer lease. The authorization-v139+document, replay ledger, enrollment epoch, file names, permissions, and CLI40+schemas are unchanged, so no state migration is required.41+2542 The expected fingerprint is the redacted value returned by2643 `sigil appliance status`. It is an accidental-target confirmation interlock,2744 not a new authentication factor. Existing same-user file ownership and mode
M scripts/tests/authenticated-probe-proof.sh +6 -6