cat / goq
clone

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

fix/architecture-review-findings
goq / crates / sigil-host / src / authorization.rs
authorization.rs 39.6K
1use std::fs::{self, File, OpenOptions};2use std::io::{Read, Write};3use std::path::{Path, PathBuf};4use std::sync::{Arc, Mutex, MutexGuard, TryLockError};5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};67use anyhow::{Context, Result, ensure};8use iroh::EndpointId;9use serde::{Deserialize, Serialize};10use sigil_protocol::{11 INVITATION_CLOCK_SKEW_SECS, InvitationClaims, InvitationGrants, MAX_INVITATION_TTL_SECS,12 SignedInvitation,13};1415#[cfg(unix)]16use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};1718const STATE_VERSION: u16 = 1;19const STATE_FILE_NAME: &str = "authorization-v1.json";20const LOCK_FILE_NAME: &str = "authorization-v1.lock";21const MAX_STATE_FILE_LEN: u64 = 64 * 1024;22const MAX_CONSUMED_INVITATIONS: usize = 64;23const WRITER_LOCK_TIMEOUT: Duration = Duration::from_millis(250);24const WRITER_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(10);2526#[derive(Clone, Debug)]27pub struct AuthorizationStore {28 location: AuthorizationLocation,29 writer: Arc<AuthorizationWriter>,30}3132#[derive(Clone, Debug)]33struct AuthorizationLocation {34 state_directory: PathBuf,35 host: EndpointId,36}3738#[derive(Debug)]39struct 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}4647#[derive(Clone, Debug)]48pub enum AuthorizationPolicy {49 Required(AuthorizationStore),50 /// Explicit direct `sigil serve --identity ... --source test-pattern`51 /// proof mode. Configured hosts never select this branch.52 TestPatternProof,53 /// Explicitly insecure configured-host mode for development hardware tests.54 /// Ordinary release builds cannot select this branch.55 DevelopmentBypass,56}5758#[derive(Clone, Copy, Debug, PartialEq, Eq)]59pub struct AuthorizationSnapshot {60 pub epoch: u64,61 pub peer: Option<EndpointId>,62 pub grants: Option<InvitationGrants>,63 pub enrolled_at_unix: Option<u64>,64}6566#[derive(Clone, Copy, Debug, PartialEq, Eq)]67pub struct AuthorizationInspection {68 pub snapshot: AuthorizationSnapshot,69 pub storage_present: bool,70}7172#[derive(Clone, Copy, Debug, PartialEq, Eq)]73pub struct RevocationOutcome {74 pub had_enrollment: bool,75 pub previous_epoch: u64,76 pub current_epoch: u64,77}7879impl 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}9798#[derive(Clone, Debug, Serialize, Deserialize)]99#[serde(deny_unknown_fields)]100struct AuthorizationState {101 version: u16,102 host_node_id: String,103 enrollment_epoch: u64,104 enrollment: Option<Enrollment>,105 consumed_invitations: Vec<ConsumedInvitation>,106}107108#[derive(Clone, Debug, Serialize, Deserialize)]109#[serde(deny_unknown_fields)]110struct Enrollment {111 peer_node_id: String,112 grants: InvitationGrants,113 enrolled_at_unix: u64,114}115116#[derive(Clone, Debug, Serialize, Deserialize)]117#[serde(deny_unknown_fields)]118struct ConsumedInvitation {119 nonce: [u8; 32],120 expires_at_unix: u64,121}122123impl AuthorizationState {124 fn empty(host: EndpointId) -> Self {125 Self {126 version: STATE_VERSION,127 host_node_id: host.to_string(),128 enrollment_epoch: 1,129 enrollment: None,130 consumed_invitations: Vec::new(),131 }132 }133134 fn validate(&self, expected_host: EndpointId) -> Result<()> {135 ensure!(136 self.version == STATE_VERSION,137 "unsupported authorization state version"138 );139 ensure!(140 self.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),141 "authorization state belongs to a different Sigil host"142 );143 ensure!(144 self.enrollment_epoch != 0,145 "authorization epoch must be non-zero"146 );147 ensure!(148 self.consumed_invitations.len() <= MAX_CONSUMED_INVITATIONS,149 "authorization replay ledger exceeds its fixed bound"150 );151 if let Some(enrollment) = &self.enrollment {152 enrollment153 .peer_node_id154 .parse::<EndpointId>()155 .context("authorization enrollment contains an invalid peer node ID")?;156 enrollment.grants.validate()?;157 ensure!(158 enrollment.grants.contains(InvitationGrants::VIEW),159 "authorization enrollment must include view permission"160 );161 }162 for (index, consumed) in self.consumed_invitations.iter().enumerate() {163 ensure!(164 consumed.nonce != [0; 32],165 "consumed invitation nonce is zero"166 );167 ensure!(168 !self.consumed_invitations[..index]169 .iter()170 .any(|earlier| earlier.nonce == consumed.nonce),171 "authorization replay ledger contains a duplicate nonce"172 );173 }174 Ok(())175 }176177 fn prune_expired(&mut self, now: u64) -> bool {178 let before = self.consumed_invitations.len();179 self.consumed_invitations.retain(|consumed| {180 consumed181 .expires_at_unix182 .saturating_add(INVITATION_CLOCK_SKEW_SECS)183 >= now184 });185 before != self.consumed_invitations.len()186 }187}188189impl AuthorizationStore {190 pub fn open(state_directory: impl Into<PathBuf>, host: EndpointId) -> Result<Self> {191 let location = AuthorizationLocation {192 state_directory: state_directory.into(),193 host,194 };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()?;199 state.validate(host)?;200 Ok(Self {201 location,202 writer: Arc::new(AuthorizationWriter {203 state: Mutex::new(state),204 _lease: lock,205 }),206 })207 }208209 #[cfg(test)]210 fn issue_claims(211 &self,212 peer: EndpointId,213 grants: InvitationGrants,214 ttl_seconds: u64,215 now: u64,216 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 }230231 /// 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],241 ) -> Result<InvitationClaims> {242 grants.validate()?;243 ensure!(244 grants.contains(InvitationGrants::VIEW),245 "invitations must grant view permission"246 );247 ensure!(248 (1..=MAX_INVITATION_TTL_SECS).contains(&ttl_seconds),249 "invitation TTL must be between 1 and {MAX_INVITATION_TTL_SECS} seconds"250 );251 ensure!(252 snapshot.peer.is_none() && snapshot.grants.is_none(),253 "a Portal peer is already enrolled; revoke it before issuing another invitation"254 );255 InvitationClaims::new(256 *host.as_bytes(),257 *peer.as_bytes(),258 now,259 now.checked_add(ttl_seconds)260 .context("invitation expiry overflow")?,261 snapshot.epoch,262 nonce,263 grants,264 )265 .map_err(Into::into)266 }267268 pub fn authorize_or_redeem(269 &self,270 remote: EndpointId,271 invitation_token: Option<&str>,272 now: u64,273 ) -> Result<InvitationGrants> {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);281282 let result = match invitation_token {283 None => {284 let enrollment = candidate285 .enrollment286 .as_ref()287 .context("Portal peer is not enrolled")?;288 ensure!(289 enrollment.peer_node_id.parse::<EndpointId>()? == remote,290 "Portal peer is not enrolled"291 );292 enrollment.grants293 }294 Some(token) => {295 ensure!(296 candidate.enrollment.is_none(),297 "an invitation cannot be used after enrollment"298 );299 let invitation = SignedInvitation::decode(token)300 .context("decoding and verifying enrollment invitation")?;301 let claims = invitation.claims;302 ensure!(303 claims.host_node_id == *self.location.host.as_bytes(),304 "invitation belongs to a different Sigil host"305 );306 ensure!(307 claims.intended_peer_id == *remote.as_bytes(),308 "invitation is bound to a different Portal peer"309 );310 ensure!(311 claims.enrollment_epoch == candidate.enrollment_epoch,312 "invitation enrollment epoch is stale"313 );314 ensure!(315 claims.issued_at_unix <= now.saturating_add(INVITATION_CLOCK_SKEW_SECS),316 "invitation was issued too far in the future"317 );318 ensure!(now <= claims.expires_at_unix, "invitation has expired");319 ensure!(320 claims.grants.contains(InvitationGrants::VIEW),321 "invitation does not grant view permission"322 );323 ensure!(324 !candidate325 .consumed_invitations326 .iter()327 .any(|consumed| consumed.nonce == claims.nonce),328 "invitation has already been consumed"329 );330 ensure!(331 candidate.consumed_invitations.len() < MAX_CONSUMED_INVITATIONS,332 "authorization replay ledger is full"333 );334 candidate.enrollment = Some(Enrollment {335 peer_node_id: remote.to_string(),336 grants: claims.grants,337 enrolled_at_unix: now,338 });339 candidate.consumed_invitations.push(ConsumedInvitation {340 nonce: claims.nonce,341 expires_at_unix: claims.expires_at_unix,342 });343 changed = true;344 claims.grants345 }346 };347348 if changed {349 self.location.write_state(&candidate)?;350 *state = candidate;351 }352 Ok(result)353 }354355 #[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)360 }361362 /// Inspect durable enrollment without creating the state directory or lock.363 /// The authorization writer uses atomic replacement, so an unlocked reader364 /// observes either the previous complete document or the next one.365 pub fn inspect_existing(366 state_directory: impl Into<PathBuf>,367 host: EndpointId,368 ) -> Result<AuthorizationInspection> {369 let location = AuthorizationLocation {370 state_directory: state_directory.into(),371 host,372 };373 let directory_metadata = match fs::symlink_metadata(&location.state_directory) {374 Ok(metadata) => metadata,375 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {376 return Ok(AuthorizationInspection {377 snapshot: AuthorizationSnapshot {378 epoch: 1,379 peer: None,380 grants: None,381 enrolled_at_unix: None,382 },383 storage_present: false,384 });385 }386 Err(error) => return Err(error).context("inspecting authorization state directory"),387 };388 ensure!(389 !directory_metadata.file_type().is_symlink(),390 "authorization state directory is a symlink"391 );392 ensure!(393 directory_metadata.is_dir(),394 "authorization state path is not a directory"395 );396 #[cfg(unix)]397 {398 ensure!(399 directory_metadata.mode() & 0o077 == 0,400 "authorization state directory is accessible by group or others"401 );402 ensure!(403 directory_metadata.uid() == unsafe { libc::geteuid() },404 "authorization state directory has the wrong owner"405 );406 }407408 let path = location.state_path();409 let mut options = OpenOptions::new();410 options.read(true);411 #[cfg(unix)]412 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);413 let mut file = match options.open(&path) {414 Ok(file) => file,415 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {416 return Ok(AuthorizationInspection {417 snapshot: AuthorizationSnapshot {418 epoch: 1,419 peer: None,420 grants: None,421 enrolled_at_unix: None,422 },423 storage_present: false,424 });425 }426 Err(error) => return Err(error).context("opening authorization state"),427 };428 location.validate_secure_file(&file, &path)?;429 let metadata = file.metadata()?;430 ensure!(431 metadata.len() <= MAX_STATE_FILE_LEN,432 "authorization state exceeds its fixed size bound"433 );434 let mut bytes = Vec::with_capacity(metadata.len() as usize);435 Read::by_ref(&mut file)436 .take(MAX_STATE_FILE_LEN.saturating_add(1))437 .read_to_end(&mut bytes)?;438 ensure!(439 bytes.len() as u64 <= MAX_STATE_FILE_LEN,440 "authorization state exceeds its fixed size bound"441 );442 ensure!(!bytes.is_empty(), "authorization state is empty");443 let state: AuthorizationState =444 serde_json::from_slice(&bytes).context("parsing authorization state")?;445 state.validate(host)?;446 let snapshot = AuthorizationSnapshot::from_state(&state)?;447 Ok(AuthorizationInspection {448 snapshot,449 storage_present: true,450 })451 }452453 pub fn revoke_with_outcome(&self, now: u64) -> Result<RevocationOutcome> {454 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 = candidate460 .enrollment_epoch461 .checked_add(1)462 .context("authorization epoch exhausted")?;463 candidate.prune_expired(now);464 self.location.write_state(&candidate)?;465 *state = candidate;466 Ok(RevocationOutcome {467 had_enrollment,468 previous_epoch,469 current_epoch: state.enrollment_epoch,470 })471 }472473 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}485486impl AuthorizationLocation {487 fn state_path(&self) -> PathBuf {488 self.state_directory.join(STATE_FILE_NAME)489 }490491 fn lock_path(&self) -> PathBuf {492 self.state_directory.join(LOCK_FILE_NAME)493 }494495 fn ensure_state_directory(&self) -> Result<()> {496 if self.state_directory.exists() {497 let metadata = fs::symlink_metadata(&self.state_directory)?;498 ensure!(499 !metadata.file_type().is_symlink(),500 "authorization state directory is a symlink"501 );502 ensure!(503 metadata.is_dir(),504 "authorization state path is not a directory"505 );506 #[cfg(unix)]507 {508 ensure!(509 metadata.mode() & 0o077 == 0,510 "authorization state directory is accessible by group or others"511 );512 ensure!(513 metadata.uid() == unsafe { libc::geteuid() },514 "authorization state directory has the wrong owner"515 );516 }517 } else {518 #[cfg(unix)]519 {520 let mut builder = fs::DirBuilder::new();521 builder522 .recursive(true)523 .mode(0o700)524 .create(&self.state_directory)?;525 }526 #[cfg(not(unix))]527 fs::create_dir_all(&self.state_directory)?;528 }529 Ok(())530 }531532 fn open_lock(&self) -> Result<File> {533 let mut options = OpenOptions::new();534 options.read(true).write(true).create(true);535 #[cfg(unix)]536 options537 .mode(0o600)538 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);539 let file = options540 .open(self.lock_path())541 .context("opening authorization lock")?;542 self.validate_secure_file(&file, &self.lock_path())?;543 Ok(file)544 }545546 fn lock_exclusive_bounded(&self, file: &File, timeout: Duration) -> Result<()> {547 #[cfg(unix)]548 {549 use std::os::fd::AsRawFd;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 }571 }572 Ok(())573 }574575 fn load_state(&self) -> Result<AuthorizationState> {576 let path = self.state_path();577 let mut options = OpenOptions::new();578 options.read(true);579 #[cfg(unix)]580 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);581 let mut file = match options.open(&path) {582 Ok(file) => file,583 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {584 return Ok(AuthorizationState::empty(self.host));585 }586 Err(error) => return Err(error).context("opening authorization state"),587 };588 self.validate_secure_file(&file, &path)?;589 let metadata = file.metadata()?;590 ensure!(591 metadata.len() <= MAX_STATE_FILE_LEN,592 "authorization state exceeds its fixed size bound"593 );594 let mut bytes = Vec::with_capacity(metadata.len() as usize);595 Read::by_ref(&mut file)596 .take(MAX_STATE_FILE_LEN.saturating_add(1))597 .read_to_end(&mut bytes)?;598 ensure!(599 bytes.len() as u64 <= MAX_STATE_FILE_LEN,600 "authorization state exceeds its fixed size bound"601 );602 ensure!(!bytes.is_empty(), "authorization state is empty");603 serde_json::from_slice(&bytes).context("parsing authorization state")604 }605606 fn write_state(&self, state: &AuthorizationState) -> Result<()> {607 state.validate(self.host)?;608 let bytes = serde_json::to_vec_pretty(state)?;609 ensure!(610 bytes.len() as u64 <= MAX_STATE_FILE_LEN,611 "authorization state exceeds its fixed size bound"612 );613 let mut random = [0_u8; 8];614 getrandom::fill(&mut random).context("generating authorization temporary-file name")?;615 let temporary = self.state_directory.join(format!(616 ".{STATE_FILE_NAME}.{:016x}.tmp",617 u64::from_be_bytes(random)618 ));619 let mut options = OpenOptions::new();620 options.write(true).create_new(true);621 #[cfg(unix)]622 options623 .mode(0o600)624 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);625 let mut file = options626 .open(&temporary)627 .context("creating authorization state temporary file")?;628 let write_result = (|| -> Result<()> {629 file.write_all(&bytes)?;630 file.write_all(b"\n")?;631 file.sync_all()?;632 fs::rename(&temporary, self.state_path())?;633 File::open(&self.state_directory)?.sync_all()?;634 Ok(())635 })();636 if write_result.is_err() {637 let _ = fs::remove_file(&temporary);638 }639 write_result640 }641642 fn validate_secure_file(&self, file: &File, path: &Path) -> Result<()> {643 let metadata = file.metadata()?;644 ensure!(645 metadata.is_file(),646 "{} is not a regular file",647 path.display()648 );649 #[cfg(unix)]650 {651 ensure!(652 metadata.mode() & 0o077 == 0,653 "{} is accessible by group or others",654 path.display()655 );656 ensure!(657 metadata.uid() == unsafe { libc::geteuid() },658 "{} has the wrong owner",659 path.display()660 );661 }662 Ok(())663 }664}665666impl AuthorizationPolicy {667 pub fn authorize_or_redeem(668 &self,669 remote: EndpointId,670 invitation_token: Option<&str>,671 now: u64,672 ) -> Result<InvitationGrants> {673 match self {674 Self::Required(store) => store.authorize_or_redeem(remote, invitation_token, now),675 Self::TestPatternProof | Self::DevelopmentBypass => {676 ensure!(677 invitation_token.is_none(),678 "unrestricted development modes do not accept invitations"679 );680 Ok(InvitationGrants::ALL)681 }682 }683 }684}685686pub fn unix_timestamp_now() -> Result<u64> {687 Ok(SystemTime::now()688 .duration_since(UNIX_EPOCH)689 .context("system clock is before the Unix epoch")?690 .as_secs())691}692693pub fn grant_names(grants: InvitationGrants) -> &'static str {694 match (695 grants.contains(InvitationGrants::POINTER_KEYBOARD),696 grants.contains(InvitationGrants::GAMEPAD),697 ) {698 (false, false) => "view",699 (true, false) => "view,pointer-keyboard",700 (false, true) => "view,gamepad",701 (true, true) => "view,pointer-keyboard,gamepad",702 }703}704705#[cfg(test)]706mod tests {707 use super::*;708 use iroh::SecretKey;709 use sigil_protocol::SignedInvitation;710 #[cfg(unix)]711 use std::os::unix::fs::PermissionsExt;712 #[cfg(unix)]713 use std::process::Command;714715 fn store() -> (tempfile::TempDir, SecretKey, SecretKey, AuthorizationStore) {716 let directory = tempfile::tempdir().unwrap();717 #[cfg(unix)]718 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();719 let host = SecretKey::from_bytes(&[7; 32]);720 let peer = SecretKey::from_bytes(&[9; 32]);721 let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();722 (directory, host, peer, store)723 }724725 fn token(726 store: &AuthorizationStore,727 host: &SecretKey,728 peer: EndpointId,729 now: u64,730 nonce: [u8; 32],731 grants: InvitationGrants,732 ) -> String {733 let claims = store.issue_claims(peer, grants, 600, now, nonce).unwrap();734 SignedInvitation::issue(claims, &host.to_bytes())735 .unwrap()736 .encode()737 }738739 #[test]740 fn invitation_enrolls_once_and_reconnects_ticket_free() {741 let (_directory, host, peer, store) = store();742 let grants = InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD);743 let token = token(&store, &host, peer.public(), 1_000, [1; 32], grants);744 assert_eq!(745 store746 .authorize_or_redeem(peer.public(), Some(&token), 1_001)747 .unwrap(),748 grants749 );750 assert_eq!(751 store752 .authorize_or_redeem(peer.public(), None, 1_002)753 .unwrap(),754 grants755 );756 assert!(757 store758 .authorize_or_redeem(peer.public(), Some(&token), 1_002)759 .unwrap_err()760 .to_string()761 .contains("after enrollment")762 );763764 let state_directory = store.location.state_directory.clone();765 drop(store);766 let reopened = AuthorizationStore::open(state_directory, host.public()).unwrap();767 assert_eq!(768 reopened769 .authorize_or_redeem(peer.public(), None, 1_003)770 .unwrap(),771 grants772 );773 }774775 #[test]776 fn read_only_inspection_never_creates_state_and_retains_enrollment_time() {777 let root = tempfile::tempdir().unwrap();778 #[cfg(unix)]779 fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap();780 let missing = root.path().join("missing-state");781 let host = SecretKey::from_bytes(&[7; 32]);782 let inspection = AuthorizationStore::inspect_existing(&missing, host.public()).unwrap();783 assert!(!inspection.storage_present);784 assert_eq!(inspection.snapshot.epoch, 1);785 assert!(inspection.snapshot.peer.is_none());786 assert!(!missing.exists());787788 let (directory, host, peer, store) = store();789 let token = token(790 &store,791 &host,792 peer.public(),793 1_000,794 [11; 32],795 InvitationGrants::ALL,796 );797 store798 .authorize_or_redeem(peer.public(), Some(&token), 1_001)799 .unwrap();800 let inspection =801 AuthorizationStore::inspect_existing(directory.path(), host.public()).unwrap();802 assert!(inspection.storage_present);803 assert_eq!(inspection.snapshot.peer, Some(peer.public()));804 assert_eq!(inspection.snapshot.enrolled_at_unix, Some(1_001));805 }806807 #[test]808 fn peer_host_expiry_and_future_time_fail_closed() {809 let (_directory, host, peer, store) = store();810 let other_peer = SecretKey::from_bytes(&[10; 32]);811 let token = token(812 &store,813 &host,814 peer.public(),815 1_000,816 [2; 32],817 InvitationGrants::VIEW,818 );819 assert!(820 store821 .authorize_or_redeem(other_peer.public(), Some(&token), 1_001)822 .is_err()823 );824 assert!(825 store826 .authorize_or_redeem(peer.public(), Some(&token), 1_601)827 .is_err()828 );829 assert!(830 store831 .authorize_or_redeem(peer.public(), Some(&token), 900)832 .is_err()833 );834835 let other_host = SecretKey::from_bytes(&[11; 32]);836 let claims = InvitationClaims::new(837 *other_host.public().as_bytes(),838 *peer.public().as_bytes(),839 1_000,840 1_600,841 1,842 [3; 32],843 InvitationGrants::VIEW,844 )845 .unwrap();846 let cross_host = SignedInvitation::issue(claims, &other_host.to_bytes())847 .unwrap()848 .encode();849 assert!(850 store851 .authorize_or_redeem(peer.public(), Some(&cross_host), 1_001)852 .is_err()853 );854 }855856 #[test]857 fn revoke_invalidates_outstanding_epoch_and_clears_peer() {858 let (_directory, host, peer, store) = store();859 let token = token(860 &store,861 &host,862 peer.public(),863 1_000,864 [4; 32],865 InvitationGrants::VIEW,866 );867 assert!(!store.revoke_with_outcome(1_001).unwrap().had_enrollment);868 assert!(869 store870 .authorize_or_redeem(peer.public(), Some(&token), 1_002)871 .unwrap_err()872 .to_string()873 .contains("epoch")874 );875 assert_eq!(store.snapshot().unwrap().epoch, 2);876 assert!(store.snapshot().unwrap().peer.is_none());877 }878879 #[test]880 fn copied_state_is_bound_to_host_and_permissions_are_strict() {881 let (_directory, host, peer, store) = store();882 let token = token(883 &store,884 &host,885 peer.public(),886 1_000,887 [5; 32],888 InvitationGrants::VIEW.union(InvitationGrants::POINTER_KEYBOARD),889 );890 store891 .authorize_or_redeem(peer.public(), Some(&token), 1_001)892 .unwrap();893 let other_host = SecretKey::from_bytes(&[12; 32]);894 assert!(895 AuthorizationStore::open(store.location.state_directory.clone(), other_host.public())896 .is_err()897 );898 let snapshot = store.snapshot().unwrap();899 assert!(900 snapshot901 .grants902 .unwrap()903 .contains(InvitationGrants::POINTER_KEYBOARD)904 );905 assert!(!snapshot.grants.unwrap().contains(InvitationGrants::GAMEPAD));906 }907908 #[test]909 fn consumed_nonce_replay_survives_revoke_and_store_reopen() {910 let (_directory, host, peer, store) = store();911 let nonce = [6; 32];912 let first = token(913 &store,914 &host,915 peer.public(),916 1_000,917 nonce,918 InvitationGrants::VIEW,919 );920 store921 .authorize_or_redeem(peer.public(), Some(&first), 1_001)922 .unwrap();923 assert!(store.revoke_with_outcome(1_002).unwrap().had_enrollment);924925 let state_directory = store.location.state_directory.clone();926 drop(store);927 let reopened = AuthorizationStore::open(state_directory, host.public()).unwrap();928 let replay = token(929 &reopened,930 &host,931 peer.public(),932 1_003,933 nonce,934 InvitationGrants::VIEW,935 );936 let state_before = fs::read(reopened.location.state_path()).unwrap();937 let error = reopened938 .authorize_or_redeem(peer.public(), Some(&replay), 1_004)939 .unwrap_err();940941 assert!(error.to_string().contains("already been consumed"));942 assert_eq!(943 fs::read(reopened.location.state_path()).unwrap(),944 state_before945 );946 let snapshot = reopened.snapshot().unwrap();947 assert_eq!(snapshot.epoch, 2);948 assert!(snapshot.peer.is_none());949 }950951 #[test]952 fn development_bypass_grants_all_without_touching_persistent_state() {953 let directory = tempfile::tempdir().unwrap();954 let remote = SecretKey::from_bytes(&[29; 32]).public();955 let policy = AuthorizationPolicy::DevelopmentBypass;956957 assert_eq!(958 policy.authorize_or_redeem(remote, None, 1_000).unwrap(),959 InvitationGrants::ALL960 );961 assert!(962 policy963 .authorize_or_redeem(remote, Some("not-an-invitation"), 1_000)964 .is_err()965 );966 assert!(fs::read_dir(directory.path()).unwrap().next().is_none());967 }968969 #[test]970 fn invitation_clock_boundaries_are_inclusive_and_fail_closed_outside_skew() {971 let (_directory, host, peer, expiry_store) = store();972 let expires_at = token(973 &expiry_store,974 &host,975 peer.public(),976 1_000,977 [7; 32],978 InvitationGrants::VIEW,979 );980 assert!(981 expiry_store982 .authorize_or_redeem(peer.public(), Some(&expires_at), 1_600)983 .is_ok()984 );985986 let (_directory, host, peer, skew_store) = store();987 let exact_skew = token(988 &skew_store,989 &host,990 peer.public(),991 1_060,992 [8; 32],993 InvitationGrants::VIEW,994 );995 assert!(996 skew_store997 .authorize_or_redeem(peer.public(), Some(&exact_skew), 1_000)998 .is_ok()999 );10001001 let (_directory, host, peer, outside_store) = store();1002 let outside_skew = token(1003 &outside_store,1004 &host,1005 peer.public(),1006 1_061,1007 [9; 32],1008 InvitationGrants::VIEW,1009 );1010 let error = outside_store1011 .authorize_or_redeem(peer.public(), Some(&outside_skew), 1_000)1012 .unwrap_err();1013 assert!(error.to_string().contains("too far in the future"));1014 }10151016 #[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();10231024 assert!(error.to_string().contains("authorization writer is busy"));1025 assert!(elapsed >= WRITER_LOCK_TIMEOUT);1026 assert!(elapsed < Duration::from_secs(2));10271028 drop(store);1029 AuthorizationStore::open(directory.path(), host.public()).unwrap();1030 }10311032 #[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();10371038 let error = store1039 .authorize_or_redeem(peer.public(), None, 1_000)1040 .unwrap_err();10411042 assert!(error.to_string().contains("authorization writer is busy"));1043 assert!(started.elapsed() < Duration::from_millis(50));1044 }10451046 #[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();10611062 assert_eq!(claims.enrollment_epoch, 1);1063 assert_eq!(claims.intended_peer_id, *peer.public().as_bytes());1064 }10651066 #[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();10821083 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 }10951096 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();11051106 let recovered = AuthorizationStore::open(directory.path(), host.public()).unwrap();1107 assert_eq!(recovered.snapshot().unwrap().epoch, 2);1108 }11091110 #[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 }11231124 #[cfg(unix)]1125 #[test]1126 fn adversarial_lock_files_and_crash_temps_fail_closed_or_recover_atomically() {1127 use std::os::unix::fs::symlink;11281129 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());11361137 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());11471148 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();11601161 let recovered = AuthorizationStore::open(recovery_directory.path(), host.public()).unwrap();1162 assert_eq!(recovered.snapshot().unwrap().epoch, 2);1163 }1164}