- commit
- 093e2c5d81c23d46f7d25aa6df1fc1e4fd41341f
- parent
- e267bba082fcdfce41d08ca9554573e4b1a4b918
- author
- Dami <dami.etoile@gmail.com>
| M | crates/ | 41 | ++- |
| M | crates/ | 660 | +++++++++++++++++++++++++++--- |
| A | crates/ | 613 | ++++++++++++++++++++++++++++ |
| M | crates/ | 191 | +++++++-- |
| M | crates/ | 57 | +++ |
| M | crates/ | 1 | + |
| M | crates/ | 9 | +- |
| M | crates/ | 16 | +- |
| M | crates/ | 309 | +++++++++++++- |
| M | docs/ | 7 | + |
| M | docs/ | 42 | +- |
| M | docs/ | 12 | +- |
| M | portal/ | 5 | + |
| M | portal/ | 18 | +- |
| M | portal/ | 17 | + |
| M | portal/ | 3 | +- |
| M | src-tauri/ | 37 | ++- |
@@ -207,6 +207,17 @@ struct EnrollmentStatus {
207207 grants: Vec<&'static str>,208208 epoch: u64,209209 enrolled_at_unix: Option<u64>,210+ enrollment_count: usize,211+ authorization_revision: u64,212+ viewers: Vec<EnrollmentViewerStatus>,213+}214+215+216+struct EnrollmentViewerStatus {217+ handle: String,218+ grants: Vec<&'static str>,219+ enrolled_at_unix: u64,220+ authorization_revision: u64,210221 }211222 212223 @@ -533,6 +544,12 @@ pub fn status_json(status: &ApplianceStatusV2, schema_version: u16) -> Result<se
533544 runtime.remove("instance_id");534545 runtime.remove("loaded_config_revision");535546 runtime.remove("reached_ready");547+ let enrollment = value["enrollment"]548+ .as_object_mut()549+ .context("appliance enrollment status is not an object")?;550+ enrollment.remove("enrollment_count");551+ enrollment.remove("authorization_revision");552+ enrollment.remove("viewers");536553 }537554 2 => {}538555 _ => anyhow::bail!("unsupported appliance status schema"),@@ -586,7 +603,22 @@ fn assemble_status(
586603 ) -> ApplianceStatusV2 {587604 let snapshot = authorization.snapshot;588605 let grants = snapshot.grants.map(grant_list).unwrap_or_default();589- let enrollment_active = snapshot.peer.is_some();606+ let enrollment_active = !snapshot.viewers.is_empty() || snapshot.peer.is_some();607+ let enrollment_count = if snapshot.viewers.is_empty() {608+ usize::from(snapshot.peer.is_some())609+ } else {610+ snapshot.viewers.len()611+ };612+ let viewers = snapshot613+ .viewers614+ .iter()615+ .map(|viewer| EnrollmentViewerStatus {616+ handle: viewer.handle.clone(),617+ grants: grant_list(viewer.grants),618+ enrolled_at_unix: viewer.enrolled_at_unix,619+ authorization_revision: viewer.authorization_revision,620+ })621+ .collect();590622 let overall = match (runtime.daemon, runtime.session) {591623 (DaemonState::Ready, SessionState::Active) => OverallState::Active,592624 (DaemonState::Ready, _) => OverallState::Ready,@@ -617,6 +649,9 @@ fn assemble_status(
617649 grants,618650 epoch: snapshot.epoch,619651 enrolled_at_unix: snapshot.enrolled_at_unix,652+ enrollment_count,653+ authorization_revision: snapshot.committed_revision,654+ viewers,620655 },621656 runtime,622657 }@@ -1212,6 +1247,8 @@ mod tests {
12121247 peer: Some(peer),12131248 grants: Some(InvitationGrants::ALL),12141249 enrolled_at_unix: Some(123),1250+ committed_revision: 1,1251+ viewers: Vec::new(),12151252 },12161253 storage_present: true,12171254 },@@ -1249,6 +1286,8 @@ mod tests {
12491286 peer: None,12501287 grants: None,12511288 enrolled_at_unix: None,1289+ committed_revision: 1,1290+ viewers: Vec::new(),12521291 },12531292 storage_present: false,12541293 },@@ -1,3 +1,4 @@
1+use std::collections::HashSet;12 use std::fs::{self, File, OpenOptions};23 use std::io::{Read, Write};34 use std::path::{Path, PathBuf};@@ -15,11 +16,16 @@ use sigil_protocol::{
1516 1617 use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};1718 18-const STATE_VERSION: u16 = 1;19-const STATE_FILE_NAME: &str = "authorization-v1.json";19+const STATE_VERSION: u16 = 2;20+const STATE_FILE_NAME: &str = "authorization-v2.json";21+const LEGACY_STATE_VERSION: u16 = 1;22+const LEGACY_STATE_FILE_NAME: &str = "authorization-v1.json";2023 const LOCK_FILE_NAME: &str = "authorization-v1.lock";2124 const MAX_STATE_FILE_LEN: u64 = 64 * 1024;2225 const MAX_CONSUMED_INVITATIONS: usize = 64;26+pub const MAX_ENROLLED_VIEWERS: usize = 32;27+const MAX_SECURITY_AUDIT_ENTRIES: usize = 64;28+const VIEWER_HANDLE_RANDOM_BYTES: usize = 8;2329 const WRITER_LOCK_TIMEOUT: Duration = Duration::from_millis(250);2430 const WRITER_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(10);2531 @@ -55,15 +61,17 @@ pub enum AuthorizationPolicy {
5561 DevelopmentBypass,5662 }5763 58-64+5965 pub struct AuthorizationSnapshot {6066 pub epoch: u64,6167 pub peer: Option<EndpointId>,6268 pub grants: Option<InvitationGrants>,6369 pub enrolled_at_unix: Option<u64>,70+ pub committed_revision: u64,71+ pub viewers: Vec<EnrolledViewer>,6472 }6573 66-74+6775 pub struct AuthorizationInspection {6876 pub snapshot: AuthorizationSnapshot,6977 pub storage_present: bool,@@ -76,21 +84,49 @@ pub struct RevocationOutcome {
7684 pub current_epoch: u64,7785 }7886 87+88+pub struct EnrolledViewer {89+ pub handle: String,90+ 91+ pub peer: EndpointId,92+ pub grants: InvitationGrants,93+ pub enrolled_at_unix: u64,94+ pub authorization_revision: u64,95+}96+97+98+pub struct AuthorizedViewer {99+ pub handle: String,100+ pub grants: InvitationGrants,101+ pub authorization_revision: u64,102+ pub committed_revision: u64,103+}104+105+106+pub struct AuthorizationMutation {107+ pub handle: String,108+ pub peer: EndpointId,109+ pub previous_grants: InvitationGrants,110+ pub current_grants: Option<InvitationGrants>,111+ pub authorization_revision: u64,112+ pub committed_revision: u64,113+}114+79115 impl AuthorizationSnapshot {80116 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- };117+ let viewers = state118+ .enrollments119+ .iter()120+ .map(Enrollment::as_public)121+ .collect::<Result<Vec<_>>>()?;122+ let first = viewers.first();89123 Ok(Self {90124 epoch: state.enrollment_epoch,91- peer,92- grants,93- enrolled_at_unix,125+ peer: first.map(|viewer| viewer.peer),126+ grants: first.map(|viewer| viewer.grants),127+ enrolled_at_unix: first.map(|viewer| viewer.enrolled_at_unix),128+ committed_revision: state.committed_revision,129+ viewers,94130 })95131 }96132 }@@ -101,13 +137,68 @@ struct AuthorizationState {
101137 version: u16,102138 host_node_id: String,103139 enrollment_epoch: u64,104- enrollment: Option<Enrollment>,140+ committed_revision: u64,141+ enrollments: Vec<Enrollment>,105142 consumed_invitations: Vec<ConsumedInvitation>,143+ security_audit: Vec<SecurityAuditEntry>,106144 }107145 108146 109147 110148 struct Enrollment {149+ peer_node_id: String,150+ handle: String,151+ grants: InvitationGrants,152+ enrolled_at_unix: u64,153+ authorization_revision: u64,154+}155+156+impl Enrollment {157+ fn as_public(&self) -> Result<EnrolledViewer> {158+ Ok(EnrolledViewer {159+ handle: self.handle.clone(),160+ peer: self.peer_node_id.parse::<EndpointId>()?,161+ grants: self.grants,162+ enrolled_at_unix: self.enrolled_at_unix,163+ authorization_revision: self.authorization_revision,164+ })165+ }166+}167+168+169+170+struct SecurityAuditEntry {171+ revision: u64,172+ occurred_at_unix: u64,173+ action: SecurityAuditAction,174+ handle: String,175+ previous_grants: Option<InvitationGrants>,176+ current_grants: Option<InvitationGrants>,177+}178+179+180+181+enum SecurityAuditAction {182+ Enrolled,183+ GrantsReplaced,184+ Revoked,185+ ResetAll,186+ MigratedV1,187+}188+189+190+191+struct LegacyAuthorizationState {192+ version: u16,193+ host_node_id: String,194+ enrollment_epoch: u64,195+ enrollment: Option<LegacyEnrollment>,196+ consumed_invitations: Vec<ConsumedInvitation>,197+}198+199+200+201+struct LegacyEnrollment {111202 peer_node_id: String,112203 grants: InvitationGrants,113204 enrolled_at_unix: u64,@@ -126,11 +217,64 @@ impl AuthorizationState {
126217 version: STATE_VERSION,127218 host_node_id: host.to_string(),128219 enrollment_epoch: 1,129- enrollment: None,220+ committed_revision: 1,221+ enrollments: Vec::new(),130222 consumed_invitations: Vec::new(),223+ security_audit: Vec::new(),131224 }132225 }133226 227+ fn from_legacy(legacy: LegacyAuthorizationState, expected_host: EndpointId) -> Result<Self> {228+ ensure!(229+ legacy.version == LEGACY_STATE_VERSION,230+ "unsupported legacy authorization state version"231+ );232+ ensure!(233+ legacy.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),234+ "authorization state belongs to a different Sigil host"235+ );236+ ensure!(237+ legacy.enrollment_epoch != 0,238+ "authorization epoch must be non-zero"239+ );240+ ensure!(241+ legacy.consumed_invitations.len() <= MAX_CONSUMED_INVITATIONS,242+ "authorization replay ledger exceeds its fixed bound"243+ );244+ let mut state = Self::empty(expected_host);245+ state.enrollment_epoch = legacy.enrollment_epoch;246+ state.consumed_invitations = legacy.consumed_invitations;247+ if let Some(enrollment) = legacy.enrollment {248+ let peer = enrollment249+ .peer_node_id250+ .parse::<EndpointId>()251+ .context("legacy authorization enrollment contains an invalid peer node ID")?;252+ enrollment.grants.validate()?;253+ ensure!(254+ enrollment.grants.contains(InvitationGrants::VIEW),255+ "legacy authorization enrollment must include view permission"256+ );257+ let handle = random_viewer_handle(&[])?;258+ state.enrollments.push(Enrollment {259+ peer_node_id: peer.to_string(),260+ handle: handle.clone(),261+ grants: enrollment.grants,262+ enrolled_at_unix: enrollment.enrolled_at_unix,263+ authorization_revision: 1,264+ });265+ state.push_audit(SecurityAuditEntry {266+ revision: 1,267+ occurred_at_unix: enrollment.enrolled_at_unix,268+ action: SecurityAuditAction::MigratedV1,269+ handle,270+ previous_grants: Some(enrollment.grants),271+ current_grants: Some(enrollment.grants),272+ });273+ }274+ state.validate(expected_host)?;275+ Ok(state)276+ }277+134278 fn validate(&self, expected_host: EndpointId) -> Result<()> {135279 ensure!(136280 self.version == STATE_VERSION,@@ -144,20 +288,48 @@ impl AuthorizationState {
144288 self.enrollment_epoch != 0,145289 "authorization epoch must be non-zero"146290 );291+ ensure!(292+ self.committed_revision != 0,293+ "authorization committed revision must be non-zero"294+ );295+ ensure!(296+ self.enrollments.len() <= MAX_ENROLLED_VIEWERS,297+ "authorization enrollment map exceeds its fixed bound"298+ );147299 ensure!(148300 self.consumed_invitations.len() <= MAX_CONSUMED_INVITATIONS,149301 "authorization replay ledger exceeds its fixed bound"150302 );151- if let Some(enrollment) = &self.enrollment {152- enrollment303+ ensure!(304+ self.security_audit.len() <= MAX_SECURITY_AUDIT_ENTRIES,305+ "authorization security audit exceeds its fixed bound"306+ );307+ let mut peers = HashSet::with_capacity(self.enrollments.len());308+ let mut handles = HashSet::with_capacity(self.enrollments.len());309+ for enrollment in &self.enrollments {310+ let peer = enrollment153311 .peer_node_id154312 .parse::<EndpointId>()155313 .context("authorization enrollment contains an invalid peer node ID")?;314+ ensure!(315+ peers.insert(peer),316+ "authorization contains a duplicate peer"317+ );318+ validate_viewer_handle(&enrollment.handle)?;319+ ensure!(320+ handles.insert(enrollment.handle.as_str()),321+ "authorization contains a duplicate viewer handle"322+ );156323 enrollment.grants.validate()?;157324 ensure!(158325 enrollment.grants.contains(InvitationGrants::VIEW),159326 "authorization enrollment must include view permission"160327 );328+ ensure!(329+ enrollment.authorization_revision != 0330+ && enrollment.authorization_revision <= self.committed_revision,331+ "authorization enrollment revision is invalid"332+ );161333 }162334 for (index, consumed) in self.consumed_invitations.iter().enumerate() {163335 ensure!(@@ -171,6 +343,23 @@ impl AuthorizationState {
171343 "authorization replay ledger contains a duplicate nonce"172344 );173345 }346+ let mut last_audit_revision = 0;347+ for audit in &self.security_audit {348+ ensure!(349+ audit.revision != 0350+ && audit.revision <= self.committed_revision351+ && audit.revision >= last_audit_revision,352+ "authorization security audit revision is invalid"353+ );354+ validate_viewer_handle(&audit.handle)?;355+ if let Some(grants) = audit.previous_grants {356+ grants.validate()?;357+ }358+ if let Some(grants) = audit.current_grants {359+ grants.validate()?;360+ }361+ last_audit_revision = audit.revision;362+ }174363 Ok(())175364 }176365 @@ -184,6 +373,43 @@ impl AuthorizationState {
184373 });185374 before != self.consumed_invitations.len()186375 }376+377+ fn next_revision(&self) -> Result<u64> {378+ self.committed_revision379+ .checked_add(1)380+ .context("authorization committed revision exhausted")381+ }382+383+ fn push_audit(&mut self, entry: SecurityAuditEntry) {384+ if self.security_audit.len() == MAX_SECURITY_AUDIT_ENTRIES {385+ self.security_audit.remove(0);386+ }387+ self.security_audit.push(entry);388+ }389+}390+391+fn validate_viewer_handle(handle: &str) -> Result<()> {392+ ensure!(393+ handle.len() == "viewer-".len() + VIEWER_HANDLE_RANDOM_BYTES * 2394+ && handle.starts_with("viewer-")395+ && handle["viewer-".len()..]396+ .bytes()397+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),398+ "authorization viewer handle is invalid"399+ );400+ Ok(())401+}402+403+fn random_viewer_handle(existing: &[Enrollment]) -> Result<String> {404+ for _ in 0..16 {405+ let mut random = [0_u8; VIEWER_HANDLE_RANDOM_BYTES];406+ getrandom::fill(&mut random).context("generating opaque viewer handle")?;407+ let handle = format!("viewer-{:016x}", u64::from_be_bytes(random));408+ if !existing.iter().any(|viewer| viewer.handle == handle) {409+ return Ok(handle);410+ }411+ }412+ anyhow::bail!("could not allocate a unique opaque viewer handle")187413 }188414 189415 impl AuthorizationStore {@@ -195,8 +421,12 @@ impl AuthorizationStore {
195421 location.ensure_state_directory()?;196422 let lock = location.open_lock()?;197423 location.lock_exclusive_bounded(&lock, WRITER_LOCK_TIMEOUT)?;198- let state = location.load_state()?;424+ let (state, migrated) = location.load_state()?;199425 state.validate(host)?;426+ if migrated {427+ location.write_state(&state)?;428+ location.remove_legacy_state()?;429+ }200430 Ok(Self {201431 location,202432 writer: Arc::new(AuthorizationWriter {@@ -249,8 +479,12 @@ impl AuthorizationStore {
249479 "invitation TTL must be between 1 and {MAX_INVITATION_TTL_SECS} seconds"250480 );251481 ensure!(252- snapshot.peer.is_none() && snapshot.grants.is_none(),253- "a Portal peer is already enrolled; revoke it before issuing another invitation"482+ snapshot.viewers.len() < MAX_ENROLLED_VIEWERS,483+ "authorization enrollment map is full"484+ );485+ ensure!(486+ !snapshot.viewers.iter().any(|viewer| viewer.peer == peer),487+ "Portal peer is already enrolled"254488 );255489 InvitationClaims::new(256490 *host.as_bytes(),@@ -265,12 +499,12 @@ impl AuthorizationStore {
265499 .map_err(Into::into)266500 }267501 268- pub fn authorize_or_redeem(502+ pub fn authorize_or_redeem_viewer(269503 &self,270504 remote: EndpointId,271505 invitation_token: Option<&str>,272506 now: u64,273- ) -> Result<InvitationGrants> {507+ ) -> Result<AuthorizedViewer> {274508 // Admission never waits for another in-process authorization operation.275509 // A concurrent redemption or durable write fails this handshake closed276510 // and lets the client retry instead of growing accept-path latency.@@ -282,19 +516,29 @@ impl AuthorizationStore {
282516 let result = match invitation_token {283517 None => {284518 let enrollment = candidate285- .enrollment286- .as_ref()519+ .enrollments520+ .iter()521+ .find(|enrollment| {522+ enrollment.peer_node_id.parse::<EndpointId>().ok() == Some(remote)523+ })287524 .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.grants525+ AuthorizedViewer {526+ handle: enrollment.handle.clone(),527+ grants: enrollment.grants,528+ authorization_revision: enrollment.authorization_revision,529+ committed_revision: candidate.committed_revision,530+ }293531 }294532 Some(token) => {295533 ensure!(296- candidate.enrollment.is_none(),297- "an invitation cannot be used after enrollment"534+ !candidate.enrollments.iter().any(|enrollment| {535+ enrollment.peer_node_id.parse::<EndpointId>().ok() == Some(remote)536+ }),537+ "an invitation cannot be used after enrollment: Portal peer is already enrolled"538+ );539+ ensure!(540+ candidate.enrollments.len() < MAX_ENROLLED_VIEWERS,541+ "authorization enrollment map is full"298542 );299543 let invitation = SignedInvitation::decode(token)300544 .context("decoding and verifying enrollment invitation")?;@@ -331,17 +575,35 @@ impl AuthorizationStore {
331575 candidate.consumed_invitations.len() < MAX_CONSUMED_INVITATIONS,332576 "authorization replay ledger is full"333577 );334- candidate.enrollment = Some(Enrollment {578+ let revision = candidate.next_revision()?;579+ let handle = random_viewer_handle(&candidate.enrollments)?;580+ candidate.enrollments.push(Enrollment {335581 peer_node_id: remote.to_string(),582+ handle: handle.clone(),336583 grants: claims.grants,337584 enrolled_at_unix: now,585+ authorization_revision: revision,338586 });339587 candidate.consumed_invitations.push(ConsumedInvitation {340588 nonce: claims.nonce,341589 expires_at_unix: claims.expires_at_unix,342590 });591+ candidate.committed_revision = revision;592+ candidate.push_audit(SecurityAuditEntry {593+ revision,594+ occurred_at_unix: now,595+ action: SecurityAuditAction::Enrolled,596+ handle: handle.clone(),597+ previous_grants: None,598+ current_grants: Some(claims.grants),599+ });343600 changed = true;344- claims.grants601+ AuthorizedViewer {602+ handle,603+ grants: claims.grants,604+ authorization_revision: revision,605+ committed_revision: revision,606+ }345607 }346608 };347609 @@ -352,6 +614,16 @@ impl AuthorizationStore {
352614 Ok(result)353615 }354616 617+ pub fn authorize_or_redeem(618+ &self,619+ remote: EndpointId,620+ invitation_token: Option<&str>,621+ now: u64,622+ ) -> Result<InvitationGrants> {623+ self.authorize_or_redeem_viewer(remote, invitation_token, now)624+ .map(|viewer| viewer.grants)625+ }626+355627 356628 fn snapshot(&self) -> Result<AuthorizationSnapshot> {357629 let state = self.lock_state()?;@@ -379,6 +651,8 @@ impl AuthorizationStore {
379651 peer: None,380652 grants: None,381653 enrolled_at_unix: None,654+ committed_revision: 1,655+ viewers: Vec::new(),382656 },383657 storage_present: false,384658 });@@ -405,7 +679,7 @@ impl AuthorizationStore {
405679 );406680 }407681 408- let path = location.state_path();682+ let mut path = location.state_path();409683 let mut options = OpenOptions::new();410684 options.read(true);411685 @@ -413,15 +687,26 @@ impl AuthorizationStore {
413687 let mut file = match options.open(&path) {414688 Ok(file) => file,415689 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- });690+ path = location.legacy_state_path();691+ match options.open(&path) {692+ Ok(file) => file,693+ Err(legacy_error) if legacy_error.kind() == std::io::ErrorKind::NotFound => {694+ return Ok(AuthorizationInspection {695+ snapshot: AuthorizationSnapshot {696+ epoch: 1,697+ peer: None,698+ grants: None,699+ enrolled_at_unix: None,700+ committed_revision: 1,701+ viewers: Vec::new(),702+ },703+ storage_present: false,704+ });705+ }706+ Err(legacy_error) => {707+ return Err(legacy_error).context("opening legacy authorization state");708+ }709+ }425710 }426711 Err(error) => return Err(error).context("opening authorization state"),427712 };@@ -440,8 +725,13 @@ impl AuthorizationStore {
440725 "authorization state exceeds its fixed size bound"441726 );442727 ensure!(!bytes.is_empty(), "authorization state is empty");443- let state: AuthorizationState =444- serde_json::from_slice(&bytes).context("parsing authorization state")?;728+ let state: AuthorizationState = if path == location.state_path() {729+ serde_json::from_slice(&bytes).context("parsing authorization state")?730+ } else {731+ let legacy: LegacyAuthorizationState =732+ serde_json::from_slice(&bytes).context("parsing legacy authorization state")?;733+ AuthorizationState::from_legacy(legacy, host)?734+ };445735 state.validate(host)?;446736 let snapshot = AuthorizationSnapshot::from_state(&state)?;447737 Ok(AuthorizationInspection {@@ -455,12 +745,24 @@ impl AuthorizationStore {
455745 state.validate(self.location.host)?;456746 let mut candidate = state.clone();457747 let previous_epoch = candidate.enrollment_epoch;458- let had_enrollment = candidate.enrollment.take().is_some();748+ let had_enrollment = !candidate.enrollments.is_empty();749+ let removed = std::mem::take(&mut candidate.enrollments);459750 candidate.enrollment_epoch = candidate460751 .enrollment_epoch461752 .checked_add(1)462753 .context("authorization epoch exhausted")?;463754 candidate.prune_expired(now);755+ candidate.committed_revision = candidate.next_revision()?;756+ for enrollment in removed {757+ candidate.push_audit(SecurityAuditEntry {758+ revision: candidate.committed_revision,759+ occurred_at_unix: now,760+ action: SecurityAuditAction::ResetAll,761+ handle: enrollment.handle,762+ previous_grants: Some(enrollment.grants),763+ current_grants: None,764+ });765+ }464766 self.location.write_state(&candidate)?;465767 *state = candidate;466768 Ok(RevocationOutcome {@@ -470,6 +772,106 @@ impl AuthorizationStore {
470772 })471773 }472774 775+ pub fn list_viewers(&self) -> Result<AuthorizationSnapshot> {776+ let state = self.lock_state()?;777+ state.validate(self.location.host)?;778+ AuthorizationSnapshot::from_state(&state)779+ }780+781+ pub fn revoke_viewer(&self, handle: &str, now: u64) -> Result<AuthorizationMutation> {782+ validate_viewer_handle(handle)?;783+ let mut state = self.lock_state()?;784+ state.validate(self.location.host)?;785+ let mut candidate = state.clone();786+ let index = candidate787+ .enrollments788+ .iter()789+ .position(|viewer| viewer.handle == handle)790+ .context("viewer handle is not enrolled")?;791+ let removed = candidate.enrollments.remove(index);792+ let revision = candidate.next_revision()?;793+ candidate.committed_revision = revision;794+ candidate.prune_expired(now);795+ candidate.push_audit(SecurityAuditEntry {796+ revision,797+ occurred_at_unix: now,798+ action: SecurityAuditAction::Revoked,799+ handle: removed.handle.clone(),800+ previous_grants: Some(removed.grants),801+ current_grants: None,802+ });803+ self.location.write_state(&candidate)?;804+ *state = candidate;805+ Ok(AuthorizationMutation {806+ handle: removed.handle,807+ peer: removed.peer_node_id.parse()?,808+ previous_grants: removed.grants,809+ current_grants: None,810+ authorization_revision: revision,811+ committed_revision: revision,812+ })813+ }814+815+ pub fn replace_viewer_grants(816+ &self,817+ handle: &str,818+ grants: Option<InvitationGrants>,819+ now: u64,820+ ) -> Result<AuthorizationMutation> {821+ validate_viewer_handle(handle)?;822+ if let Some(grants) = grants {823+ grants.validate()?;824+ ensure!(825+ grants.contains(InvitationGrants::VIEW),826+ "a persisted viewer grant must include view permission"827+ );828+ }829+ let mut state = self.lock_state()?;830+ state.validate(self.location.host)?;831+ let mut candidate = state.clone();832+ let index = candidate833+ .enrollments834+ .iter()835+ .position(|viewer| viewer.handle == handle)836+ .context("viewer handle is not enrolled")?;837+ let previous = candidate.enrollments[index].clone();838+ ensure!(839+ grants != Some(previous.grants),840+ "viewer already has the requested grants"841+ );842+ let revision = candidate.next_revision()?;843+ if let Some(grants) = grants {844+ candidate.enrollments[index].grants = grants;845+ candidate.enrollments[index].authorization_revision = revision;846+ } else {847+ candidate.enrollments.remove(index);848+ }849+ candidate.committed_revision = revision;850+ candidate.prune_expired(now);851+ candidate.push_audit(SecurityAuditEntry {852+ revision,853+ occurred_at_unix: now,854+ action: if grants.is_some() {855+ SecurityAuditAction::GrantsReplaced856+ } else {857+ SecurityAuditAction::Revoked858+ },859+ handle: previous.handle.clone(),860+ previous_grants: Some(previous.grants),861+ current_grants: grants,862+ });863+ self.location.write_state(&candidate)?;864+ *state = candidate;865+ Ok(AuthorizationMutation {866+ handle: previous.handle,867+ peer: previous.peer_node_id.parse()?,868+ previous_grants: previous.grants,869+ current_grants: grants,870+ authorization_revision: revision,871+ committed_revision: revision,872+ })873+ }874+473875 fn lock_state(&self) -> Result<MutexGuard<'_, AuthorizationState>> {474876 match self.writer.state.try_lock() {475877 Ok(state) => Ok(state),@@ -488,6 +890,10 @@ impl AuthorizationLocation {
488890 self.state_directory.join(STATE_FILE_NAME)489891 }490892 893+ fn legacy_state_path(&self) -> PathBuf {894+ self.state_directory.join(LEGACY_STATE_FILE_NAME)895+ }896+491897 fn lock_path(&self) -> PathBuf {492898 self.state_directory.join(LOCK_FILE_NAME)493899 }@@ -572,8 +978,8 @@ impl AuthorizationLocation {
572978 Ok(())573979 }574980 575- fn load_state(&self) -> Result<AuthorizationState> {576- let path = self.state_path();981+ fn load_state(&self) -> Result<(AuthorizationState, bool)> {982+ let mut path = self.state_path();577983 let mut options = OpenOptions::new();578984 options.read(true);579985 @@ -581,7 +987,16 @@ impl AuthorizationLocation {
581987 let mut file = match options.open(&path) {582988 Ok(file) => file,583989 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {584- return Ok(AuthorizationState::empty(self.host));990+ path = self.legacy_state_path();991+ match options.open(&path) {992+ Ok(file) => file,993+ Err(legacy_error) if legacy_error.kind() == std::io::ErrorKind::NotFound => {994+ return Ok((AuthorizationState::empty(self.host), false));995+ }996+ Err(legacy_error) => {997+ return Err(legacy_error).context("opening legacy authorization state");998+ }999+ }5851000 }5861001 Err(error) => return Err(error).context("opening authorization state"),5871002 };@@ -600,7 +1015,37 @@ impl AuthorizationLocation {
6001015 "authorization state exceeds its fixed size bound"6011016 );6021017 ensure!(!bytes.is_empty(), "authorization state is empty");603- serde_json::from_slice(&bytes).context("parsing authorization state")1018+ if path == self.state_path() {1019+ serde_json::from_slice(&bytes)1020+ .context("parsing authorization state")1021+ .map(|state| (state, false))1022+ } else {1023+ let legacy: LegacyAuthorizationState =1024+ serde_json::from_slice(&bytes).context("parsing legacy authorization state")?;1025+ Ok((AuthorizationState::from_legacy(legacy, self.host)?, true))1026+ }1027+ }1028+1029+ fn remove_legacy_state(&self) -> Result<()> {1030+ let path = self.legacy_state_path();1031+ match fs::symlink_metadata(&path) {1032+ Ok(metadata) => {1033+ ensure!(1034+ metadata.is_file() && !metadata.file_type().is_symlink(),1035+ "legacy authorization state is not a regular file"1036+ );1037+ 1038+ ensure!(1039+ metadata.uid() == unsafe { libc::geteuid() } && metadata.mode() & 0o077 == 0,1040+ "legacy authorization state has unsafe ownership or permissions"1041+ );1042+ fs::remove_file(&path)?;1043+ File::open(&self.state_directory)?.sync_all()?;1044+ Ok(())1045+ }1046+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),1047+ Err(error) => Err(error).context("inspecting legacy authorization state"),1048+ }6041049 }6051050 6061051 fn write_state(&self, state: &AuthorizationState) -> Result<()> {@@ -664,23 +1109,40 @@ impl AuthorizationLocation {
6641109 }6651110 6661111 impl AuthorizationPolicy {667- pub fn authorize_or_redeem(1112+ pub fn authorize_or_redeem_viewer(6681113 &self,6691114 remote: EndpointId,6701115 invitation_token: Option<&str>,6711116 now: u64,672- ) -> Result<InvitationGrants> {1117+ ) -> Result<AuthorizedViewer> {6731118 match self {674- Self::Required(store) => store.authorize_or_redeem(remote, invitation_token, now),1119+ Self::Required(store) => {1120+ store.authorize_or_redeem_viewer(remote, invitation_token, now)1121+ }6751122 Self::TestPatternProof | Self::DevelopmentBypass => {6761123 ensure!(6771124 invitation_token.is_none(),6781125 "unrestricted development modes do not accept invitations"6791126 );680- Ok(InvitationGrants::ALL)1127+ Ok(AuthorizedViewer {1128+ handle: "viewer-0000000000000000".to_owned(),1129+ grants: InvitationGrants::ALL,1130+ authorization_revision: 1,1131+ committed_revision: 1,1132+ })6811133 }6821134 }6831135 }1136+1137+ pub fn authorize_or_redeem(1138+ &self,1139+ remote: EndpointId,1140+ invitation_token: Option<&str>,1141+ now: u64,1142+ ) -> Result<InvitationGrants> {1143+ self.authorize_or_redeem_viewer(remote, invitation_token, now)1144+ .map(|viewer| viewer.grants)1145+ }6841146 }6851147 6861148 pub fn unix_timestamp_now() -> Result<u64> {@@ -772,6 +1234,92 @@ mod tests {
7721234 );7731235 }7741236 1237+ 1238+ fn multiple_peers_enroll_with_unique_stable_handles() {1239+ let (_directory, host, first_peer, store) = store();1240+ let second_peer = SecretKey::from_bytes(&[19; 32]);1241+ let first_token = token(1242+ &store,1243+ &host,1244+ first_peer.public(),1245+ 1_000,1246+ [21; 32],1247+ InvitationGrants::ALL,1248+ );1249+ let first = store1250+ .authorize_or_redeem_viewer(first_peer.public(), Some(&first_token), 1_001)1251+ .unwrap();1252+ let second_token = token(1253+ &store,1254+ &host,1255+ second_peer.public(),1256+ 1_002,1257+ [22; 32],1258+ InvitationGrants::VIEW,1259+ );1260+ let second = store1261+ .authorize_or_redeem_viewer(second_peer.public(), Some(&second_token), 1_003)1262+ .unwrap();1263+1264+ assert_ne!(first.handle, second.handle);1265+ validate_viewer_handle(&first.handle).unwrap();1266+ validate_viewer_handle(&second.handle).unwrap();1267+ assert!(second.committed_revision > first.committed_revision);1268+ let snapshot = store.list_viewers().unwrap();1269+ assert_eq!(snapshot.viewers.len(), 2);1270+ assert_eq!(1271+ store1272+ .authorize_or_redeem_viewer(first_peer.public(), None, 1_004)1273+ .unwrap()1274+ .handle,1275+ first.handle1276+ );1277+ }1278+1279+ 1280+ fn sole_writer_atomically_converts_v1_and_never_prefers_it_over_v2() {1281+ let directory = tempfile::tempdir().unwrap();1282+ 1283+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();1284+ let host = SecretKey::from_bytes(&[27; 32]);1285+ let peer = SecretKey::from_bytes(&[28; 32]);1286+ let legacy = serde_json::json!({1287+ "version": 1,1288+ "host_node_id": host.public().to_string(),1289+ "enrollment_epoch": 4,1290+ "enrollment": {1291+ "peer_node_id": peer.public().to_string(),1292+ "grants": InvitationGrants::ALL.bits(),1293+ "enrolled_at_unix": 12341294+ },1295+ "consumed_invitations": [{1296+ "nonce": vec![31_u8; 32],1297+ "expires_at_unix": 20001298+ }]1299+ });1300+ let legacy_path = directory.path().join(LEGACY_STATE_FILE_NAME);1301+ fs::write(&legacy_path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap();1302+ 1303+ fs::set_permissions(&legacy_path, fs::Permissions::from_mode(0o600)).unwrap();1304+1305+ let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();1306+ let snapshot = store.list_viewers().unwrap();1307+ assert_eq!(snapshot.epoch, 4);1308+ assert_eq!(snapshot.viewers.len(), 1);1309+ assert_eq!(snapshot.viewers[0].peer, peer.public());1310+ assert!(directory.path().join(STATE_FILE_NAME).is_file());1311+ assert!(!legacy_path.exists());1312+1313+ // A stale legacy file appearing after conversion is ignored; v2 is the1314+ // only authority once its atomic replacement is present.1315+ fs::write(&legacy_path, b"{not valid json").unwrap();1316+ 1317+ fs::set_permissions(&legacy_path, fs::Permissions::from_mode(0o600)).unwrap();1318+ drop(store);1319+ let reopened = AuthorizationStore::open(directory.path(), host.public()).unwrap();1320+ assert_eq!(reopened.list_viewers().unwrap().viewers.len(), 1);1321+ }1322+7751323 7761324 fn read_only_inspection_never_creates_state_and_retains_enrollment_time() {7771325 let root = tempfile::tempdir().unwrap();@@ -0,0 +1,613 @@
1+use std::path::{Path, PathBuf};2+use std::sync::Arc;3+use std::time::Duration;4+5+use anyhow::{Context, Result, ensure};6+use iroh::EndpointId;7+use serde::{Deserialize, Serialize};8+use sigil_protocol::{InvitationGrants, MAX_INVITATION_TTL_SECS, SignedInvitation};9+use tokio::io::{AsyncReadExt, AsyncWriteExt};10+11+use crate::authorization::{12+ AuthorizationMutation, AuthorizationStore, EnrolledViewer, grant_names, unix_timestamp_now,13+};14+use crate::secure_state;15+use crate::server::{InputOperations, SessionRegistry};16+17+pub const ADMIN_SOCKET_FILE: &str = "authorization-admin-v1.sock";18+const MAX_ADMIN_REQUEST_BYTES: u64 = 16 * 1024;19+const MAX_ADMIN_RESPONSE_BYTES: usize = 64 * 1024;20+const ADMIN_IO_TIMEOUT: Duration = Duration::from_secs(5);21+22+23+24+pub enum AuthorizationAdminRequest {25+ List,26+ InspectRevision,27+ CreateInvitation {28+ peer_node_id: String,29+ grants: u8,30+ expires_in_seconds: u64,31+ },32+ RevokeViewer {33+ handle: String,34+ },35+ ReplaceGrants {36+ handle: String,37+ grants: Option<u8>,38+ },39+}40+41+42+43+pub enum AuthorizationAdminResult {44+ Viewers {45+ committed_revision: u64,46+ enrollment_epoch: u64,47+ viewers: Vec<AdminViewer>,48+ },49+ Revision {50+ committed_revision: u64,51+ },52+ Invitation {53+ token: String,54+ expires_at_unix: u64,55+ committed_revision: u64,56+ },57+ Mutation {58+ handle: String,59+ committed_revision: u64,60+ disconnected: bool,61+ input_neutralized: bool,62+ },63+}64+65+66+67+pub struct AdminViewer {68+ pub handle: String,69+ pub grants: Vec<String>,70+ pub enrolled_at_unix: u64,71+ pub authorization_revision: u64,72+}73+74+75+76+struct AuthorizationAdminResponse {77+ ok: bool,78+ 79+ result: Option<AuthorizationAdminResult>,80+ 81+ error: Option<String>,82+}83+84+pub struct AuthorizationAdminServer {85+ socket_path: PathBuf,86+ shutdown: Option<tokio::sync::oneshot::Sender<()>>,87+ task: Option<tokio::task::JoinHandle<()>>,88+}89+90+impl AuthorizationAdminServer {91+ pub fn start(92+ state_directory: &Path,93+ store: AuthorizationStore,94+ sessions: Arc<SessionRegistry>,95+ input_operations: Arc<InputOperations>,96+ host_secret: [u8; 32],97+ ) -> Result<Self> {98+ 99+ {100+ let _ = (101+ state_directory,102+ store,103+ sessions,104+ input_operations,105+ host_secret,106+ );107+ anyhow::bail!("authorization administration requires a Unix host");108+ }109+ 110+ {111+ let socket_path =112+ secure_state::prepare_private_unix_socket(state_directory, ADMIN_SOCKET_FILE)?;113+ let listener = tokio::net::UnixListener::bind(&socket_path).with_context(|| {114+ format!(115+ "binding authorization administration socket {}",116+ socket_path.display()117+ )118+ })?;119+ secure_state::secure_private_unix_socket(&socket_path)?;120+ let (shutdown, mut shutdown_rx) = tokio::sync::oneshot::channel();121+ let task_path = socket_path.clone();122+ let task = tokio::spawn(async move {123+ loop {124+ tokio::select! {125+ _ = &mut shutdown_rx => break,126+ accepted = listener.accept() => {127+ match accepted {128+ Ok((stream, _address)) => {129+ if let Err(error) = serve_request(130+ stream,131+ &store,132+ &sessions,133+ &input_operations,134+ host_secret,135+ ).await {136+ tracing::warn!(%error, "authorization administration request failed");137+ }138+ }139+ Err(error) => {140+ tracing::warn!(%error, "authorization administration listener failed");141+ break;142+ }143+ }144+ }145+ }146+ }147+ let _ = std::fs::remove_file(&task_path);148+ });149+ Ok(Self {150+ socket_path,151+ shutdown: Some(shutdown),152+ task: Some(task),153+ })154+ }155+ }156+157+ pub async fn shutdown(mut self) {158+ if let Some(shutdown) = self.shutdown.take() {159+ let _ = shutdown.send(());160+ }161+ if let Some(task) = self.task.take() {162+ let _ = task.await;163+ }164+ }165+}166+167+impl Drop for AuthorizationAdminServer {168+ fn drop(&mut self) {169+ if let Some(shutdown) = self.shutdown.take() {170+ let _ = shutdown.send(());171+ }172+ if let Some(task) = self.task.take() {173+ task.abort();174+ }175+ let _ = std::fs::remove_file(&self.socket_path);176+ }177+}178+179+180+async fn serve_request(181+ mut stream: tokio::net::UnixStream,182+ store: &AuthorizationStore,183+ sessions: &SessionRegistry,184+ input_operations: &InputOperations,185+ host_secret: [u8; 32],186+) -> Result<()> {187+ let response = match tokio::time::timeout(ADMIN_IO_TIMEOUT, async {188+ let mut bytes = Vec::new();189+ (&mut stream)190+ .take(MAX_ADMIN_REQUEST_BYTES + 1)191+ .read_to_end(&mut bytes)192+ .await?;193+ ensure!(194+ !bytes.is_empty() && bytes.len() as u64 <= MAX_ADMIN_REQUEST_BYTES,195+ "authorization administration request is empty or oversized"196+ );197+ let request: AuthorizationAdminRequest = serde_json::from_slice(&bytes)198+ .context("parsing authorization administration request")?;199+ handle_request(request, store, sessions, input_operations, host_secret).await200+ })201+ .await202+ {203+ Ok(Ok(result)) => AuthorizationAdminResponse {204+ ok: true,205+ result: Some(result),206+ error: None,207+ },208+ Ok(Err(error)) => AuthorizationAdminResponse {209+ ok: false,210+ result: None,211+ error: Some(format!("{error:#}")),212+ },213+ Err(_) => AuthorizationAdminResponse {214+ ok: false,215+ result: None,216+ error: Some("authorization administration request timed out".to_owned()),217+ },218+ };219+ let encoded = serde_json::to_vec(&response)?;220+ ensure!(221+ encoded.len() <= MAX_ADMIN_RESPONSE_BYTES,222+ "authorization administration response exceeds its fixed bound"223+ );224+ tokio::time::timeout(ADMIN_IO_TIMEOUT, async {225+ stream.write_all(&encoded).await?;226+ stream.shutdown().await227+ })228+ .await229+ .context("timed out writing authorization administration response")??;230+ Ok(())231+}232+233+async fn handle_request(234+ request: AuthorizationAdminRequest,235+ store: &AuthorizationStore,236+ sessions: &SessionRegistry,237+ input_operations: &InputOperations,238+ host_secret: [u8; 32],239+) -> Result<AuthorizationAdminResult> {240+ match request {241+ AuthorizationAdminRequest::List => {242+ let snapshot = store.list_viewers()?;243+ Ok(AuthorizationAdminResult::Viewers {244+ committed_revision: snapshot.committed_revision,245+ enrollment_epoch: snapshot.epoch,246+ viewers: snapshot.viewers.iter().map(AdminViewer::from).collect(),247+ })248+ }249+ AuthorizationAdminRequest::InspectRevision => {250+ let snapshot = store.list_viewers()?;251+ Ok(AuthorizationAdminResult::Revision {252+ committed_revision: snapshot.committed_revision,253+ })254+ }255+ AuthorizationAdminRequest::CreateInvitation {256+ peer_node_id,257+ grants,258+ expires_in_seconds,259+ } => {260+ let peer = peer_node_id261+ .parse::<EndpointId>()262+ .context("invitation peer node ID is invalid")?;263+ let grants = InvitationGrants::new(grants)?;264+ ensure!(265+ grants.contains(InvitationGrants::VIEW),266+ "invitations must grant view permission"267+ );268+ ensure!(269+ (1..=MAX_INVITATION_TTL_SECS).contains(&expires_in_seconds),270+ "invitation TTL is outside the fixed bound"271+ );272+ let snapshot = store.list_viewers()?;273+ let mut nonce = [0_u8; 32];274+ getrandom::fill(&mut nonce).context("generating invitation nonce")?;275+ let now = unix_timestamp_now()?;276+ let host = iroh::SecretKey::from_bytes(&host_secret).public();277+ let claims = AuthorizationStore::issue_claims_from_snapshot(278+ host,279+ snapshot.clone(),280+ peer,281+ grants,282+ expires_in_seconds,283+ now,284+ nonce,285+ )?;286+ let expires_at_unix = claims.expires_at_unix;287+ let token = SignedInvitation::issue(claims, &host_secret)?.encode();288+ Ok(AuthorizationAdminResult::Invitation {289+ token,290+ expires_at_unix,291+ committed_revision: snapshot.committed_revision,292+ })293+ }294+ AuthorizationAdminRequest::RevokeViewer { handle } => {295+ let mutation = store.revoke_viewer(&handle, unix_timestamp_now()?)?;296+ apply_mutation(mutation, sessions, input_operations)297+ }298+ AuthorizationAdminRequest::ReplaceGrants { handle, grants } => {299+ let grants = grants.map(InvitationGrants::new).transpose()?;300+ let mutation = store.replace_viewer_grants(&handle, grants, unix_timestamp_now()?)?;301+ apply_mutation(mutation, sessions, input_operations)302+ }303+ }304+}305+306+fn apply_mutation(307+ mutation: AuthorizationMutation,308+ sessions: &SessionRegistry,309+ input_operations: &InputOperations,310+) -> Result<AuthorizationAdminResult> {311+ let effect = sessions.apply_authorization_mutation(&mutation)?;312+ if effect.neutralize_input {313+ input_operations.reset()?;314+ if !effect.disconnected {315+ sessions.complete_authorization_neutralization(316+ mutation.peer,317+ mutation.authorization_revision,318+ )?;319+ }320+ }321+ Ok(AuthorizationAdminResult::Mutation {322+ handle: mutation.handle,323+ committed_revision: mutation.committed_revision,324+ disconnected: effect.disconnected,325+ input_neutralized: effect.neutralize_input,326+ })327+}328+329+impl From<&EnrolledViewer> for AdminViewer {330+ fn from(viewer: &EnrolledViewer) -> Self {331+ Self {332+ handle: viewer.handle.clone(),333+ grants: grant_names(viewer.grants)334+ .split(',')335+ .map(str::to_owned)336+ .collect(),337+ enrolled_at_unix: viewer.enrolled_at_unix,338+ authorization_revision: viewer.authorization_revision,339+ }340+ }341+}342+343+344+pub async fn request(345+ state_directory: &Path,346+ request: &AuthorizationAdminRequest,347+) -> Result<AuthorizationAdminResult> {348+ secure_state::validate_private_directory(state_directory)?;349+ let socket_path = state_directory.join(ADMIN_SOCKET_FILE);350+ let metadata = std::fs::symlink_metadata(&socket_path)351+ .with_context(|| format!("inspecting administration socket {}", socket_path.display()))?;352+ use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};353+ ensure!(354+ metadata.file_type().is_socket()355+ && !metadata.file_type().is_symlink()356+ && metadata.uid() == unsafe { libc::geteuid() }357+ && metadata.mode() & 0o077 == 0,358+ "authorization administration socket is unsafe"359+ );360+ let encoded = serde_json::to_vec(request)?;361+ ensure!(362+ encoded.len() as u64 <= MAX_ADMIN_REQUEST_BYTES,363+ "authorization administration request exceeds its fixed bound"364+ );365+ let mut stream = tokio::time::timeout(366+ ADMIN_IO_TIMEOUT,367+ tokio::net::UnixStream::connect(&socket_path),368+ )369+ .await370+ .context("timed out connecting to authorization administration socket")??;371+ tokio::time::timeout(ADMIN_IO_TIMEOUT, async {372+ stream.write_all(&encoded).await?;373+ stream.shutdown().await374+ })375+ .await376+ .context("timed out writing authorization administration request")??;377+ let mut response = Vec::new();378+ tokio::time::timeout(379+ ADMIN_IO_TIMEOUT,380+ (&mut stream)381+ .take(MAX_ADMIN_RESPONSE_BYTES as u64 + 1)382+ .read_to_end(&mut response),383+ )384+ .await385+ .context("timed out reading authorization administration response")??;386+ ensure!(387+ !response.is_empty() && response.len() <= MAX_ADMIN_RESPONSE_BYTES,388+ "authorization administration response is empty or oversized"389+ );390+ let response: AuthorizationAdminResponse = serde_json::from_slice(&response)391+ .context("parsing authorization administration response")?;392+ if response.ok {393+ response394+ .result395+ .context("successful administration response has no result")396+ } else {397+ anyhow::bail!(398+ "{}",399+ response400+ .error401+ .unwrap_or_else(|| "authorization administration request failed".to_owned())402+ )403+ }404+}405+406+407+mod tests {408+ use super::*;409+ use crate::config::{HostConfig, InputMode, VideoSource};410+ use crate::input::InputBackend;411+ use sigil_protocol::{ControllerSlot, FocusCommandActionV2, FocusCommandV2};412+ use std::fs;413+ use std::os::unix::fs::PermissionsExt;414+415+ fn disabled_input_backend(state_path: &Path) -> InputBackend {416+ InputBackend::initialize(&HostConfig {417+ identity_path: state_path.join("unused-identity"),418+ state_path: state_path.to_path_buf(),419+ source: VideoSource::TestPattern,420+ width: Some(1280),421+ height: Some(800),422+ framerate: 60,423+ codec: "h264".to_owned(),424+ input_mode: InputMode::Disabled,425+ uinput: None,426+ ffmpeg_path: PathBuf::from("ffmpeg"),427+ gamescope_pipewire: None,428+ audio: None,429+ })430+ .unwrap()431+ }432+433+ async fn issue_and_redeem(434+ directory: &Path,435+ store: &AuthorizationStore,436+ peer: EndpointId,437+ grants: InvitationGrants,438+ ) -> crate::authorization::AuthorizedViewer {439+ let result = request(440+ directory,441+ &AuthorizationAdminRequest::CreateInvitation {442+ peer_node_id: peer.to_string(),443+ grants: grants.bits(),444+ expires_in_seconds: 600,445+ },446+ )447+ .await448+ .unwrap();449+ let AuthorizationAdminResult::Invitation { token, .. } = result else {450+ panic!("unexpected invitation response");451+ };452+ store453+ .authorize_or_redeem_viewer(peer, Some(&token), unix_timestamp_now().unwrap())454+ .unwrap()455+ }456+457+ 458+ async fn socket_administers_multiple_viewers_and_persists_mutations() {459+ let directory = tempfile::tempdir().unwrap();460+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();461+ let host = iroh::SecretKey::from_bytes(&[7; 32]);462+ let peer_one = iroh::SecretKey::from_bytes(&[8; 32]).public();463+ let peer_two = iroh::SecretKey::from_bytes(&[9; 32]).public();464+ let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();465+ let sessions = Arc::new(SessionRegistry::default());466+ let operations = Arc::new(InputOperations::new(disabled_input_backend(467+ directory.path(),468+ )));469+ let server = AuthorizationAdminServer::start(470+ directory.path(),471+ store.clone(),472+ Arc::clone(&sessions),473+ operations,474+ host.to_bytes(),475+ )476+ .unwrap();477+478+ let first =479+ issue_and_redeem(directory.path(), &store, peer_one, InvitationGrants::ALL).await;480+ let second =481+ issue_and_redeem(directory.path(), &store, peer_two, InvitationGrants::VIEW).await;482+ assert_ne!(first.handle, second.handle);483+484+ let AuthorizationAdminResult::Viewers {485+ viewers,486+ committed_revision,487+ ..488+ } = request(directory.path(), &AuthorizationAdminRequest::List)489+ .await490+ .unwrap()491+ else {492+ panic!("unexpected list response");493+ };494+ assert_eq!(viewers.len(), 2);495+ assert!(committed_revision >= 3);496+ assert!(497+ viewers498+ .iter()499+ .all(|viewer| !viewer.handle.contains(&peer_one.to_string()))500+ );501+502+ request(503+ directory.path(),504+ &AuthorizationAdminRequest::ReplaceGrants {505+ handle: first.handle.clone(),506+ grants: Some(InvitationGrants::VIEW.bits()),507+ },508+ )509+ .await510+ .unwrap();511+ assert!(512+ sessions513+ .claim_v2_authorized(peer_one, [9; 16], first.clone())514+ .is_err(),515+ "an admission decision from before the committed mutation must be stale"516+ );517+ request(518+ directory.path(),519+ &AuthorizationAdminRequest::RevokeViewer {520+ handle: second.handle,521+ },522+ )523+ .await524+ .unwrap();525+526+ server.shutdown().await;527+ let snapshot = store.list_viewers().unwrap();528+ assert_eq!(snapshot.viewers.len(), 1);529+ assert_eq!(snapshot.viewers[0].handle, first.handle);530+ assert_eq!(snapshot.viewers[0].grants, InvitationGrants::VIEW);531+ assert!(directory.path().join("authorization-v2.json").is_file());532+ assert!(!directory.path().join(ADMIN_SOCKET_FILE).exists());533+ }534+535+ 536+ async fn committed_reduction_defocuses_then_view_revocation_disconnects() {537+ let directory = tempfile::tempdir().unwrap();538+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();539+ let host = iroh::SecretKey::from_bytes(&[17; 32]);540+ let peer = iroh::SecretKey::from_bytes(&[18; 32]).public();541+ let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();542+ let sessions = Arc::new(SessionRegistry::default());543+ let operations = Arc::new(InputOperations::new(disabled_input_backend(544+ directory.path(),545+ )));546+ let server = AuthorizationAdminServer::start(547+ directory.path(),548+ store.clone(),549+ Arc::clone(&sessions),550+ Arc::clone(&operations),551+ host.to_bytes(),552+ )553+ .unwrap();554+ let authorized =555+ issue_and_redeem(directory.path(), &store, peer, InvitationGrants::ALL).await;556+ let handle = authorized.handle.clone();557+ let media = sessions558+ .claim_v2_authorized(peer, [3; 16], authorized)559+ .unwrap();560+ let granted = sessions561+ .apply_focus_command(562+ peer,563+ media.session_id,564+ &FocusCommandV2 {565+ request_id: 1,566+ action: FocusCommandActionV2::Request,567+ slot: ControllerSlot::ZERO,568+ expected_revision: 1,569+ expected_focus_generation: None,570+ },571+ )572+ .unwrap();573+ let focus_generation = granted.snapshot.self_focus_generation().unwrap();574+575+ let AuthorizationAdminResult::Mutation {576+ input_neutralized,577+ disconnected,578+ ..579+ } = request(580+ directory.path(),581+ &AuthorizationAdminRequest::ReplaceGrants {582+ handle: handle.clone(),583+ grants: Some(InvitationGrants::VIEW.bits()),584+ },585+ )586+ .await587+ .unwrap()588+ else {589+ panic!("unexpected grant mutation response");590+ };591+ assert!(input_neutralized);592+ assert!(!disconnected);593+ assert!(!sessions.is_v2_focus_owner(594+ peer,595+ media.session_id,596+ ControllerSlot::ZERO,597+ focus_generation598+ ));599+ assert!(sessions.is_active(peer, media.session_id));600+601+ let AuthorizationAdminResult::Mutation { disconnected, .. } = request(602+ directory.path(),603+ &AuthorizationAdminRequest::RevokeViewer { handle },604+ )605+ .await606+ .unwrap() else {607+ panic!("unexpected revoke response");608+ };609+ assert!(disconnected);610+ assert!(!sessions.is_active(peer, media.session_id));611+ server.shutdown().await;612+ }613+}@@ -1,6 +1,7 @@
11 mod appliance;22 mod audio;33 mod authorization;4+mod authorization_admin;45 mod clock;56 mod config;67 mod config_management;@@ -31,14 +32,11 @@ use moq_net::Origin;
3132 use sigil_protocol::{3233 AUDIO_ALPN_V1, CONTROL_ALPN_V1, CONTROL_ALPN_V2, INPUT_ALPN_V1, INPUT_ALPN_V2,3334 InvitationGrants, MAX_INVITATION_TTL_SECS, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1,34- SignedInvitation,3535 };3636 use tracing::{info, warn};3737 use tracing_subscriber::EnvFilter;3838 39-use crate::authorization::{40- AuthorizationPolicy, AuthorizationStore, grant_names, unix_timestamp_now,41-};39+use crate::authorization::{AuthorizationPolicy, AuthorizationStore, grant_names};4240 use crate::config::{ConfigRevision, HostConfig, InputMode, VideoSource};4341 use crate::cursor::PointerPositionTracker;4442 use crate::input::InputBackend;@@ -84,7 +82,7 @@ enum Command {
8482 8583 command: InvitationCommand,8684 },87- /// Inspect or revoke the one enrolled Portal peer.85+ /// Inspect or administer enrolled Portal viewers.8886 Enrollment {8987 9088 command: EnrollmentCommand,@@ -236,6 +234,9 @@ enum InvitationCommand {
236234 pointer_keyboard: bool,237235 238236 gamepad: bool,237+ /// Explicitly issue a view-only invitation.238+ )]239+ view_only: bool,239240 240241 output: PathBuf,241242 /// Also print the short-lived invitation as a goq:// deep link.@@ -251,6 +252,11 @@ enum EnrollmentCommand {
251252 252253 config: PathBuf,253254 },255+ /// List all enrolled viewers by stable opaque handle.256+ List {257+ 258+ config: PathBuf,259+ },254260 /// Remove the enrolled peer and invalidate every outstanding invitation.255261 Revoke {256262 @@ -261,6 +267,24 @@ enum EnrollmentCommand {
261267 /// Emit the stable machine-readable schema.262268 263269 json: bool,270+ /// Revoke one viewer through the running daemon. Without this option,271+ /// the command retains the stopped-service reset-all compatibility path.272+ 273+ handle: Option<String>,274+ },275+ /// Atomically replace one viewer's grants through the running daemon.276+ SetCapabilities {277+ 278+ config: PathBuf,279+ 280+ handle: String,281+ /// Remove view permission and revoke this viewer completely.282+ )]283+ revoke_view: bool,284+ 285+ pointer_keyboard: bool,286+ 287+ gamepad: bool,264288 },265289 }266290 @@ -376,8 +400,8 @@ async fn run() -> CliResult<()> {
376400 Command::Identity { command } => identity_command(command).map_err(Into::into),377401 Command::Config { command } => config_command(command).await.map_err(Into::into),378402 Command::Capture { command } => capture_command(command).await.map_err(Into::into),379- Command::Invitation { command } => invitation_command(command).map_err(Into::into),380- Command::Enrollment { command } => enrollment_command(command).map_err(Into::into),403+ Command::Invitation { command } => invitation_command(command).await.map_err(Into::into),404+ Command::Enrollment { command } => enrollment_command(command).await.map_err(Into::into),381405 Command::Serve(args) => serve_command(args).await.map_err(Into::into),382406 }383407 }@@ -475,7 +499,7 @@ fn authorization_snapshot_from_config(
475499 Ok((config, secret, inspection.snapshot))476500 }477501 478-fn invitation_command(command: InvitationCommand) -> Result<()> {502+async fn invitation_command(command: InvitationCommand) -> Result<()> {479503 match command {480504 InvitationCommand::Create {481505 config,@@ -483,10 +507,13 @@ fn invitation_command(command: InvitationCommand) -> Result<()> {
483507 expires_in_seconds,484508 pointer_keyboard,485509 gamepad,510+ view_only: _,486511 output,487512 print_deep_link,488513 } => {489- let (_config, secret, snapshot) = authorization_snapshot_from_config(&config)?;514+ let config = HostConfig::load(&config)?;515+ config.ensure_runtime_directory()?;516+ let secret = identity::load(&config.identity_path)?;490517 let mut grants = InvitationGrants::VIEW;491518 if pointer_keyboard {492519 grants = grants.union(InvitationGrants::POINTER_KEYBOARD);@@ -494,21 +521,23 @@ fn invitation_command(command: InvitationCommand) -> Result<()> {
494521 if gamepad {495522 grants = grants.union(InvitationGrants::GAMEPAD);496523 }497- let mut nonce = [0_u8; 32];498- getrandom::fill(&mut nonce).context("generating invitation nonce")?;499- let now = unix_timestamp_now()?;500- let claims = AuthorizationStore::issue_claims_from_snapshot(501- secret.public(),502- snapshot,503- peer,504- grants,505- expires_in_seconds,506- now,507- nonce,508- )?;509- let expires_at = claims.expires_at_unix;510- let invitation = SignedInvitation::issue(claims, &secret.to_bytes())?;511- let token = invitation.encode();524+ let result = authorization_admin::request(525+ &config.state_path,526+ &authorization_admin::AuthorizationAdminRequest::CreateInvitation {527+ peer_node_id: peer.to_string(),528+ grants: grants.bits(),529+ expires_in_seconds,530+ },531+ )532+ .await?;533+ let authorization_admin::AuthorizationAdminResult::Invitation {534+ token,535+ expires_at_unix: expires_at,536+ ..537+ } = result538+ else {539+ anyhow::bail!("authorization daemon returned an unexpected invitation response");540+ };512541 write_invitation_file(&output, &token)?;513542 println!("invitation={}", output.display());514543 println!("host_node_id={}", secret.public());@@ -523,27 +552,105 @@ fn invitation_command(command: InvitationCommand) -> Result<()> {
523552 Ok(())524553 }525554 526-fn enrollment_command(command: EnrollmentCommand) -> Result<()> {555+async fn enrollment_command(command: EnrollmentCommand) -> Result<()> {527556 match command {528557 EnrollmentCommand::Show { config } => {529558 let (_config, _secret, snapshot) = authorization_snapshot_from_config(&config)?;530559 println!("enrollment_epoch={}", snapshot.epoch);531- match (snapshot.peer, snapshot.grants) {532- (Some(peer), Some(grants)) => {533- println!("enrollment=active");534- println!("peer_node_id={peer}");535- println!("grants={}", grant_names(grants));560+ println!("committed_revision={}", snapshot.committed_revision);561+ println!("viewer_count={}", snapshot.viewers.len());562+ if snapshot.viewers.is_empty() {563+ println!("enrollment=none");564+ } else {565+ println!("enrollment=active");566+ for viewer in snapshot.viewers {567+ println!(568+ "viewer={} grants={} authorization_revision={} enrolled_at_unix={}",569+ viewer.handle,570+ grant_names(viewer.grants),571+ viewer.authorization_revision,572+ viewer.enrolled_at_unix573+ );536574 }537- (None, None) => println!("enrollment=none"),538- _ => unreachable!("validated authorization state is internally consistent"),575+ }576+ }577+ EnrollmentCommand::List { config } => {578+ let config = HostConfig::load(&config)?;579+ let result = authorization_admin::request(580+ &config.state_path,581+ &authorization_admin::AuthorizationAdminRequest::List,582+ )583+ .await?;584+ let authorization_admin::AuthorizationAdminResult::Viewers {585+ committed_revision,586+ enrollment_epoch,587+ viewers,588+ } = result589+ else {590+ anyhow::bail!("authorization daemon returned an unexpected list response");591+ };592+ println!("committed_revision={committed_revision}");593+ println!("enrollment_epoch={enrollment_epoch}");594+ println!("viewer_count={}", viewers.len());595+ for viewer in viewers {596+ println!(597+ "viewer={} grants={} authorization_revision={} enrolled_at_unix={}",598+ viewer.handle,599+ viewer.grants.join(","),600+ viewer.authorization_revision,601+ viewer.enrolled_at_unix602+ );539603 }540604 }541605 EnrollmentCommand::Revoke {542606 config,543607 expected_host_fingerprint,544608 json: _,609+ handle,545610 } => {546- let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)?;611+ if let Some(handle) = handle {612+ let config = HostConfig::load(&config)?;613+ let secret = identity::load(&config.identity_path)?;614+ ensure!(615+ expected_host_fingerprint == appliance::fingerprint(secret.public()),616+ "expected host fingerprint does not match this Sigil appliance"617+ );618+ let result = authorization_admin::request(619+ &config.state_path,620+ &authorization_admin::AuthorizationAdminRequest::RevokeViewer { handle },621+ )622+ .await?;623+ println!("{}", serde_json::to_string(&result)?);624+ } else {625+ let result = appliance::reset_enrollment(&config, &expected_host_fingerprint)?;626+ println!("{}", serde_json::to_string(&result)?);627+ }628+ }629+ EnrollmentCommand::SetCapabilities {630+ config,631+ handle,632+ revoke_view,633+ pointer_keyboard,634+ gamepad,635+ } => {636+ let config = HostConfig::load(&config)?;637+ let grants = if revoke_view {638+ None639+ } else {640+ let mut grants = InvitationGrants::VIEW;641+ if pointer_keyboard {642+ grants = grants.union(InvitationGrants::POINTER_KEYBOARD);643+ }644+ if gamepad {645+ grants = grants.union(InvitationGrants::GAMEPAD);646+ }647+ Some(grants.bits())648+ };649+ let result = authorization_admin::request(650+ &config.state_path,651+ &authorization_admin::AuthorizationAdminRequest::ReplaceGrants { handle, grants },652+ )653+ .await?;547654 println!("{}", serde_json::to_string(&result)?);548655 }549656 }@@ -1004,6 +1111,19 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10041111 10051112 let moq_origin = Origin::random();10061113 let input_operations = Arc::new(InputOperations::new(input_backend.clone()));1114+ let authorization_admin = match &authorization {1115+ AuthorizationPolicy::Required(store) => Some(1116+ authorization_admin::AuthorizationAdminServer::start(1117+ &config.state_path,1118+ store.clone(),1119+ Arc::clone(&sessions),1120+ Arc::clone(&input_operations),1121+ host_secret,1122+ )1123+ .context("starting local authorization administration listener")?,1124+ ),1125+ AuthorizationPolicy::TestPatternProof | AuthorizationPolicy::DevelopmentBypass => None,1126+ };10071127 let router = Router::builder(endpoint)10081128 .accept(10091129 CONTROL_ALPN_V2,@@ -1049,7 +1169,7 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10491169 .accept(10501170 INPUT_ALPN_V2,10511171 InputV2Handler {1052- operations: input_operations,1172+ operations: Arc::clone(&input_operations),10531173 pointer_positions: pointer_positions.clone(),10541174 sessions: Arc::clone(&sessions),10551175 },@@ -1099,6 +1219,9 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
10991219 {11001220 warn!(%error, "publishing stopping appliance status failed; continuing shutdown");11011221 }1222+ if let Some(administration) = authorization_admin {1223+ administration.shutdown().await;1224+ }11021225 degrade_on_error(11031226 publisher.as_ref(),11041227 appliance::RuntimeErrorCode::RouterShutdown,@@ -6,6 +6,8 @@ use anyhow::{Context, Result, ensure};
66 77 88 use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};9+10+use std::os::unix::fs::{FileTypeExt, PermissionsExt};911 1012 pub fn ensure_private_directory(path: &Path) -> Result<()> {1113 match fs::symlink_metadata(path) {@@ -273,6 +275,61 @@ pub fn remove_file_if_exists(directory: &Path, file_name: &str) -> Result<bool>
273275 }274276 }275277 278+279+pub fn prepare_private_unix_socket(280+ directory: &Path,281+ file_name: &str,282+) -> Result<std::path::PathBuf> {283+ validate_private_directory(directory)?;284+ let path = directory.join(file_name);285+ match fs::symlink_metadata(&path) {286+ Ok(metadata) => {287+ ensure!(288+ metadata.file_type().is_socket() && !metadata.file_type().is_symlink(),289+ "private administration socket path is not a Unix socket"290+ );291+ ensure!(292+ metadata.uid() == unsafe { libc::geteuid() },293+ "private administration socket has the wrong owner"294+ );295+ ensure!(296+ metadata.mode() & 0o077 == 0,297+ "private administration socket is accessible by group or others"298+ );299+ fs::remove_file(&path).with_context(|| {300+ format!("removing stale administration socket {}", path.display())301+ })?;302+ }303+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}304+ Err(error) => {305+ return Err(error)306+ .with_context(|| format!("inspecting administration socket {}", path.display()));307+ }308+ }309+ Ok(path)310+}311+312+313+pub fn secure_private_unix_socket(path: &Path) -> Result<()> {314+ fs::set_permissions(path, fs::Permissions::from_mode(0o600))315+ .with_context(|| format!("securing administration socket {}", path.display()))?;316+ let metadata = fs::symlink_metadata(path)317+ .with_context(|| format!("inspecting administration socket {}", path.display()))?;318+ ensure!(319+ metadata.file_type().is_socket() && !metadata.file_type().is_symlink(),320+ "private administration socket path is not a Unix socket"321+ );322+ ensure!(323+ metadata.uid() == unsafe { libc::geteuid() },324+ "private administration socket has the wrong owner"325+ );326+ ensure!(327+ metadata.mode() & 0o777 == 0o600,328+ "private administration socket must have mode 0600"329+ );330+ Ok(())331+}332+276333 fn validate_private_file(file: &File, path: &Path) -> Result<()> {277334 let metadata = file.metadata()?;278335 ensure!(@@ -1158,6 +1158,7 @@ pub(super) async fn serve_media_feedback(
11581158 info!(11591159 %remote,11601160 session_id = lease.session_id,1161+ authorization_revision = lease.authorization_revision,11611162 ceiling_kbps,11621163 applied = false,11631164 mode = if encoder_actuation_available { "active" } else { "shadow" },@@ -210,7 +210,7 @@ impl InputOperations {
210210 self.backend.apply(&binding.event, negotiated)211211 }212212 213- pub(super) fn reset(&self) -> Result<()> {213+ pub(crate) fn reset(&self) -> Result<()> {214214 let _operation = match self.operation.lock() {215215 Ok(operation) => operation,216216 Err(poisoned) => {@@ -329,6 +329,7 @@ async fn serve_input(
329329 };330330 let session_id = lease.session_id;331331 let grants = lease.grants;332+ let authorization_revision = lease.authorization_revision;332333 // Owning the lease guarantees cancellation or unwinding neutralizes the333334 // virtual devices before another input stream can be admitted.334335 let session_guard = InputSessionGuard::new(lease, || backend.reset_session());@@ -347,7 +348,7 @@ async fn serve_input(
347348 &HostHello::accepted(session_id, negotiated.clone()),348349 )349350 .await?;350- info!(%remote, session_id, "input client accepted");351+ info!(%remote, session_id, authorization_revision, "input client accepted");351352 352353 let session_result: Result<()> = async {353354 let mut received_events = 0_u64;@@ -527,7 +528,7 @@ async fn serve_input_v2(
527528 ),528529 )529530 .await?;530- info!(%remote, session_id = lease.session_id, focus_generation = lease.focus_generation, "input v2 client accepted");531+ info!(%remote, session_id = lease.session_id, focus_generation = lease.focus_generation, authorization_revision = lease.authorization_revision, "input v2 client accepted");531532 532533 let session_id = lease.session_id;533534 let slot = lease.slot;@@ -703,7 +704,7 @@ async fn serve_audio(
703704 )704705 .await?;705706 send.finish()?;706- info!(%remote, session_id = lease.session_id, "audio client accepted");707+ info!(%remote, session_id = lease.session_id, authorization_revision = lease.authorization_revision, "audio client accepted");707708 708709 let session_result: Result<()> = async {709710 loop {@@ -445,22 +445,22 @@ pub(super) async fn serve_control_moq_v2(
445445 );446446 debug!(%remote, agent = %hello.agent, "MoQ control v2 hello received");447447 448- let grants = match authorization.authorize_or_redeem(448+ let authorized = match authorization.authorize_or_redeem_viewer(449449 remote,450450 hello.invitation.as_deref(),451451 unix_timestamp_now()?,452452 ) {453- Ok(grants) => grants,453+ Ok(authorized) => authorized,454454 Err(error) => {455455 send_rejection_v2(&mut send, "Portal peer is not authorized").await?;456456 return Err(error.context("authorizing MoQ control v2 peer"));457457 }458458 };459459 ensure!(460- grants.contains(InvitationGrants::VIEW),460+ authorized.grants.contains(InvitationGrants::VIEW),461461 "authorized MoQ control v2 peer lacks view permission"462462 );463- let lease = match sessions.claim_v2(remote, hello.nonce, grants) {463+ let lease = match sessions.claim_v2_authorized(remote, hello.nonce, authorized) {464464 Ok(lease) => lease,465465 Err(error) => {466466 send_rejection_v2(&mut send, "host already has an active client").await?;@@ -518,7 +518,7 @@ pub(super) async fn serve_control_moq_v2(
518518 lease.session_id,519519 *remote.as_bytes(),520520 SubscriptionTracks::VIDEO_H264,521- 1,521+ lease.authorization_revision,522522 issued_at_unix,523523 issued_at_unix.saturating_add(15 * 60),524524 subscription_nonce,@@ -560,6 +560,8 @@ pub(super) async fn serve_control_moq_v2(
560560 info!(561561 %remote,562562 session_id = lease.session_id,563+ authorization_revision = lease.authorization_revision,564+ authorization_committed_revision = lease.authorization_committed_revision,563565 %broadcast_name,564566 "MoQ control v2 client accepted; awaiting authorized media attachment"565567 );@@ -723,6 +725,7 @@ async fn run_control_moq_session(
723725 None => MoqGroupPublisher::new(track),724726 };725727 let (control_sender, mut control_requests) = tokio::sync::watch::channel(None);728+ let terminate_when_control_ends = matches!(&control_reader, MoqControlReader::V2 { .. });726729 let mut control_task = match control_reader {727730 MoqControlReader::V1(control_recv) => tokio::spawn(forward_media_v3_control_requests(728731 control_recv,@@ -757,6 +760,9 @@ async fn run_control_moq_session(
757760 match control_result {758761 Ok(Ok(())) => {759762 debug!(%remote, "MoQ keyframe-control stream closed");763+ if terminate_when_control_ends {764+ return Ok(());765+ }760766 }761767 Ok(Err(error)) => {762768 return Err(error).context("reading MoQ keyframe-control stream");@@ -16,6 +16,7 @@ use sigil_protocol::{
1616 use tracing::{debug, warn};1717 1818 use super::{ENCODER_CONTROL_COMMIT_TIMEOUT, VideoDimensions};19+use crate::authorization::{AuthorizationMutation, AuthorizedViewer};1920 use crate::clock::SessionClock;2021 use crate::source::EncoderControl;2122 @@ -27,6 +28,7 @@ pub struct SessionRegistry {
2728 pending_moq: Mutex<Option<PendingMoqAttachment>>,2829 next_session_id: AtomicU64,2930 next_focus_generation: AtomicU64,31+ authorization_committed_revision: AtomicU64,3032 v2_state: Mutex<Option<V2SessionState>>,3133 v2_snapshot: tokio::sync::watch::Sender<Option<SessionSnapshotV2>>,3234 pub(super) session_changed: tokio::sync::Notify,@@ -76,6 +78,9 @@ struct ActiveSession {
7678 nonce: [u8; 16],7779 session_clock: SessionClock,7880 grants: InvitationGrants,81+ viewer_handle: Option<String>,82+ authorization_revision: u64,83+ authorization_committed_revision: u64,7984 media_active: bool,8085 input_claimed: bool,8186 audio_claimed: bool,@@ -97,12 +102,19 @@ struct V2SessionState {
97102 revision: u64,98103 focus: FocusStateV2,99104 transition_reason: FocusTransitionReasonV2,105+ authorization_neutralizing: bool,100106 }101107 102108 103-pub(super) struct FocusCommandEffect {104- pub(super) snapshot: SessionSnapshotV2,105- pub(super) neutralize: bool,109+pub(crate) struct FocusCommandEffect {110+ pub(crate) snapshot: SessionSnapshotV2,111+ pub(crate) neutralize: bool,112+}113+114+115+pub struct AuthorizationSessionEffect {116+ pub disconnected: bool,117+ pub neutralize_input: bool,106118 }107119 108120 @@ -353,6 +365,7 @@ impl Default for SessionRegistry {
353365 pending_moq: Mutex::new(None),354366 next_session_id: AtomicU64::new(0),355367 next_focus_generation: AtomicU64::new(0),368+ authorization_committed_revision: AtomicU64::new(1),356369 v2_state: Mutex::new(None),357370 v2_snapshot,358371 session_changed: tokio::sync::Notify::new(),@@ -388,6 +401,9 @@ impl SessionRegistry {
388401 nonce,389402 session_clock,390403 grants,404+ viewer_handle: None,405+ authorization_revision: 1,406+ authorization_committed_revision: 1,391407 media_active: true,392408 input_claimed: false,393409 audio_claimed: false,@@ -411,6 +427,40 @@ impl SessionRegistry {
411427 nonce: [u8; 16],412428 grants: InvitationGrants,413429 ) -> Result<V2SessionLease> {430+ self.claim_v2_authorized(431+ remote,432+ nonce,433+ AuthorizedViewer {434+ handle: "viewer-0000000000000000".to_owned(),435+ grants,436+ authorization_revision: 1,437+ committed_revision: 1,438+ },439+ )440+ }441+442+ pub(crate) fn claim_v2_authorized(443+ self: &Arc<Self>,444+ remote: EndpointId,445+ nonce: [u8; 16],446+ authorized: AuthorizedViewer,447+ ) -> Result<V2SessionLease> {448+ authorized.grants.validate()?;449+ ensure!(450+ authorized.grants.contains(InvitationGrants::VIEW),451+ "viewer lacks view permission"452+ );453+ ensure!(454+ authorized.authorization_revision != 0 && authorized.committed_revision != 0,455+ "viewer authorization revision is invalid"456+ );457+ self.authorization_committed_revision458+ .fetch_max(authorized.committed_revision, Ordering::SeqCst);459+ ensure!(460+ self.authorization_committed_revision.load(Ordering::SeqCst)461+ == authorized.committed_revision,462+ "viewer admission used a stale authorization revision"463+ );414464 let mut active = self.active.lock().expect("session registry poisoned");415465 if let Some(current) = active.as_ref() {416466 bail!("host already has active client {}", current.remote);@@ -418,7 +468,7 @@ impl SessionRegistry {
418468 let session_id = self.next_session_id.fetch_add(1, Ordering::Relaxed) + 1;419469 let session_clock = SessionClock::start();420470 let media_v3_telemetry = Arc::new(MediaV3Telemetry::default());421- let presence_id = ViewerPresenceId::new(format!("viewer-{session_id:016x}"))?;471+ let presence_id = ViewerPresenceId::new(authorized.handle.clone())?;422472 let v2 = V2SessionState {423473 presence_id,424474 revision: 1,@@ -426,13 +476,17 @@ impl SessionRegistry {
426476 slot: ControllerSlot::ZERO,427477 },428478 transition_reason: FocusTransitionReasonV2::Initial,479+ authorization_neutralizing: false,429480 };430481 let session = ActiveSession {431482 remote,432483 session_id,433484 nonce,434485 session_clock,435- grants,486+ grants: authorized.grants,487+ viewer_handle: Some(authorized.handle),488+ authorization_revision: authorized.authorization_revision,489+ authorization_committed_revision: authorized.committed_revision,436490 media_active: true,437491 input_claimed: false,438492 audio_claimed: false,@@ -455,6 +509,8 @@ impl SessionRegistry {
455509 session_clock,456510 media_v3_telemetry,457511 initial_snapshot: snapshot,512+ authorization_revision: authorized.authorization_revision,513+ authorization_committed_revision: authorized.committed_revision,458514 })459515 }460516 @@ -483,6 +539,7 @@ impl SessionRegistry {
483539 remote,484540 session_id: session.session_id,485541 grants: session.grants,542+ authorization_revision: session.authorization_revision,486543 })487544 }488545 @@ -523,7 +580,7 @@ impl SessionRegistry {
523580 .context("active v2 session is missing revision state")524581 }525582 526- pub(super) fn apply_focus_command(583+ pub(crate) fn apply_focus_command(527584 &self,528585 remote: EndpointId,529586 session_id: u64,@@ -553,6 +610,10 @@ impl SessionRegistry {
553610 554611 let neutralize = match command.action {555612 FocusCommandActionV2::Request => {613+ ensure!(614+ !v2.authorization_neutralizing,615+ "input authorization is still neutralizing"616+ );556617 ensure!(557618 session.grants.contains(InvitationGrants::POINTER_KEYBOARD)558619 || session.grants.contains(InvitationGrants::GAMEPAD),@@ -645,6 +706,118 @@ impl SessionRegistry {
645706 Ok(true)646707 }647708 709+ pub fn apply_authorization_mutation(710+ &self,711+ mutation: &AuthorizationMutation,712+ ) -> Result<AuthorizationSessionEffect> {713+ ensure!(714+ mutation.committed_revision != 0 && mutation.authorization_revision != 0,715+ "authorization mutation revision is invalid"716+ );717+ self.authorization_committed_revision718+ .fetch_max(mutation.committed_revision, Ordering::SeqCst);719+720+ let mut active = self.active.lock().expect("session registry poisoned");721+ let Some(session) = active.as_mut().filter(|session| {722+ session.mode == SessionMode::V2Single723+ && session.remote == mutation.peer724+ && session.viewer_handle.as_deref() == Some(mutation.handle.as_str())725+ }) else {726+ return Ok(AuthorizationSessionEffect::default());727+ };728+ ensure!(729+ mutation.authorization_revision > session.authorization_revision,730+ "authorization mutation is stale for the active viewer"731+ );732+ session.authorization_revision = mutation.authorization_revision;733+ session.authorization_committed_revision = mutation.committed_revision;734+735+ let current_grants = mutation.current_grants.unwrap_or(InvitationGrants::VIEW);736+ let input_reduced = mutation737+ .previous_grants738+ .contains(InvitationGrants::POINTER_KEYBOARD)739+ && !current_grants.contains(InvitationGrants::POINTER_KEYBOARD)740+ || mutation.previous_grants.contains(InvitationGrants::GAMEPAD)741+ && !current_grants.contains(InvitationGrants::GAMEPAD);742+743+ let mut v2_state = self.v2_state.lock().expect("v2 session state poisoned");744+ let v2 = v2_state745+ .as_mut()746+ .context("active v2 session is missing focus state")?;747+ let held_focus = matches!(v2.focus, FocusStateV2::Held { .. });748+ let neutralize_input = input_reduced;749+ let disconnected = mutation.current_grants.is_none();750+751+ if disconnected {752+ session.grants = InvitationGrants::VIEW;753+ session.media_active = false;754+ session.encoder_control = None;755+ if held_focus {756+ v2.focus = FocusStateV2::Vacant {757+ slot: ControllerSlot::ZERO,758+ };759+ v2.transition_reason = FocusTransitionReasonV2::Revoked;760+ }761+ let mut pending = self.pending_moq.lock().expect("MoQ registry poisoned");762+ if pending.as_ref().is_some_and(|attachment| {763+ attachment.remote == mutation.peer && attachment.session_id == session.session_id764+ }) {765+ *pending = None;766+ }767+ *v2_state = None;768+ self.v2_snapshot.send_replace(None);769+ } else if let Some(grants) = mutation.current_grants {770+ session.grants = grants;771+ if input_reduced {772+ v2.authorization_neutralizing = true;773+ if held_focus {774+ v2.focus = FocusStateV2::Vacant {775+ slot: ControllerSlot::ZERO,776+ };777+ }778+ v2.transition_reason = FocusTransitionReasonV2::Revoked;779+ v2.revision = v2780+ .revision781+ .checked_add(1)782+ .context("v2 snapshot revision overflowed")?;783+ self.v2_snapshot784+ .send_replace(Some(v2_snapshot_for(session, v2)?));785+ }786+ }787+ drop(v2_state);788+ drop(active);789+ self.session_changed.notify_waiters();790+ Ok(AuthorizationSessionEffect {791+ disconnected,792+ neutralize_input: neutralize_input || disconnected && held_focus,793+ })794+ }795+796+ pub fn complete_authorization_neutralization(797+ &self,798+ remote: EndpointId,799+ authorization_revision: u64,800+ ) -> Result<()> {801+ let active = self.active.lock().expect("session registry poisoned");802+ let Some(_session) = active.as_ref().filter(|session| {803+ session.mode == SessionMode::V2Single804+ && session.media_active805+ && session.remote == remote806+ && session.authorization_revision == authorization_revision807+ }) else {808+ return Ok(());809+ };810+ let mut v2_state = self.v2_state.lock().expect("v2 session state poisoned");811+ let v2 = v2_state812+ .as_mut()813+ .context("active v2 session is missing focus state")?;814+ v2.authorization_neutralizing = false;815+ drop(v2_state);816+ drop(active);817+ self.session_changed.notify_waiters();818+ Ok(())819+ }820+648821 pub(super) fn claim_input_v2(649822 self: &Arc<Self>,650823 remote: EndpointId,@@ -695,10 +868,11 @@ impl SessionRegistry {
695868 slot,696869 focus_generation,697870 grants: session.grants,871+ authorization_revision: session.authorization_revision,698872 })699873 }700874 701- pub(super) fn is_v2_focus_owner(875+ pub(crate) fn is_v2_focus_owner(702876 &self,703877 remote: EndpointId,704878 session_id: u64,@@ -832,6 +1006,7 @@ impl SessionRegistry {
8321006 session_id: session.session_id,8331007 telemetry: Arc::clone(&session.media_v3_telemetry),8341008 encoder_control: session.encoder_control.clone(),1009+ authorization_revision: session.authorization_revision,8351010 })8361011 }8371012 @@ -858,6 +1033,7 @@ impl SessionRegistry {
8581033 session_id: session.session_id,8591034 session_clock: session.session_clock,8601035 grants: session.grants,1036+ authorization_revision: session.authorization_revision,8611037 })8621038 }8631039 @@ -907,18 +1083,8 @@ impl SessionRegistry {
9071083 broadcast: BroadcastConsumer,9081084 subscription_capability: SignedSubscriptionCapability,9091085 ) -> Result<MoqAttachmentWait> {910- ensure!(911- subscription_capability.claims.media_generation_id == session_id912- && subscription_capability.claims.subscriber_endpoint_id == *remote.as_bytes()913- && subscription_capability914- .claims915- .tracks916- .contains(SubscriptionTracks::VIDEO_H264)917- && subscription_capability.claims.authorization_revision == 1,918- "subscription capability does not match the v2 viewer attachment"919- );9201086 let active = self.active.lock().expect("session registry poisoned");921- let telemetry = active1087+ let session = active9221088 .as_ref()9231089 .filter(|session| {9241090 session.mode == SessionMode::V2Single@@ -926,8 +1092,19 @@ impl SessionRegistry {
9261092 && session.remote == remote9271093 && session.session_id == session_id9281094 })929- .map(|session| Arc::clone(&session.media_v3_telemetry))9301095 .context("authenticated MoQ expectation does not match the active v2 session")?;1096+ ensure!(1097+ subscription_capability.claims.media_generation_id == session_id1098+ && subscription_capability.claims.subscriber_endpoint_id == *remote.as_bytes()1099+ && subscription_capability1100+ .claims1101+ .tracks1102+ .contains(SubscriptionTracks::VIDEO_H264)1103+ && subscription_capability.claims.authorization_revision1104+ == session.authorization_revision,1105+ "subscription capability does not match the v2 viewer attachment"1106+ );1107+ let telemetry = Arc::clone(&session.media_v3_telemetry);9311108 let mut pending = self.pending_moq.lock().expect("MoQ registry poisoned");9321109 ensure!(9331110 pending.is_none(),@@ -983,7 +1160,7 @@ impl SessionRegistry {
9831160 session.session_id,9841161 *remote.as_bytes(),9851162 SubscriptionTracks::VIDEO_H264,986- 1,1163+ session.authorization_revision,9871164 now_unix,9881165 )9891166 .context("verifying endpoint-bound MoQ subscription capability")?;@@ -1037,7 +1214,7 @@ impl SessionRegistry {
10371214 }10381215 }10391216 1040- pub(super) fn is_active(&self, remote: EndpointId, session_id: u64) -> bool {1217+ pub(crate) fn is_active(&self, remote: EndpointId, session_id: u64) -> bool {10411218 self.active10421219 .lock()10431220 .expect("session registry poisoned")@@ -1121,13 +1298,15 @@ pub(super) struct SessionLease {
11211298 }11221299 11231300 1124-pub(super) struct V2SessionLease {1301+pub(crate) struct V2SessionLease {11251302 registry: Arc<SessionRegistry>,11261303 remote: EndpointId,1127- pub(super) session_id: u64,1304+ pub(crate) session_id: u64,11281305 pub(super) session_clock: SessionClock,11291306 pub(super) media_v3_telemetry: Arc<MediaV3Telemetry>,11301307 pub(super) initial_snapshot: SessionSnapshotV2,1308+ pub(super) authorization_revision: u64,1309+ pub(super) authorization_committed_revision: u64,11311310 }11321311 11331312 impl Drop for V2SessionLease {@@ -1148,6 +1327,7 @@ pub(super) struct InputLease {
11481327 remote: EndpointId,11491328 pub(super) session_id: u64,11501329 pub(super) grants: InvitationGrants,1330+ pub(super) authorization_revision: u64,11511331 }11521332 11531333 @@ -1158,6 +1338,7 @@ pub(super) struct InputV2Lease {
11581338 pub(super) slot: ControllerSlot,11591339 pub(super) focus_generation: u64,11601340 pub(super) grants: InvitationGrants,1341+ pub(super) authorization_revision: u64,11611342 }11621343 11631344 @@ -1167,6 +1348,7 @@ pub(super) struct AudioLease {
11671348 pub(super) session_id: u64,11681349 pub(super) session_clock: SessionClock,11691350 pub(super) grants: InvitationGrants,1351+ pub(super) authorization_revision: u64,11701352 }11711353 11721354 @@ -1176,6 +1358,7 @@ pub(super) struct FeedbackLease {
11761358 pub(super) session_id: u64,11771359 pub(super) telemetry: Arc<MediaV3Telemetry>,11781360 pub(super) encoder_control: Option<EncoderControl>,1361+ pub(super) authorization_revision: u64,11791362 }11801363 11811364 @@ -1437,6 +1620,86 @@ mod tests {
14371620 drop(media);14381621 }14391622 1623+ 1624+ fn authorization_reduction_blocks_focus_until_neutralization_completes() {1625+ let sessions = Arc::new(SessionRegistry::default());1626+ let remote = endpoint(1);1627+ let handle = "viewer-0000000000000001".to_owned();1628+ let media = sessions1629+ .claim_v2_authorized(1630+ remote,1631+ [4; 16],1632+ AuthorizedViewer {1633+ handle: handle.clone(),1634+ grants: InvitationGrants::ALL,1635+ authorization_revision: 1,1636+ committed_revision: 1,1637+ },1638+ )1639+ .unwrap();1640+ let granted = sessions1641+ .apply_focus_command(1642+ remote,1643+ media.session_id,1644+ &FocusCommandV2 {1645+ request_id: 1,1646+ action: FocusCommandActionV2::Request,1647+ slot: ControllerSlot::ZERO,1648+ expected_revision: 1,1649+ expected_focus_generation: None,1650+ },1651+ )1652+ .unwrap();1653+ assert_eq!(granted.snapshot.revision, 2);1654+1655+ let effect = sessions1656+ .apply_authorization_mutation(&AuthorizationMutation {1657+ handle,1658+ peer: remote,1659+ previous_grants: InvitationGrants::ALL,1660+ current_grants: Some(InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD)),1661+ authorization_revision: 2,1662+ committed_revision: 2,1663+ })1664+ .unwrap();1665+ assert!(effect.neutralize_input);1666+ assert!(1667+ sessions1668+ .apply_focus_command(1669+ remote,1670+ media.session_id,1671+ &FocusCommandV2 {1672+ request_id: 2,1673+ action: FocusCommandActionV2::Request,1674+ slot: ControllerSlot::ZERO,1675+ expected_revision: 3,1676+ expected_focus_generation: None,1677+ },1678+ )1679+ .unwrap_err()1680+ .to_string()1681+ .contains("still neutralizing")1682+ );1683+ sessions1684+ .complete_authorization_neutralization(remote, 2)1685+ .unwrap();1686+ assert!(1687+ sessions1688+ .apply_focus_command(1689+ remote,1690+ media.session_id,1691+ &FocusCommandV2 {1692+ request_id: 3,1693+ action: FocusCommandActionV2::Request,1694+ slot: ControllerSlot::ZERO,1695+ expected_revision: 3,1696+ expected_focus_generation: None,1697+ },1698+ )1699+ .is_ok()1700+ );1701+ }1702+14401703 14411704 fn legacy_mode_rejects_v2_coexistence() {14421705 let sessions = Arc::new(SessionRegistry::default());@@ -55,6 +55,13 @@ PipeWire selectors, audio/input configuration, identity, and state paths are
5555 not editable through this contract. Operator comments and all non-managed TOML5656 fields survive an update.5757 58+Enrollment capacity is authorization state, not a host configuration field.59+Authorization v2 durably supports at most 32 enrolled viewers, while this60+implementation phase intentionally keeps the concurrent v2 runtime cap at one61+until shared media-generation ownership is available. A later configuration62+revision adds the independently bounded `max_viewers` setting; it does not63+change the durable enrollment ceiling.64+5865 Requests are strict JSON and contain an optimistic-concurrency revision:5966 6067 ```json@@ -1,8 +1,8 @@
11 # Sigil appliance enrollment reset v122 3-`sigil appliance enrollment-reset` is the local mutation used by appliance4-management surfaces to revoke the one enrolled Portal without rotating the5-Sigil host identity:3+`sigil appliance enrollment-reset` is the stopped-service reset-all mutation4+used by appliance management surfaces. It revokes every enrolled Portal5+without rotating the Sigil host identity:66 77 ```bash88 sigil appliance enrollment-reset \@@ -34,10 +34,26 @@ owner releases the kernel lease and a retry loads the last complete atomic
3434 state document. Live handshake contention fails closed immediately and may be3535 retried by Portal.3636 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.37+The writer now owns one strict, bounded `authorization-v2.json` document with a38+global committed revision, up to 32 peer records, unique opaque viewer handles,39+per-viewer grant revisions, the invitation replay ledger, and a bounded durable40+security-mutation audit. On its first sole-writer open, Sigil converts a valid41+development `authorization-v1.json` in memory, atomically installs v2, syncs the42+directory, and removes v1. If v2 exists, v1 is never consulted. Unknown fields,43+duplicate peers or handles, unsafe storage, oversized state, and ambiguous44+versions fail closed.45+46+The running daemon also binds47+`<state_path>/authorization-admin-v1.sock`. Its parent and socket are owner-only,48+the JSON request and response are bounded and strict, and no network listener49+is added. `sigil invitation create`, `sigil enrollment list`, per-viewer50+`sigil enrollment revoke --handle`, and `sigil enrollment set-capabilities`51+use this path. A mutation is acknowledged only after the v2 document is52+atomically durable, its committed revision is published, and the live registry53+has applied the change. Input-grant reduction first invalidates focus and then54+neutralizes all virtual input under the shared input-operation lock. Revoking a55+viewer's view permission disconnects that v2 viewer. Opaque handles and coarse56+grants are returned; raw peer keys and invitation bytes are not logged.4157 4258 The expected fingerprint is the redacted value returned by4359 `sigil appliance status`. It is an accidental-target confirmation interlock,@@ -58,13 +74,15 @@ Success writes exactly one JSON object:
5874 }5975 ```6076 61-Every successful invocation advances the authorization epoch, even when no62-Portal is enrolled, because an unredeemed invitation may still exist outside77+Every successful reset-all invocation advances the authorization epoch, even78+when no Portal is enrolled, because an unredeemed invitation may still exist outside6379 the host. The host identity stays byte-for-byte unchanged. Nonexpired replay6480 records remain intact, and all invitations from earlier epochs become stale.6581 A retry after a lost success response may advance the epoch again; that is a6682 safe additional invalidation.6783 68-The legacy `sigil enrollment revoke` spelling is a strict alias. It also69-requires `--expected-host-fingerprint` and `--json`, so there is no70-confirmation-free or live-daemon bypass.84+The legacy `sigil enrollment revoke` spelling without `--handle` remains a85+strict reset-all alias. It requires `--expected-host-fingerprint` and `--json`.86+With `--handle`, it performs a live reset-one operation through the daemon's87+owner-only socket and retains the same host-fingerprint interlock. These scopes88+are explicit: reset-one does not advance the invitation epoch; reset-all does.@@ -23,8 +23,9 @@ The public document includes only:
2323 2424 - Sigil version and overall `ready`, `active`, `degraded`, or `unavailable`2525 state.26-- Redacted host and enrolled-Portal fingerprints.27-- Canonically ordered enrollment grants, epoch, and enrollment time.26+- Redacted host fingerprint plus bounded enrollment count.27+- Stable opaque viewer handles, canonically ordered coarse grants, enrollment28+ times, per-viewer authorization revisions, and the global committed revision.2829 - Runtime freshness, daemon state, uptime, session state, and a closed set of2930 error codes.3031 - The exact loaded configuration revision, fresh daemon instance ID, and sticky@@ -35,6 +36,13 @@ It never includes complete endpoint IDs, invitation material, replay-ledger
3536 entries, session nonces, addresses, PipeWire object names, filesystem paths, or3637 free-form error text.3738 39+Authorization status is read from the strict v2 document without taking the40+daemon's writer lease. Schema v2 reports the bounded viewer list and committed41+revision. Schema v1 removes those new fields and retains its original42+single-enrollment projection for compatibility. A valid development v143+document can be inspected before migration, but only a sole writer converts it;44+once v2 exists, stale v1 bytes are ignored.45+3846 ## Runtime authority3947 4048 While serving, Sigil holds an exclusive nonblocking lock at@@ -35,3 +35,8 @@ test('raw invitation bytes stay in native commands instead of the DOM', () => {
3535 assert.match(main, /portal_import_invitation_file/);3636 assert.doesNotMatch(main, /readAsText|FileReader/);3737 });38+39+test('invitation grants distinguish view-only and input-capable access', () => {40+ assert.match(main, /summary\.viewOnly \? 'view only' : 'input-capable'/);41+ assert.match(main, /summary\.grants\.map\(grantLabel\)/);42+});@@ -12,7 +12,14 @@ export function normalizeInvitationSummary(value, nowUnix = Math.floor(Date.now(
1212 if (!value || typeof value !== 'object' || Array.isArray(value)) {1313 throw new TypeError('invitation summary must be an object');1414 }15- const allowed = new Set(['host_node_id', 'peer_node_id', 'expires_at_unix', 'grants']);15+ const allowed = new Set([16+ 'host_node_id',17+ 'peer_node_id',18+ 'expires_at_unix',19+ 'grants',20+ 'view_only',21+ 'input_capable',22+ ]);1623 for (const key of Object.keys(value)) {1724 if (!allowed.has(key)) throw new TypeError(`unknown invitation summary field: ${key}`);1825 }@@ -31,6 +38,13 @@ export function normalizeInvitationSummary(value, nowUnix = Math.floor(Date.now(
3138 }3239 grants.push(grant);3340 }41+ const inputCapable = grants.includes('pointer_keyboard') || grants.includes('gamepad');42+ if (value.input_capable !== undefined && value.input_capable !== inputCapable) {43+ throw new TypeError('invitation input eligibility conflicts with its grants');44+ }45+ if (value.view_only !== undefined && value.view_only !== !inputCapable) {46+ throw new TypeError('invitation view-only state conflicts with its grants');47+ }3448 return Object.freeze({3549 hostNodeId: value.host_node_id,3650 peerNodeId: value.peer_node_id,@@ -38,6 +52,8 @@ export function normalizeInvitationSummary(value, nowUnix = Math.floor(Date.now(
3852 peerFingerprint: shortPeerFingerprint(value.peer_node_id),3953 expiresAtUnix: value.expires_at_unix,4054 grants: Object.freeze(grants),55+ viewOnly: !inputCapable,56+ inputCapable,4157 });4258 }4359 @@ -21,6 +21,8 @@ test('normalizes only a bounded public invitation summary', () => {
2121 assert.equal(summary.hostFingerprint, '12121212…12121212');2222 assert.equal(summary.peerFingerprint, 'abababab…abababab');2323 assert.deepEqual(summary.grants, ['view', 'gamepad']);24+ assert.equal(summary.viewOnly, false);25+ assert.equal(summary.inputCapable, true);2426 assert.equal('token' in summary, false);2527 });2628 @@ -30,9 +32,24 @@ test('rejects expired, malformed, duplicate, unknown, and raw-token-bearing summ
3032 assert.throws(() => normalizeInvitationSummary({ ...base, host_node_id: 'nope' }, 1_000));3133 assert.throws(() => normalizeInvitationSummary({ ...base, grants: ['view', 'view'] }, 1_000));3234 assert.throws(() => normalizeInvitationSummary({ ...base, grants: ['admin'] }, 1_000));35+ assert.throws(() => normalizeInvitationSummary({ ...base, input_capable: true }, 1_000));36+ assert.throws(() => normalizeInvitationSummary({ ...base, view_only: false }, 1_000));3337 assert.throws(() => normalizeInvitationSummary({ ...base, token: 'secret' }, 1_000));3438 });3539 40+test('reports view-only invitation eligibility explicitly', () => {41+ const summary = normalizeInvitationSummary({42+ host_node_id: HOST,43+ peer_node_id: PEER,44+ expires_at_unix: 1_100,45+ grants: ['view'],46+ view_only: true,47+ input_capable: false,48+ }, 1_000);49+ assert.equal(summary.viewOnly, true);50+ assert.equal(summary.inputCapable, false);51+});52+3653 test('formats fingerprints, grants, and bounded expiry copy', () => {3754 assert.equal(shortPeerFingerprint(PEER), 'abababab…abababab');3855 assert.equal(grantLabel('pointer_keyboard'), 'keyboard + mouse');@@ -132,7 +132,8 @@ function showInvitationSummary(value) {
132132 document.getElementById('invitation-host').textContent = summary.hostFingerprint;133133 document.getElementById('invitation-peer').textContent = summary.peerFingerprint;134134 document.getElementById('invitation-expiry').textContent = formatInvitationExpiry(summary.expiresAtUnix);135- document.getElementById('invitation-grants').textContent = summary.grants.map(grantLabel).join(' · ');135+ const access = summary.viewOnly ? 'view only' : 'input-capable';136+ document.getElementById('invitation-grants').textContent = `${access} · ${summary.grants.map(grantLabel).join(' · ')}`;136137 document.getElementById('invitation-status').textContent = '';137138 document.getElementById('invitation-overlay').classList.remove('hidden');138139 setTimeout(() => setControllerFocus(document.getElementById('confirm-invitation')), 0);@@ -46,6 +46,8 @@ pub struct InvitationSummary {
4646 peer_node_id: String,4747 expires_at_unix: u64,4848 grants: Vec<&'static str>,49+ view_only: bool,50+ input_capable: bool,4951 }5052 5153 @@ -54,6 +56,8 @@ pub struct EnrollmentStatus {
5456 host_node_id: Option<String>,5557 peer_node_id: Option<String>,5658 grants: Vec<&'static str>,59+ view_only: bool,60+ input_capable: bool,5761 pending: Option<InvitationSummary>,5862 }5963 @@ -96,11 +100,18 @@ fn public_key(bytes: [u8; 32], name: &str) -> Result<PublicKey, String> {
96100 }97101 98102 fn summary(invitation: &SignedInvitation) -> Result<InvitationSummary, String> {103+ let input_capable = invitation104+ .claims105+ .grants106+ .contains(InvitationGrants::POINTER_KEYBOARD)107+ || invitation.claims.grants.contains(InvitationGrants::GAMEPAD);99108 Ok(InvitationSummary {100109 host_node_id: public_key(invitation.claims.host_node_id, "host")?.to_string(),101110 peer_node_id: public_key(invitation.claims.intended_peer_id, "Portal")?.to_string(),102111 expires_at_unix: invitation.claims.expires_at_unix,103112 grants: grant_names(invitation.claims.grants),113+ view_only: !input_capable,114+ input_capable,104115 })105116 }106117 @@ -364,15 +375,21 @@ pub fn portal_enrollment_status(
364375 host_node_id: None,365376 peer_node_id: None,366377 grants: Vec::new(),378+ view_only: false,379+ input_capable: false,367380 pending,368381 });369382 };370383 let grants = validate_profile(&profile)?;384+ let input_capable = grants.contains(InvitationGrants::POINTER_KEYBOARD)385+ || grants.contains(InvitationGrants::GAMEPAD);371386 Ok(EnrollmentStatus {372387 enrolled: true,373388 host_node_id: Some(profile.host_node_id),374389 peer_node_id: Some(profile.peer_node_id),375390 grants: grant_names(grants),391+ view_only: !input_capable,392+ input_capable,376393 pending,377394 })378395 }@@ -552,7 +569,7 @@ mod tests {
552569 553570 use super::*;554571 555- fn token(now: u64) -> String {572+ fn token_with_grants(now: u64, grants: InvitationGrants) -> String {556573 let host = SigningKey::from_bytes(&[7; 32]);557574 let peer = SigningKey::from_bytes(&[9; 32]);558575 let claims = InvitationClaims::new(@@ -562,12 +579,16 @@ mod tests {
562579 now + 300,563580 1,564581 [3; 32],565- InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),582+ grants,566583 )567584 .unwrap();568585 SignedInvitation::issue(claims, &[7; 32]).unwrap().encode()569586 }570587 588+ fn token(now: u64) -> String {589+ token_with_grants(now, InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD))590+ }591+571592 572593 fn staged_summary_never_contains_the_raw_ticket() {573594 let now = now_unix().unwrap();@@ -577,6 +598,18 @@ mod tests {
577598 let json = serde_json::to_string(&summary).unwrap();578599 assert!(!json.contains(&raw));579600 assert!(json.contains("gamepad"));601+ assert!(json.contains("\"input_capable\":true"));602+ assert!(json.contains("\"view_only\":false"));603+ }604+605+ 606+ fn view_only_invitation_is_reported_without_input_eligibility() {607+ let now = now_unix().unwrap();608+ let state = EnrollmentState::default();609+ let summary = stage_token(&state, token_with_grants(now, InvitationGrants::VIEW)).unwrap();610+ let json = serde_json::to_string(&summary).unwrap();611+ assert!(json.contains("\"view_only\":true"));612+ assert!(json.contains("\"input_capable\":false"));580613 }581614 582615