cat / goq
clone

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

1-n-sigls
goq / crates / sigil-host / src / authorization.rs
authorization.rs 67.0K
1use std::collections::HashSet;2use std::fs::{self, File, OpenOptions};3use std::io::{Read, Write};4use std::path::{Path, PathBuf};5use std::sync::{Arc, Mutex, MutexGuard, TryLockError};6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};78use anyhow::{Context, Result, ensure};9use iroh::EndpointId;10use serde::{Deserialize, Serialize};11use sha2::{Digest, Sha256};12use sigil_protocol::{13 INVITATION_CLOCK_SKEW_SECS, InvitationClaims, InvitationGrants, MAX_INVITATION_TTL_SECS,14 SignedInvitation,15};1617#[cfg(unix)]18use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};1920const STATE_VERSION: u16 = 2;21const STATE_FILE_NAME: &str = "authorization-v2.json";22const LEGACY_STATE_VERSION: u16 = 1;23const LEGACY_STATE_FILE_NAME: &str = "authorization-v1.json";24const LOCK_FILE_NAME: &str = "authorization-v1.lock";25const MAX_STATE_FILE_LEN: u64 = 64 * 1024;26const MAX_CONSUMED_INVITATIONS: usize = 64;27pub const MAX_ENROLLED_VIEWERS: usize = 32;28const MAX_SECURITY_AUDIT_ENTRIES: usize = 64;29const VIEWER_HANDLE_RANDOM_BYTES: usize = 8;30const WRITER_LOCK_TIMEOUT: Duration = Duration::from_millis(250);31const WRITER_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(10);3233#[derive(Clone, Debug)]34pub struct AuthorizationStore {35 location: AuthorizationLocation,36 writer: Arc<AuthorizationWriter>,37}3839#[derive(Clone, Debug)]40struct AuthorizationLocation {41 state_directory: PathBuf,42 host: EndpointId,43}4445#[derive(Debug)]46struct AuthorizationWriter {47 state: Mutex<AuthorizationState>,48 /// The process that owns this descriptor is the only durable writer.49 /// Configured service and offline recovery paths acquire their lifecycle50 /// lease before this authorization lease.51 _lease: File,52}5354#[derive(Clone, Debug)]55pub enum AuthorizationPolicy {56 Required(AuthorizationStore),57 /// Explicit direct `sigil serve --identity ... --source test-pattern`58 /// proof mode. Configured hosts never select this branch.59 TestPatternProof,60 /// Explicitly insecure configured-host mode for development hardware tests.61 /// Ordinary release builds cannot select this branch.62 DevelopmentBypass,63}6465#[derive(Clone, Debug, PartialEq, Eq)]66pub struct AuthorizationSnapshot {67 pub epoch: u64,68 pub peer: Option<EndpointId>,69 pub grants: Option<InvitationGrants>,70 pub enrolled_at_unix: Option<u64>,71 pub committed_revision: u64,72 pub viewers: Vec<EnrolledViewer>,73}7475#[derive(Clone, Debug, PartialEq, Eq)]76pub struct AuthorizationInspection {77 pub snapshot: AuthorizationSnapshot,78 pub storage_present: bool,79}8081#[derive(Clone, Copy, Debug, PartialEq, Eq)]82pub struct RevocationOutcome {83 pub had_enrollment: bool,84 pub previous_epoch: u64,85 pub current_epoch: u64,86}8788#[derive(Clone, Debug, PartialEq, Eq, Serialize)]89pub struct EnrolledViewer {90 pub handle: String,91 #[serde(skip_serializing)]92 pub peer: EndpointId,93 pub grants: InvitationGrants,94 pub enrolled_at_unix: u64,95 pub authorization_revision: u64,96}9798#[derive(Clone, Debug, PartialEq, Eq)]99pub struct AuthorizedViewer {100 pub handle: String,101 pub grants: InvitationGrants,102 pub authorization_revision: u64,103 pub committed_revision: u64,104}105106#[derive(Clone, Debug, PartialEq, Eq)]107pub struct AuthorizationMutation {108 pub handle: String,109 pub peer: EndpointId,110 pub previous_grants: InvitationGrants,111 pub current_grants: Option<InvitationGrants>,112 pub authorization_revision: u64,113 pub committed_revision: u64,114}115116impl AuthorizationSnapshot {117 fn from_state(state: &AuthorizationState) -> Result<Self> {118 let viewers = state119 .enrollments120 .iter()121 .map(Enrollment::as_public)122 .collect::<Result<Vec<_>>>()?;123 let first = viewers.first();124 Ok(Self {125 epoch: state.enrollment_epoch,126 peer: first.map(|viewer| viewer.peer),127 grants: first.map(|viewer| viewer.grants),128 enrolled_at_unix: first.map(|viewer| viewer.enrolled_at_unix),129 committed_revision: state.committed_revision,130 viewers,131 })132 }133}134135#[derive(Clone, Debug, Serialize, Deserialize)]136#[serde(deny_unknown_fields)]137struct AuthorizationState {138 version: u16,139 host_node_id: String,140 enrollment_epoch: u64,141 committed_revision: u64,142 enrollments: Vec<Enrollment>,143 consumed_invitations: Vec<ConsumedInvitation>,144 security_audit: Vec<SecurityAuditEntry>,145}146147#[derive(Clone, Debug, Serialize, Deserialize)]148#[serde(deny_unknown_fields)]149struct Enrollment {150 peer_node_id: String,151 handle: String,152 grants: InvitationGrants,153 enrolled_at_unix: u64,154 authorization_revision: u64,155}156157impl Enrollment {158 fn as_public(&self) -> Result<EnrolledViewer> {159 Ok(EnrolledViewer {160 handle: self.handle.clone(),161 peer: self.peer_node_id.parse::<EndpointId>()?,162 grants: self.grants,163 enrolled_at_unix: self.enrolled_at_unix,164 authorization_revision: self.authorization_revision,165 })166 }167}168169#[derive(Clone, Debug, Serialize, Deserialize)]170#[serde(deny_unknown_fields)]171struct SecurityAuditEntry {172 revision: u64,173 occurred_at_unix: u64,174 action: SecurityAuditAction,175 handle: String,176 previous_grants: Option<InvitationGrants>,177 current_grants: Option<InvitationGrants>,178}179180#[derive(Clone, Copy, Debug, Serialize, Deserialize)]181#[serde(rename_all = "snake_case")]182enum SecurityAuditAction {183 Enrolled,184 GrantsReplaced,185 Revoked,186 ResetAll,187 MigratedV1,188}189190#[derive(Clone, Debug, Serialize, Deserialize)]191#[serde(deny_unknown_fields)]192struct LegacyAuthorizationState {193 version: u16,194 host_node_id: String,195 enrollment_epoch: u64,196 enrollment: Option<LegacyEnrollment>,197 consumed_invitations: Vec<ConsumedInvitation>,198}199200#[derive(Clone, Debug, Serialize, Deserialize)]201#[serde(deny_unknown_fields)]202struct LegacyEnrollment {203 peer_node_id: String,204 grants: InvitationGrants,205 enrolled_at_unix: u64,206}207208#[derive(Clone, Debug, Serialize, Deserialize)]209#[serde(deny_unknown_fields)]210struct ConsumedInvitation {211 nonce: [u8; 32],212 expires_at_unix: u64,213}214215impl AuthorizationState {216 fn empty(host: EndpointId) -> Self {217 Self {218 version: STATE_VERSION,219 host_node_id: host.to_string(),220 enrollment_epoch: 1,221 committed_revision: 1,222 enrollments: Vec::new(),223 consumed_invitations: Vec::new(),224 security_audit: Vec::new(),225 }226 }227228 fn from_legacy(legacy: LegacyAuthorizationState, expected_host: EndpointId) -> Result<Self> {229 ensure!(230 legacy.version == LEGACY_STATE_VERSION,231 "unsupported legacy authorization state version"232 );233 ensure!(234 legacy.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),235 "authorization state belongs to a different Sigil host"236 );237 ensure!(238 legacy.enrollment_epoch != 0,239 "authorization epoch must be non-zero"240 );241 ensure!(242 legacy.consumed_invitations.len() <= MAX_CONSUMED_INVITATIONS,243 "authorization replay ledger exceeds its fixed bound"244 );245 let mut state = Self::empty(expected_host);246 state.enrollment_epoch = legacy.enrollment_epoch;247 state.consumed_invitations = legacy.consumed_invitations;248 if let Some(enrollment) = legacy.enrollment {249 let peer = enrollment250 .peer_node_id251 .parse::<EndpointId>()252 .context("legacy authorization enrollment contains an invalid peer node ID")?;253 enrollment.grants.validate()?;254 ensure!(255 enrollment.grants.contains(InvitationGrants::VIEW),256 "legacy authorization enrollment must include view permission"257 );258 let handle = random_viewer_handle(&[])?;259 state.enrollments.push(Enrollment {260 peer_node_id: peer.to_string(),261 handle: handle.clone(),262 grants: enrollment.grants,263 enrolled_at_unix: enrollment.enrolled_at_unix,264 authorization_revision: 1,265 });266 state.push_audit(SecurityAuditEntry {267 revision: 1,268 occurred_at_unix: enrollment.enrolled_at_unix,269 action: SecurityAuditAction::MigratedV1,270 handle,271 previous_grants: Some(enrollment.grants),272 current_grants: Some(enrollment.grants),273 });274 }275 state.validate(expected_host)?;276 Ok(state)277 }278279 fn validate(&self, expected_host: EndpointId) -> Result<()> {280 ensure!(281 self.version == STATE_VERSION,282 "unsupported authorization state version"283 );284 ensure!(285 self.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),286 "authorization state belongs to a different Sigil host"287 );288 ensure!(289 self.enrollment_epoch != 0,290 "authorization epoch must be non-zero"291 );292 ensure!(293 self.committed_revision != 0,294 "authorization committed revision must be non-zero"295 );296 ensure!(297 self.enrollments.len() <= MAX_ENROLLED_VIEWERS,298 "authorization enrollment map exceeds its fixed bound"299 );300 ensure!(301 self.consumed_invitations.len() <= MAX_CONSUMED_INVITATIONS,302 "authorization replay ledger exceeds its fixed bound"303 );304 ensure!(305 self.security_audit.len() <= MAX_SECURITY_AUDIT_ENTRIES,306 "authorization security audit exceeds its fixed bound"307 );308 let mut peers = HashSet::with_capacity(self.enrollments.len());309 let mut handles = HashSet::with_capacity(self.enrollments.len());310 for enrollment in &self.enrollments {311 let peer = enrollment312 .peer_node_id313 .parse::<EndpointId>()314 .context("authorization enrollment contains an invalid peer node ID")?;315 ensure!(316 peers.insert(peer),317 "authorization contains a duplicate peer"318 );319 validate_viewer_handle(&enrollment.handle)?;320 ensure!(321 handles.insert(enrollment.handle.as_str()),322 "authorization contains a duplicate viewer handle"323 );324 enrollment.grants.validate()?;325 ensure!(326 enrollment.grants.contains(InvitationGrants::VIEW),327 "authorization enrollment must include view permission"328 );329 ensure!(330 enrollment.authorization_revision != 0331 && enrollment.authorization_revision <= self.committed_revision,332 "authorization enrollment revision is invalid"333 );334 }335 for (index, consumed) in self.consumed_invitations.iter().enumerate() {336 ensure!(337 consumed.nonce != [0; 32],338 "consumed invitation nonce is zero"339 );340 ensure!(341 !self.consumed_invitations[..index]342 .iter()343 .any(|earlier| earlier.nonce == consumed.nonce),344 "authorization replay ledger contains a duplicate nonce"345 );346 }347 let mut last_audit_revision = 0;348 for audit in &self.security_audit {349 ensure!(350 audit.revision != 0351 && audit.revision <= self.committed_revision352 && audit.revision >= last_audit_revision,353 "authorization security audit revision is invalid"354 );355 validate_viewer_handle(&audit.handle)?;356 if let Some(grants) = audit.previous_grants {357 grants.validate()?;358 }359 if let Some(grants) = audit.current_grants {360 grants.validate()?;361 }362 last_audit_revision = audit.revision;363 }364 Ok(())365 }366367 fn prune_expired(&mut self, now: u64) -> bool {368 let before = self.consumed_invitations.len();369 self.consumed_invitations.retain(|consumed| {370 consumed371 .expires_at_unix372 .saturating_add(INVITATION_CLOCK_SKEW_SECS)373 >= now374 });375 before != self.consumed_invitations.len()376 }377378 fn next_revision(&self) -> Result<u64> {379 self.committed_revision380 .checked_add(1)381 .context("authorization committed revision exhausted")382 }383384 fn push_audit(&mut self, entry: SecurityAuditEntry) {385 if self.security_audit.len() == MAX_SECURITY_AUDIT_ENTRIES {386 self.security_audit.remove(0);387 }388 self.security_audit.push(entry);389 }390}391392fn validate_viewer_handle(handle: &str) -> Result<()> {393 ensure!(394 handle.len() == "viewer-".len() + VIEWER_HANDLE_RANDOM_BYTES * 2395 && handle.starts_with("viewer-")396 && handle["viewer-".len()..]397 .bytes()398 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),399 "authorization viewer handle is invalid"400 );401 Ok(())402}403404fn random_viewer_handle(existing: &[Enrollment]) -> Result<String> {405 for _ in 0..16 {406 let mut random = [0_u8; VIEWER_HANDLE_RANDOM_BYTES];407 getrandom::fill(&mut random).context("generating opaque viewer handle")?;408 let handle = format!("viewer-{:016x}", u64::from_be_bytes(random));409 if !existing.iter().any(|viewer| viewer.handle == handle) {410 return Ok(handle);411 }412 }413 anyhow::bail!("could not allocate a unique opaque viewer handle")414}415416impl AuthorizationStore {417 pub fn open(state_directory: impl Into<PathBuf>, host: EndpointId) -> Result<Self> {418 let location = AuthorizationLocation {419 state_directory: state_directory.into(),420 host,421 };422 location.ensure_state_directory()?;423 let lock = location.open_lock()?;424 location.lock_exclusive_bounded(&lock, WRITER_LOCK_TIMEOUT)?;425 let (state, migrated) = location.load_state()?;426 state.validate(host)?;427 if migrated {428 location.write_state(&state)?;429 location.remove_legacy_state()?;430 }431 Ok(Self {432 location,433 writer: Arc::new(AuthorizationWriter {434 state: Mutex::new(state),435 _lease: lock,436 }),437 })438 }439440 #[cfg(test)]441 fn issue_claims(442 &self,443 peer: EndpointId,444 grants: InvitationGrants,445 ttl_seconds: u64,446 now: u64,447 nonce: [u8; 32],448 ) -> Result<InvitationClaims> {449 let state = self.lock_state()?;450 state.validate(self.location.host)?;451 Self::issue_claims_from_snapshot(452 self.location.host,453 AuthorizationSnapshot::from_state(&state)?,454 peer,455 grants,456 ttl_seconds,457 now,458 nonce,459 )460 }461462 /// Issue claims from one validated atomic read. Invitation creation is463 /// read-only: the daemon may remain the sole writer while it is running.464 pub fn issue_claims_from_snapshot(465 host: EndpointId,466 snapshot: AuthorizationSnapshot,467 peer: EndpointId,468 grants: InvitationGrants,469 ttl_seconds: u64,470 now: u64,471 nonce: [u8; 32],472 ) -> Result<InvitationClaims> {473 grants.validate()?;474 ensure!(475 grants.contains(InvitationGrants::VIEW),476 "invitations must grant view permission"477 );478 ensure!(479 (1..=MAX_INVITATION_TTL_SECS).contains(&ttl_seconds),480 "invitation TTL must be between 1 and {MAX_INVITATION_TTL_SECS} seconds"481 );482 ensure!(483 snapshot.viewers.len() < MAX_ENROLLED_VIEWERS,484 "authorization enrollment map is full"485 );486 ensure!(487 !snapshot.viewers.iter().any(|viewer| viewer.peer == peer),488 "Portal peer is already enrolled"489 );490 InvitationClaims::new(491 *host.as_bytes(),492 *peer.as_bytes(),493 now,494 now.checked_add(ttl_seconds)495 .context("invitation expiry overflow")?,496 snapshot.epoch,497 nonce,498 grants,499 )500 .map_err(Into::into)501 }502503 pub fn authorize_or_redeem_viewer(504 &self,505 remote: EndpointId,506 invitation_token: Option<&str>,507 now: u64,508 ) -> Result<AuthorizedViewer> {509 // Admission never waits for another in-process authorization operation.510 // A concurrent redemption or durable write fails this handshake closed511 // and lets the client retry instead of growing accept-path latency.512 let mut state = self.lock_state()?;513 state.validate(self.location.host)?;514 let mut candidate = state.clone();515 let mut changed = candidate.prune_expired(now);516517 let result = match invitation_token {518 None => {519 let enrollment = candidate520 .enrollments521 .iter()522 .find(|enrollment| {523 enrollment.peer_node_id.parse::<EndpointId>().ok() == Some(remote)524 })525 .context("Portal peer is not enrolled")?;526 AuthorizedViewer {527 handle: enrollment.handle.clone(),528 grants: enrollment.grants,529 authorization_revision: enrollment.authorization_revision,530 committed_revision: candidate.committed_revision,531 }532 }533 Some(token) => {534 ensure!(535 !candidate.enrollments.iter().any(|enrollment| {536 enrollment.peer_node_id.parse::<EndpointId>().ok() == Some(remote)537 }),538 "an invitation cannot be used after enrollment: Portal peer is already enrolled"539 );540 ensure!(541 candidate.enrollments.len() < MAX_ENROLLED_VIEWERS,542 "authorization enrollment map is full"543 );544 let invitation = SignedInvitation::decode(token)545 .context("decoding and verifying enrollment invitation")?;546 let claims = invitation.claims;547 ensure!(548 claims.host_node_id == *self.location.host.as_bytes(),549 "invitation belongs to a different Sigil host"550 );551 ensure!(552 claims.intended_peer_id == *remote.as_bytes(),553 "invitation is bound to a different Portal peer"554 );555 ensure!(556 claims.enrollment_epoch == candidate.enrollment_epoch,557 "invitation enrollment epoch is stale"558 );559 ensure!(560 claims.issued_at_unix <= now.saturating_add(INVITATION_CLOCK_SKEW_SECS),561 "invitation was issued too far in the future"562 );563 ensure!(now <= claims.expires_at_unix, "invitation has expired");564 ensure!(565 claims.grants.contains(InvitationGrants::VIEW),566 "invitation does not grant view permission"567 );568 ensure!(569 !candidate570 .consumed_invitations571 .iter()572 .any(|consumed| consumed.nonce == claims.nonce),573 "invitation has already been consumed"574 );575 ensure!(576 candidate.consumed_invitations.len() < MAX_CONSUMED_INVITATIONS,577 "authorization replay ledger is full"578 );579 let revision = candidate.next_revision()?;580 let handle = random_viewer_handle(&candidate.enrollments)?;581 candidate.enrollments.push(Enrollment {582 peer_node_id: remote.to_string(),583 handle: handle.clone(),584 grants: claims.grants,585 enrolled_at_unix: now,586 authorization_revision: revision,587 });588 candidate.consumed_invitations.push(ConsumedInvitation {589 nonce: claims.nonce,590 expires_at_unix: claims.expires_at_unix,591 });592 candidate.committed_revision = revision;593 candidate.push_audit(SecurityAuditEntry {594 revision,595 occurred_at_unix: now,596 action: SecurityAuditAction::Enrolled,597 handle: handle.clone(),598 previous_grants: None,599 current_grants: Some(claims.grants),600 });601 changed = true;602 AuthorizedViewer {603 handle,604 grants: claims.grants,605 authorization_revision: revision,606 committed_revision: revision,607 }608 }609 };610611 if changed {612 self.location.write_state(&candidate)?;613 *state = candidate;614 }615 Ok(result)616 }617618 #[allow(dead_code)]619 pub fn authorize_or_redeem(620 &self,621 remote: EndpointId,622 invitation_token: Option<&str>,623 now: u64,624 ) -> Result<InvitationGrants> {625 self.authorize_or_redeem_viewer(remote, invitation_token, now)626 .map(|viewer| viewer.grants)627 }628629 #[cfg(test)]630 fn snapshot(&self) -> Result<AuthorizationSnapshot> {631 let state = self.lock_state()?;632 state.validate(self.location.host)?;633 AuthorizationSnapshot::from_state(&state)634 }635636 /// Inspect durable enrollment without creating the state directory or lock.637 /// The authorization writer uses atomic replacement, so an unlocked reader638 /// observes either the previous complete document or the next one.639 pub fn inspect_existing(640 state_directory: impl Into<PathBuf>,641 host: EndpointId,642 ) -> Result<AuthorizationInspection> {643 let location = AuthorizationLocation {644 state_directory: state_directory.into(),645 host,646 };647 let directory_metadata = match fs::symlink_metadata(&location.state_directory) {648 Ok(metadata) => metadata,649 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {650 return Ok(AuthorizationInspection {651 snapshot: AuthorizationSnapshot {652 epoch: 1,653 peer: None,654 grants: None,655 enrolled_at_unix: None,656 committed_revision: 1,657 viewers: Vec::new(),658 },659 storage_present: false,660 });661 }662 Err(error) => return Err(error).context("inspecting authorization state directory"),663 };664 ensure!(665 !directory_metadata.file_type().is_symlink(),666 "authorization state directory is a symlink"667 );668 ensure!(669 directory_metadata.is_dir(),670 "authorization state path is not a directory"671 );672 #[cfg(unix)]673 {674 ensure!(675 directory_metadata.mode() & 0o077 == 0,676 "authorization state directory is accessible by group or others"677 );678 ensure!(679 directory_metadata.uid() == unsafe { libc::geteuid() },680 "authorization state directory has the wrong owner"681 );682 }683684 let mut path = location.state_path();685 let mut options = OpenOptions::new();686 options.read(true);687 #[cfg(unix)]688 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);689 let mut file = match options.open(&path) {690 Ok(file) => file,691 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {692 path = location.legacy_state_path();693 match options.open(&path) {694 Ok(file) => file,695 Err(legacy_error) if legacy_error.kind() == std::io::ErrorKind::NotFound => {696 return Ok(AuthorizationInspection {697 snapshot: AuthorizationSnapshot {698 epoch: 1,699 peer: None,700 grants: None,701 enrolled_at_unix: None,702 committed_revision: 1,703 viewers: Vec::new(),704 },705 storage_present: false,706 });707 }708 Err(legacy_error) => {709 return Err(legacy_error).context("opening legacy authorization state");710 }711 }712 }713 Err(error) => return Err(error).context("opening authorization state"),714 };715 location.validate_secure_file(&file, &path)?;716 let metadata = file.metadata()?;717 ensure!(718 metadata.len() <= MAX_STATE_FILE_LEN,719 "authorization state exceeds its fixed size bound"720 );721 let mut bytes = Vec::with_capacity(metadata.len() as usize);722 Read::by_ref(&mut file)723 .take(MAX_STATE_FILE_LEN.saturating_add(1))724 .read_to_end(&mut bytes)?;725 ensure!(726 bytes.len() as u64 <= MAX_STATE_FILE_LEN,727 "authorization state exceeds its fixed size bound"728 );729 ensure!(!bytes.is_empty(), "authorization state is empty");730 let state: AuthorizationState = if path == location.state_path() {731 serde_json::from_slice(&bytes).context("parsing authorization state")?732 } else {733 let legacy: LegacyAuthorizationState =734 serde_json::from_slice(&bytes).context("parsing legacy authorization state")?;735 AuthorizationState::from_legacy(legacy, host)?736 };737 state.validate(host)?;738 let snapshot = AuthorizationSnapshot::from_state(&state)?;739 Ok(AuthorizationInspection {740 snapshot,741 storage_present: true,742 })743 }744745 pub fn revoke_with_outcome(&self, now: u64) -> Result<RevocationOutcome> {746 let mut state = self.lock_state()?;747 state.validate(self.location.host)?;748 let mut candidate = state.clone();749 let previous_epoch = candidate.enrollment_epoch;750 let had_enrollment = !candidate.enrollments.is_empty();751 let removed = std::mem::take(&mut candidate.enrollments);752 candidate.enrollment_epoch = candidate753 .enrollment_epoch754 .checked_add(1)755 .context("authorization epoch exhausted")?;756 candidate.prune_expired(now);757 candidate.committed_revision = candidate.next_revision()?;758 for enrollment in removed {759 candidate.push_audit(SecurityAuditEntry {760 revision: candidate.committed_revision,761 occurred_at_unix: now,762 action: SecurityAuditAction::ResetAll,763 handle: enrollment.handle,764 previous_grants: Some(enrollment.grants),765 current_grants: None,766 });767 }768 self.location.write_state(&candidate)?;769 *state = candidate;770 Ok(RevocationOutcome {771 had_enrollment,772 previous_epoch,773 current_epoch: state.enrollment_epoch,774 })775 }776777 pub fn list_viewers(&self) -> Result<AuthorizationSnapshot> {778 let state = self.lock_state()?;779 state.validate(self.location.host)?;780 AuthorizationSnapshot::from_state(&state)781 }782783 pub fn revoke_viewer(&self, handle: &str, now: u64) -> Result<AuthorizationMutation> {784 validate_viewer_handle(handle)?;785 let mut state = self.lock_state()?;786 state.validate(self.location.host)?;787 let mut candidate = state.clone();788 let index = candidate789 .enrollments790 .iter()791 .position(|viewer| viewer.handle == handle)792 .context("viewer handle is not enrolled")?;793 let removed = candidate.enrollments.remove(index);794 let revision = candidate.next_revision()?;795 candidate.committed_revision = revision;796 candidate.prune_expired(now);797 candidate.push_audit(SecurityAuditEntry {798 revision,799 occurred_at_unix: now,800 action: SecurityAuditAction::Revoked,801 handle: removed.handle.clone(),802 previous_grants: Some(removed.grants),803 current_grants: None,804 });805 self.location.write_state(&candidate)?;806 *state = candidate;807 Ok(AuthorizationMutation {808 handle: removed.handle,809 peer: removed.peer_node_id.parse()?,810 previous_grants: removed.grants,811 current_grants: None,812 authorization_revision: revision,813 committed_revision: revision,814 })815 }816817 pub fn replace_viewer_grants(818 &self,819 handle: &str,820 grants: Option<InvitationGrants>,821 now: u64,822 ) -> Result<AuthorizationMutation> {823 validate_viewer_handle(handle)?;824 if let Some(grants) = grants {825 grants.validate()?;826 ensure!(827 grants.contains(InvitationGrants::VIEW),828 "a persisted viewer grant must include view permission"829 );830 }831 let mut state = self.lock_state()?;832 state.validate(self.location.host)?;833 let mut candidate = state.clone();834 let index = candidate835 .enrollments836 .iter()837 .position(|viewer| viewer.handle == handle)838 .context("viewer handle is not enrolled")?;839 let previous = candidate.enrollments[index].clone();840 ensure!(841 grants != Some(previous.grants),842 "viewer already has the requested grants"843 );844 let revision = candidate.next_revision()?;845 if let Some(grants) = grants {846 candidate.enrollments[index].grants = grants;847 candidate.enrollments[index].authorization_revision = revision;848 } else {849 candidate.enrollments.remove(index);850 }851 candidate.committed_revision = revision;852 candidate.prune_expired(now);853 candidate.push_audit(SecurityAuditEntry {854 revision,855 occurred_at_unix: now,856 action: if grants.is_some() {857 SecurityAuditAction::GrantsReplaced858 } else {859 SecurityAuditAction::Revoked860 },861 handle: previous.handle.clone(),862 previous_grants: Some(previous.grants),863 current_grants: grants,864 });865 self.location.write_state(&candidate)?;866 *state = candidate;867 Ok(AuthorizationMutation {868 handle: previous.handle,869 peer: previous.peer_node_id.parse()?,870 previous_grants: previous.grants,871 current_grants: grants,872 authorization_revision: revision,873 committed_revision: revision,874 })875 }876877 fn lock_state(&self) -> Result<MutexGuard<'_, AuthorizationState>> {878 match self.writer.state.try_lock() {879 Ok(state) => Ok(state),880 Err(TryLockError::WouldBlock) => {881 anyhow::bail!("authorization writer is busy; retry the operation")882 }883 Err(TryLockError::Poisoned(_)) => {884 anyhow::bail!("authorization writer state is poisoned")885 }886 }887 }888}889890impl AuthorizationLocation {891 fn state_path(&self) -> PathBuf {892 self.state_directory.join(STATE_FILE_NAME)893 }894895 fn legacy_state_path(&self) -> PathBuf {896 self.state_directory.join(LEGACY_STATE_FILE_NAME)897 }898899 fn lock_path(&self) -> PathBuf {900 self.state_directory.join(LOCK_FILE_NAME)901 }902903 fn ensure_state_directory(&self) -> Result<()> {904 if self.state_directory.exists() {905 let metadata = fs::symlink_metadata(&self.state_directory)?;906 ensure!(907 !metadata.file_type().is_symlink(),908 "authorization state directory is a symlink"909 );910 ensure!(911 metadata.is_dir(),912 "authorization state path is not a directory"913 );914 #[cfg(unix)]915 {916 ensure!(917 metadata.mode() & 0o077 == 0,918 "authorization state directory is accessible by group or others"919 );920 ensure!(921 metadata.uid() == unsafe { libc::geteuid() },922 "authorization state directory has the wrong owner"923 );924 }925 } else {926 #[cfg(unix)]927 {928 let mut builder = fs::DirBuilder::new();929 builder930 .recursive(true)931 .mode(0o700)932 .create(&self.state_directory)?;933 }934 #[cfg(not(unix))]935 fs::create_dir_all(&self.state_directory)?;936 }937 Ok(())938 }939940 fn open_lock(&self) -> Result<File> {941 let mut options = OpenOptions::new();942 options.read(true).write(true).create(true);943 #[cfg(unix)]944 options945 .mode(0o600)946 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);947 let file = options948 .open(self.lock_path())949 .context("opening authorization lock")?;950 self.validate_secure_file(&file, &self.lock_path())?;951 Ok(file)952 }953954 fn lock_exclusive_bounded(&self, file: &File, timeout: Duration) -> Result<()> {955 #[cfg(unix)]956 {957 use std::os::fd::AsRawFd;958 let started = Instant::now();959 loop {960 let status =961 unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };962 if status == 0 {963 break;964 }965 let error = std::io::Error::last_os_error();966 if error.kind() != std::io::ErrorKind::WouldBlock {967 return Err(error).context("locking authorization state failed");968 }969 if started.elapsed() >= timeout {970 anyhow::bail!(971 "authorization writer is busy after {} ms; stop or terminate the current writer and retry",972 timeout.as_millis()973 );974 }975 std::thread::sleep(976 WRITER_LOCK_RETRY_INTERVAL.min(timeout.saturating_sub(started.elapsed())),977 );978 }979 }980 Ok(())981 }982983 fn load_state(&self) -> Result<(AuthorizationState, bool)> {984 let mut path = self.state_path();985 let mut options = OpenOptions::new();986 options.read(true);987 #[cfg(unix)]988 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);989 let mut file = match options.open(&path) {990 Ok(file) => file,991 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {992 path = self.legacy_state_path();993 match options.open(&path) {994 Ok(file) => file,995 Err(legacy_error) if legacy_error.kind() == std::io::ErrorKind::NotFound => {996 return Ok((AuthorizationState::empty(self.host), false));997 }998 Err(legacy_error) => {999 return Err(legacy_error).context("opening legacy authorization state");1000 }1001 }1002 }1003 Err(error) => return Err(error).context("opening authorization state"),1004 };1005 self.validate_secure_file(&file, &path)?;1006 let metadata = file.metadata()?;1007 ensure!(1008 metadata.len() <= MAX_STATE_FILE_LEN,1009 "authorization state exceeds its fixed size bound"1010 );1011 let mut bytes = Vec::with_capacity(metadata.len() as usize);1012 Read::by_ref(&mut file)1013 .take(MAX_STATE_FILE_LEN.saturating_add(1))1014 .read_to_end(&mut bytes)?;1015 ensure!(1016 bytes.len() as u64 <= MAX_STATE_FILE_LEN,1017 "authorization state exceeds its fixed size bound"1018 );1019 ensure!(!bytes.is_empty(), "authorization state is empty");1020 if path == self.state_path() {1021 serde_json::from_slice(&bytes)1022 .context("parsing authorization state")1023 .map(|state| (state, false))1024 } else {1025 let legacy: LegacyAuthorizationState =1026 serde_json::from_slice(&bytes).context("parsing legacy authorization state")?;1027 Ok((AuthorizationState::from_legacy(legacy, self.host)?, true))1028 }1029 }10301031 fn remove_legacy_state(&self) -> Result<()> {1032 let path = self.legacy_state_path();1033 match fs::symlink_metadata(&path) {1034 Ok(metadata) => {1035 ensure!(1036 metadata.is_file() && !metadata.file_type().is_symlink(),1037 "legacy authorization state is not a regular file"1038 );1039 #[cfg(unix)]1040 ensure!(1041 metadata.uid() == unsafe { libc::geteuid() } && metadata.mode() & 0o077 == 0,1042 "legacy authorization state has unsafe ownership or permissions"1043 );1044 fs::remove_file(&path)?;1045 File::open(&self.state_directory)?.sync_all()?;1046 Ok(())1047 }1048 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),1049 Err(error) => Err(error).context("inspecting legacy authorization state"),1050 }1051 }10521053 fn write_state(&self, state: &AuthorizationState) -> Result<()> {1054 state.validate(self.host)?;1055 let bytes = serde_json::to_vec_pretty(state)?;1056 ensure!(1057 bytes.len() as u64 <= MAX_STATE_FILE_LEN,1058 "authorization state exceeds its fixed size bound"1059 );1060 let mut random = [0_u8; 8];1061 getrandom::fill(&mut random).context("generating authorization temporary-file name")?;1062 let temporary = self.state_directory.join(format!(1063 ".{STATE_FILE_NAME}.{:016x}.tmp",1064 u64::from_be_bytes(random)1065 ));1066 let mut options = OpenOptions::new();1067 options.write(true).create_new(true);1068 #[cfg(unix)]1069 options1070 .mode(0o600)1071 .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);1072 let mut file = options1073 .open(&temporary)1074 .context("creating authorization state temporary file")?;1075 let write_result = (|| -> Result<()> {1076 file.write_all(&bytes)?;1077 file.write_all(b"\n")?;1078 file.sync_all()?;1079 fs::rename(&temporary, self.state_path())?;1080 File::open(&self.state_directory)?.sync_all()?;1081 Ok(())1082 })();1083 if write_result.is_err() {1084 let _ = fs::remove_file(&temporary);1085 }1086 write_result1087 }10881089 fn validate_secure_file(&self, file: &File, path: &Path) -> Result<()> {1090 let metadata = file.metadata()?;1091 ensure!(1092 metadata.is_file(),1093 "{} is not a regular file",1094 path.display()1095 );1096 #[cfg(unix)]1097 {1098 ensure!(1099 metadata.mode() & 0o077 == 0,1100 "{} is accessible by group or others",1101 path.display()1102 );1103 ensure!(1104 metadata.uid() == unsafe { libc::geteuid() },1105 "{} has the wrong owner",1106 path.display()1107 );1108 }1109 Ok(())1110 }1111}11121113impl AuthorizationPolicy {1114 pub fn authorize_or_redeem_viewer(1115 &self,1116 remote: EndpointId,1117 invitation_token: Option<&str>,1118 now: u64,1119 ) -> Result<AuthorizedViewer> {1120 match self {1121 Self::Required(store) => {1122 store.authorize_or_redeem_viewer(remote, invitation_token, now)1123 }1124 Self::TestPatternProof | Self::DevelopmentBypass => {1125 ensure!(1126 invitation_token.is_none(),1127 "unrestricted development modes do not accept invitations"1128 );1129 Ok(AuthorizedViewer {1130 handle: development_viewer_handle(remote),1131 grants: InvitationGrants::ALL,1132 authorization_revision: 1,1133 committed_revision: 1,1134 })1135 }1136 }1137 }11381139 pub fn authorize_or_redeem(1140 &self,1141 remote: EndpointId,1142 invitation_token: Option<&str>,1143 now: u64,1144 ) -> Result<InvitationGrants> {1145 self.authorize_or_redeem_viewer(remote, invitation_token, now)1146 .map(|viewer| viewer.grants)1147 }1148}11491150fn development_viewer_handle(remote: EndpointId) -> String {1151 let digest =1152 Sha256::digest([b"goq-development-viewer-v1\0".as_slice(), remote.as_bytes()].concat());1153 let suffix = digest[..VIEWER_HANDLE_RANDOM_BYTES]1154 .iter()1155 .map(|byte| format!("{byte:02x}"))1156 .collect::<String>();1157 format!("viewer-{suffix}")1158}11591160pub fn unix_timestamp_now() -> Result<u64> {1161 Ok(SystemTime::now()1162 .duration_since(UNIX_EPOCH)1163 .context("system clock is before the Unix epoch")?1164 .as_secs())1165}11661167pub fn grant_names(grants: InvitationGrants) -> &'static str {1168 match (1169 grants.contains(InvitationGrants::POINTER_KEYBOARD),1170 grants.contains(InvitationGrants::GAMEPAD),1171 ) {1172 (false, false) => "view",1173 (true, false) => "view,pointer-keyboard",1174 (false, true) => "view,gamepad",1175 (true, true) => "view,pointer-keyboard,gamepad",1176 }1177}11781179#[cfg(test)]1180mod tests {1181 use super::*;1182 use iroh::SecretKey;1183 use sigil_protocol::SignedInvitation;1184 #[cfg(unix)]1185 use std::os::unix::fs::PermissionsExt;1186 #[cfg(unix)]1187 use std::process::Command;11881189 fn store() -> (tempfile::TempDir, SecretKey, SecretKey, AuthorizationStore) {1190 let directory = tempfile::tempdir().unwrap();1191 #[cfg(unix)]1192 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1193 let host = SecretKey::from_bytes(&[7; 32]);1194 let peer = SecretKey::from_bytes(&[9; 32]);1195 let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();1196 (directory, host, peer, store)1197 }11981199 fn token(1200 store: &AuthorizationStore,1201 host: &SecretKey,1202 peer: EndpointId,1203 now: u64,1204 nonce: [u8; 32],1205 grants: InvitationGrants,1206 ) -> String {1207 let claims = store.issue_claims(peer, grants, 600, now, nonce).unwrap();1208 SignedInvitation::issue(claims, &host.to_bytes())1209 .unwrap()1210 .encode()1211 }12121213 #[test]1214 fn invitation_enrolls_once_and_reconnects_ticket_free() {1215 let (_directory, host, peer, store) = store();1216 let grants = InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD);1217 let token = token(&store, &host, peer.public(), 1_000, [1; 32], grants);1218 assert_eq!(1219 store1220 .authorize_or_redeem(peer.public(), Some(&token), 1_001)1221 .unwrap(),1222 grants1223 );1224 assert_eq!(1225 store1226 .authorize_or_redeem(peer.public(), None, 1_002)1227 .unwrap(),1228 grants1229 );1230 assert!(1231 store1232 .authorize_or_redeem(peer.public(), Some(&token), 1_002)1233 .unwrap_err()1234 .to_string()1235 .contains("after enrollment")1236 );12371238 let state_directory = store.location.state_directory.clone();1239 drop(store);1240 let reopened = AuthorizationStore::open(state_directory, host.public()).unwrap();1241 assert_eq!(1242 reopened1243 .authorize_or_redeem(peer.public(), None, 1_003)1244 .unwrap(),1245 grants1246 );1247 }12481249 // Regression: every automated proof runs under TestPatternProof or1250 // DevelopmentBypass, which pin `authorization_revision` to 1. A real1251 // `Required` host mints enrollment revisions starting at 2 and rising with1252 // every grant change, so any client that assumes 1 rejects its own1253 // authenticated media. Compose the exact host issuance path with the exact1254 // client verification path across several enrollments and mutations.1255 #[test]1256 fn enrolled_viewers_verify_their_capability_at_the_advertised_revision() {1257 use sigil_protocol::{1258 Capability, HostHelloV2, SignedSubscriptionCapability, SubscriptionClaims,1259 SubscriptionTracks,1260 };12611262 let (_directory, host, first_peer, store) = store();1263 let second_peer = SecretKey::from_bytes(&[23; 32]);1264 let generation_id = 11_u64;1265 let now = 5_000_u64;12661267 let first_token = token(1268 &store,1269 &host,1270 first_peer.public(),1271 1_000,1272 [41; 32],1273 InvitationGrants::ALL,1274 );1275 let second_token = token(1276 &store,1277 &host,1278 second_peer.public(),1279 1_002,1280 [42; 32],1281 InvitationGrants::ALL,1282 );1283 let mut admissions = vec![1284 store1285 .authorize_or_redeem_viewer(first_peer.public(), Some(&first_token), 1_001)1286 .unwrap(),1287 store1288 .authorize_or_redeem_viewer(second_peer.public(), Some(&second_token), 1_003)1289 .unwrap(),1290 ];1291 // A grant change bumps the revision again; re-admit to pick it up.1292 let handle = admissions[0].handle.clone();1293 store1294 .replace_viewer_grants(&handle, Some(InvitationGrants::VIEW), 1_004)1295 .unwrap();1296 admissions.push(1297 store1298 .authorize_or_redeem_viewer(first_peer.public(), None, 1_005)1299 .unwrap(),1300 );13011302 // No enrolled viewer lands on revision 1, which is exactly why the1303 // client cannot assume it.1304 assert!(1305 admissions1306 .iter()1307 .all(|viewer| viewer.authorization_revision > 1),1308 "enrolled revisions must not collide with the development-bypass constant"1309 );13101311 for (index, authorized) in admissions.iter().enumerate() {1312 let peer = if index == 1 {1313 second_peer.public()1314 } else {1315 first_peer.public()1316 };13171318 // Host: server/moq.rs mints the capability at the lease revision1319 // and advertises that revision in the same accepted hello.1320 let capability = SignedSubscriptionCapability::issue(1321 SubscriptionClaims::new(1322 *host.public().as_bytes(),1323 generation_id,1324 *peer.as_bytes(),1325 SubscriptionTracks::VIDEO_H264,1326 authorized.authorization_revision,1327 now,1328 now + 900,1329 [u8::try_from(index + 1).unwrap(); 32],1330 1,1331 )1332 .unwrap(),1333 &host.to_bytes(),1334 )1335 .unwrap();1336 let hello = HostHelloV2::accepted(1337 7,1338 vec![Capability::VideoH264],1339 control_v2_snapshot(generation_id),1340 )1341 .with_media_subscription_capability(1342 capability.encode(),1343 authorized.authorization_revision,1344 );1345 hello1346 .validate()1347 .expect("host hello must bind its capability to the advertised revision");13481349 // Client: Portal and sigil-probe take the revision from the hello.1350 let advertised = hello1351 .media_authorization_revision1352 .expect("accepted hello advertises its authorization revision");1353 SignedSubscriptionCapability::decode(1354 hello1355 .media_subscription_capability1356 .as_deref()1357 .expect("accepted hello carries a capability"),1358 )1359 .unwrap()1360 .verify_binding(1361 *host.public().as_bytes(),1362 generation_id,1363 *peer.as_bytes(),1364 SubscriptionTracks::VIDEO_H264,1365 advertised,1366 now + 1,1367 )1368 .unwrap_or_else(|error| {1369 panic!(1370 "enrolled viewer at revision {} must verify its own capability: {error}",1371 authorized.authorization_revision1372 )1373 });13741375 // The shipped defect, pinned: a client that assumes the1376 // development-bypass constant rejects a real enrolled viewer.1377 assert!(1378 capability1379 .verify_binding(1380 *host.public().as_bytes(),1381 generation_id,1382 *peer.as_bytes(),1383 SubscriptionTracks::VIDEO_H264,1384 1,1385 now + 1,1386 )1387 .is_err(),1388 "a hardcoded revision must not accept an enrolled viewer's capability"1389 );1390 }1391 }13921393 fn control_v2_snapshot(generation_id: u64) -> sigil_protocol::SessionSnapshotV2 {1394 use sigil_protocol::{1395 ControllerSlot, FocusStateV2, FocusTransitionReasonV2, MediaGenerationDescriptorV2,1396 SessionSnapshotV2, ViewerPresenceId, ViewerPresenceV2,1397 };1398 let presence = ViewerPresenceId::new("viewer-0001").unwrap();1399 SessionSnapshotV2 {1400 revision: 1,1401 self_presence_id: presence.clone(),1402 viewers: vec![ViewerPresenceV2 {1403 presence_id: presence,1404 session_id: 7,1405 input_capable: true,1406 you: true,1407 }],1408 focus: FocusStateV2::Vacant {1409 slot: ControllerSlot::ZERO,1410 },1411 focus_proposal: None,1412 self_is_focus_owner: false,1413 transition_reason: FocusTransitionReasonV2::Initial,1414 media: MediaGenerationDescriptorV2 {1415 generation_id,1416 broadcast_name: sigil_protocol::media_generation_moq_broadcast_name(generation_id)1417 .unwrap(),1418 },1419 }1420 }14211422 #[test]1423 fn multiple_peers_enroll_with_unique_stable_handles() {1424 let (_directory, host, first_peer, store) = store();1425 let second_peer = SecretKey::from_bytes(&[19; 32]);1426 let first_token = token(1427 &store,1428 &host,1429 first_peer.public(),1430 1_000,1431 [21; 32],1432 InvitationGrants::ALL,1433 );1434 let first = store1435 .authorize_or_redeem_viewer(first_peer.public(), Some(&first_token), 1_001)1436 .unwrap();1437 let second_token = token(1438 &store,1439 &host,1440 second_peer.public(),1441 1_002,1442 [22; 32],1443 InvitationGrants::VIEW,1444 );1445 let second = store1446 .authorize_or_redeem_viewer(second_peer.public(), Some(&second_token), 1_003)1447 .unwrap();14481449 assert_ne!(first.handle, second.handle);1450 validate_viewer_handle(&first.handle).unwrap();1451 validate_viewer_handle(&second.handle).unwrap();1452 assert!(second.committed_revision > first.committed_revision);1453 let snapshot = store.list_viewers().unwrap();1454 assert_eq!(snapshot.viewers.len(), 2);1455 assert_eq!(1456 store1457 .authorize_or_redeem_viewer(first_peer.public(), None, 1_004)1458 .unwrap()1459 .handle,1460 first.handle1461 );1462 }14631464 #[test]1465 fn sole_writer_atomically_converts_v1_and_never_prefers_it_over_v2() {1466 let directory = tempfile::tempdir().unwrap();1467 #[cfg(unix)]1468 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1469 let host = SecretKey::from_bytes(&[27; 32]);1470 let peer = SecretKey::from_bytes(&[28; 32]);1471 let legacy = serde_json::json!({1472 "version": 1,1473 "host_node_id": host.public().to_string(),1474 "enrollment_epoch": 4,1475 "enrollment": {1476 "peer_node_id": peer.public().to_string(),1477 "grants": InvitationGrants::ALL.bits(),1478 "enrolled_at_unix": 12341479 },1480 "consumed_invitations": [{1481 "nonce": vec![31_u8; 32],1482 "expires_at_unix": 20001483 }]1484 });1485 let legacy_path = directory.path().join(LEGACY_STATE_FILE_NAME);1486 fs::write(&legacy_path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap();1487 #[cfg(unix)]1488 fs::set_permissions(&legacy_path, fs::Permissions::from_mode(0o600)).unwrap();14891490 let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();1491 let snapshot = store.list_viewers().unwrap();1492 assert_eq!(snapshot.epoch, 4);1493 assert_eq!(snapshot.viewers.len(), 1);1494 assert_eq!(snapshot.viewers[0].peer, peer.public());1495 assert!(directory.path().join(STATE_FILE_NAME).is_file());1496 assert!(!legacy_path.exists());14971498 // A stale legacy file appearing after conversion is ignored; v2 is the1499 // only authority once its atomic replacement is present.1500 fs::write(&legacy_path, b"{not valid json").unwrap();1501 #[cfg(unix)]1502 fs::set_permissions(&legacy_path, fs::Permissions::from_mode(0o600)).unwrap();1503 drop(store);1504 let reopened = AuthorizationStore::open(directory.path(), host.public()).unwrap();1505 assert_eq!(reopened.list_viewers().unwrap().viewers.len(), 1);1506 }15071508 #[test]1509 fn read_only_inspection_never_creates_state_and_retains_enrollment_time() {1510 let root = tempfile::tempdir().unwrap();1511 #[cfg(unix)]1512 fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap();1513 let missing = root.path().join("missing-state");1514 let host = SecretKey::from_bytes(&[7; 32]);1515 let inspection = AuthorizationStore::inspect_existing(&missing, host.public()).unwrap();1516 assert!(!inspection.storage_present);1517 assert_eq!(inspection.snapshot.epoch, 1);1518 assert!(inspection.snapshot.peer.is_none());1519 assert!(!missing.exists());15201521 let (directory, host, peer, store) = store();1522 let token = token(1523 &store,1524 &host,1525 peer.public(),1526 1_000,1527 [11; 32],1528 InvitationGrants::ALL,1529 );1530 store1531 .authorize_or_redeem(peer.public(), Some(&token), 1_001)1532 .unwrap();1533 let inspection =1534 AuthorizationStore::inspect_existing(directory.path(), host.public()).unwrap();1535 assert!(inspection.storage_present);1536 assert_eq!(inspection.snapshot.peer, Some(peer.public()));1537 assert_eq!(inspection.snapshot.enrolled_at_unix, Some(1_001));1538 }15391540 #[test]1541 fn peer_host_expiry_and_future_time_fail_closed() {1542 let (_directory, host, peer, store) = store();1543 let other_peer = SecretKey::from_bytes(&[10; 32]);1544 let token = token(1545 &store,1546 &host,1547 peer.public(),1548 1_000,1549 [2; 32],1550 InvitationGrants::VIEW,1551 );1552 assert!(1553 store1554 .authorize_or_redeem(other_peer.public(), Some(&token), 1_001)1555 .is_err()1556 );1557 assert!(1558 store1559 .authorize_or_redeem(peer.public(), Some(&token), 1_601)1560 .is_err()1561 );1562 assert!(1563 store1564 .authorize_or_redeem(peer.public(), Some(&token), 900)1565 .is_err()1566 );15671568 let other_host = SecretKey::from_bytes(&[11; 32]);1569 let claims = InvitationClaims::new(1570 *other_host.public().as_bytes(),1571 *peer.public().as_bytes(),1572 1_000,1573 1_600,1574 1,1575 [3; 32],1576 InvitationGrants::VIEW,1577 )1578 .unwrap();1579 let cross_host = SignedInvitation::issue(claims, &other_host.to_bytes())1580 .unwrap()1581 .encode();1582 assert!(1583 store1584 .authorize_or_redeem(peer.public(), Some(&cross_host), 1_001)1585 .is_err()1586 );1587 }15881589 #[test]1590 fn revoke_invalidates_outstanding_epoch_and_clears_peer() {1591 let (_directory, host, peer, store) = store();1592 let token = token(1593 &store,1594 &host,1595 peer.public(),1596 1_000,1597 [4; 32],1598 InvitationGrants::VIEW,1599 );1600 assert!(!store.revoke_with_outcome(1_001).unwrap().had_enrollment);1601 assert!(1602 store1603 .authorize_or_redeem(peer.public(), Some(&token), 1_002)1604 .unwrap_err()1605 .to_string()1606 .contains("epoch")1607 );1608 assert_eq!(store.snapshot().unwrap().epoch, 2);1609 assert!(store.snapshot().unwrap().peer.is_none());1610 }16111612 #[test]1613 fn copied_state_is_bound_to_host_and_permissions_are_strict() {1614 let (_directory, host, peer, store) = store();1615 let token = token(1616 &store,1617 &host,1618 peer.public(),1619 1_000,1620 [5; 32],1621 InvitationGrants::VIEW.union(InvitationGrants::POINTER_KEYBOARD),1622 );1623 store1624 .authorize_or_redeem(peer.public(), Some(&token), 1_001)1625 .unwrap();1626 let other_host = SecretKey::from_bytes(&[12; 32]);1627 assert!(1628 AuthorizationStore::open(store.location.state_directory.clone(), other_host.public())1629 .is_err()1630 );1631 let snapshot = store.snapshot().unwrap();1632 assert!(1633 snapshot1634 .grants1635 .unwrap()1636 .contains(InvitationGrants::POINTER_KEYBOARD)1637 );1638 assert!(!snapshot.grants.unwrap().contains(InvitationGrants::GAMEPAD));1639 }16401641 #[test]1642 fn consumed_nonce_replay_survives_revoke_and_store_reopen() {1643 let (_directory, host, peer, store) = store();1644 let nonce = [6; 32];1645 let first = token(1646 &store,1647 &host,1648 peer.public(),1649 1_000,1650 nonce,1651 InvitationGrants::VIEW,1652 );1653 store1654 .authorize_or_redeem(peer.public(), Some(&first), 1_001)1655 .unwrap();1656 assert!(store.revoke_with_outcome(1_002).unwrap().had_enrollment);16571658 let state_directory = store.location.state_directory.clone();1659 drop(store);1660 let reopened = AuthorizationStore::open(state_directory, host.public()).unwrap();1661 let replay = token(1662 &reopened,1663 &host,1664 peer.public(),1665 1_003,1666 nonce,1667 InvitationGrants::VIEW,1668 );1669 let state_before = fs::read(reopened.location.state_path()).unwrap();1670 let error = reopened1671 .authorize_or_redeem(peer.public(), Some(&replay), 1_004)1672 .unwrap_err();16731674 assert!(error.to_string().contains("already been consumed"));1675 assert_eq!(1676 fs::read(reopened.location.state_path()).unwrap(),1677 state_before1678 );1679 let snapshot = reopened.snapshot().unwrap();1680 assert_eq!(snapshot.epoch, 2);1681 assert!(snapshot.peer.is_none());1682 }16831684 #[test]1685 fn development_bypass_grants_all_without_touching_persistent_state() {1686 let directory = tempfile::tempdir().unwrap();1687 let remote = SecretKey::from_bytes(&[29; 32]).public();1688 let policy = AuthorizationPolicy::DevelopmentBypass;16891690 assert_eq!(1691 policy.authorize_or_redeem(remote, None, 1_000).unwrap(),1692 InvitationGrants::ALL1693 );1694 assert!(1695 policy1696 .authorize_or_redeem(remote, Some("not-an-invitation"), 1_000)1697 .is_err()1698 );1699 assert!(fs::read_dir(directory.path()).unwrap().next().is_none());1700 }17011702 #[test]1703 fn invitation_clock_boundaries_are_inclusive_and_fail_closed_outside_skew() {1704 let (_directory, host, peer, expiry_store) = store();1705 let expires_at = token(1706 &expiry_store,1707 &host,1708 peer.public(),1709 1_000,1710 [7; 32],1711 InvitationGrants::VIEW,1712 );1713 assert!(1714 expiry_store1715 .authorize_or_redeem(peer.public(), Some(&expires_at), 1_600)1716 .is_ok()1717 );17181719 let (_directory, host, peer, skew_store) = store();1720 let exact_skew = token(1721 &skew_store,1722 &host,1723 peer.public(),1724 1_060,1725 [8; 32],1726 InvitationGrants::VIEW,1727 );1728 assert!(1729 skew_store1730 .authorize_or_redeem(peer.public(), Some(&exact_skew), 1_000)1731 .is_ok()1732 );17331734 let (_directory, host, peer, outside_store) = store();1735 let outside_skew = token(1736 &outside_store,1737 &host,1738 peer.public(),1739 1_061,1740 [9; 32],1741 InvitationGrants::VIEW,1742 );1743 let error = outside_store1744 .authorize_or_redeem(peer.public(), Some(&outside_skew), 1_000)1745 .unwrap_err();1746 assert!(error.to_string().contains("too far in the future"));1747 }17481749 #[cfg(unix)]1750 #[test]1751 fn writer_contention_is_bounded_and_recovers_after_release() {1752 let (directory, host, _peer, store) = store();1753 let started = Instant::now();1754 let error = AuthorizationStore::open(directory.path(), host.public()).unwrap_err();1755 let elapsed = started.elapsed();17561757 assert!(error.to_string().contains("authorization writer is busy"));1758 assert!(elapsed >= WRITER_LOCK_TIMEOUT);1759 assert!(elapsed < Duration::from_secs(2));17601761 drop(store);1762 AuthorizationStore::open(directory.path(), host.public()).unwrap();1763 }17641765 #[test]1766 fn admission_fails_fast_while_in_process_writer_is_hung() {1767 let (_directory, _host, peer, store) = store();1768 let _hung_writer = store.writer.state.lock().unwrap();1769 let started = Instant::now();17701771 let error = store1772 .authorize_or_redeem(peer.public(), None, 1_000)1773 .unwrap_err();17741775 assert!(error.to_string().contains("authorization writer is busy"));1776 assert!(started.elapsed() < Duration::from_millis(50));1777 }17781779 #[test]1780 fn read_only_invitation_creation_remains_available_with_live_writer() {1781 let (directory, host, peer, _store) = store();1782 let inspection =1783 AuthorizationStore::inspect_existing(directory.path(), host.public()).unwrap();1784 let claims = AuthorizationStore::issue_claims_from_snapshot(1785 host.public(),1786 inspection.snapshot,1787 peer.public(),1788 InvitationGrants::ALL,1789 600,1790 1_000,1791 [42; 32],1792 )1793 .unwrap();17941795 assert_eq!(claims.enrollment_epoch, 1);1796 assert_eq!(claims.intended_peer_id, *peer.public().as_bytes());1797 }17981799 #[cfg(unix)]1800 #[test]1801 fn crashed_writer_releases_lease_and_preserves_durable_state() {1802 const CHILD_DIRECTORY: &str = "SIGIL_AUTHORIZATION_CRASH_TEST_DIRECTORY";1803 let directory = tempfile::tempdir().unwrap();1804 fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1805 let marker = directory.path().join("writer-ready");1806 let mut child = Command::new(std::env::current_exe().unwrap())1807 .args([1808 "--exact",1809 "authorization::tests::writer_crash_helper",1810 "--nocapture",1811 ])1812 .env(CHILD_DIRECTORY, directory.path())1813 .spawn()1814 .unwrap();18151816 let deadline = Instant::now() + Duration::from_secs(5);1817 while !marker.exists() {1818 assert!(1819 Instant::now() < deadline,1820 "writer crash helper did not acquire its lease"1821 );1822 assert!(1823 child.try_wait().unwrap().is_none(),1824 "writer crash helper exited before acquiring its lease"1825 );1826 std::thread::sleep(Duration::from_millis(10));1827 }18281829 let host = SecretKey::from_bytes(&[7; 32]);1830 assert!(1831 AuthorizationStore::open(directory.path(), host.public())1832 .unwrap_err()1833 .to_string()1834 .contains("authorization writer is busy")1835 );1836 child.kill().unwrap();1837 child.wait().unwrap();18381839 let recovered = AuthorizationStore::open(directory.path(), host.public()).unwrap();1840 assert_eq!(recovered.snapshot().unwrap().epoch, 2);1841 }18421843 #[cfg(unix)]1844 #[test]1845 fn writer_crash_helper() {1846 const CHILD_DIRECTORY: &str = "SIGIL_AUTHORIZATION_CRASH_TEST_DIRECTORY";1847 let Ok(directory) = std::env::var(CHILD_DIRECTORY) else {1848 return;1849 };1850 let host = SecretKey::from_bytes(&[7; 32]);1851 let store = AuthorizationStore::open(&directory, host.public()).unwrap();1852 store.revoke_with_outcome(1_000).unwrap();1853 fs::write(Path::new(&directory).join("writer-ready"), b"ready").unwrap();1854 std::thread::sleep(Duration::from_secs(60));1855 }18561857 #[cfg(unix)]1858 #[test]1859 fn adversarial_lock_files_and_crash_temps_fail_closed_or_recover_atomically() {1860 use std::os::unix::fs::symlink;18611862 let host = SecretKey::from_bytes(&[7; 32]);1863 let symlink_directory = tempfile::tempdir().unwrap();1864 fs::set_permissions(symlink_directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1865 let target = symlink_directory.path().join("target");1866 fs::write(&target, b"target").unwrap();1867 symlink(&target, symlink_directory.path().join(LOCK_FILE_NAME)).unwrap();1868 assert!(AuthorizationStore::open(symlink_directory.path(), host.public()).is_err());18691870 let permissive_directory = tempfile::tempdir().unwrap();1871 fs::set_permissions(1872 permissive_directory.path(),1873 fs::Permissions::from_mode(0o700),1874 )1875 .unwrap();1876 let permissive_lock = permissive_directory.path().join(LOCK_FILE_NAME);1877 fs::write(&permissive_lock, b"").unwrap();1878 fs::set_permissions(&permissive_lock, fs::Permissions::from_mode(0o644)).unwrap();1879 assert!(AuthorizationStore::open(permissive_directory.path(), host.public()).is_err());18801881 let recovery_directory = tempfile::tempdir().unwrap();1882 fs::set_permissions(recovery_directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1883 let store = AuthorizationStore::open(recovery_directory.path(), host.public()).unwrap();1884 store.revoke_with_outcome(1_000).unwrap();1885 drop(store);1886 fs::write(1887 recovery_directory1888 .path()1889 .join(".authorization-v1.json.crashed.tmp"),1890 b"{not valid json",1891 )1892 .unwrap();18931894 let recovered = AuthorizationStore::open(recovery_directory.path(), host.public()).unwrap();1895 assert_eq!(recovered.snapshot().unwrap().epoch, 2);1896 }1897}