cat / goq
clone

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

Harden invitation acceptance evidence (#19)

FelineStateMachine committed 22d7031
* test: harden invitation authorization evidence

* test: prove strict Portal invitation ingress
commit
22d703184b16c459b4bb4f860f1d84362de9cd77
parent
203e13e7c5c2a20a797aba4ad51cc0c9ffb2ebfd
author
FelineStateMachine <dami.etoile@gmail.com>
5 files changed, +303 -38
Mcrates/sigil-host/src/authorization.rs86++++++++++++++
Mcrates/sigil-protocol/src/invitation.rs65+++++++++++
Mscripts/verify-demo-build.sh4+
Msrc-tauri/src/commands/enrollment.rs179++++++++++++++++++++++++------
Msrc-tauri/src/commands/state.rs7+
M crates/sigil-host/src/authorization.rs +86 -0
@@ -665,4 +665,90 @@ mod tests {
665665 );666666 assert!(!snapshot.grants.unwrap().contains(InvitationGrants::GAMEPAD));667667 }668+669+ #[test]670+ fn consumed_nonce_replay_survives_revoke_and_store_reopen() {671+ let (_directory, host, peer, store) = store();672+ let nonce = [6; 32];673+ let first = token(674+ &store,675+ &host,676+ peer.public(),677+ 1_000,678+ nonce,679+ InvitationGrants::VIEW,680+ );681+ store682+ .authorize_or_redeem(peer.public(), Some(&first), 1_001)683+ .unwrap();684+ assert!(store.revoke(1_002).unwrap());685+686+ let reopened =687+ AuthorizationStore::open(store.state_directory.clone(), host.public()).unwrap();688+ let replay = token(689+ &reopened,690+ &host,691+ peer.public(),692+ 1_003,693+ nonce,694+ InvitationGrants::VIEW,695+ );696+ let state_before = fs::read(reopened.state_path()).unwrap();697+ let error = reopened698+ .authorize_or_redeem(peer.public(), Some(&replay), 1_004)699+ .unwrap_err();700+701+ assert!(error.to_string().contains("already been consumed"));702+ assert_eq!(fs::read(reopened.state_path()).unwrap(), state_before);703+ let snapshot = reopened.snapshot().unwrap();704+ assert_eq!(snapshot.epoch, 2);705+ assert!(snapshot.peer.is_none());706+ }707+708+ #[test]709+ fn invitation_clock_boundaries_are_inclusive_and_fail_closed_outside_skew() {710+ let (_directory, host, peer, expiry_store) = store();711+ let expires_at = token(712+ &expiry_store,713+ &host,714+ peer.public(),715+ 1_000,716+ [7; 32],717+ InvitationGrants::VIEW,718+ );719+ assert!(720+ expiry_store721+ .authorize_or_redeem(peer.public(), Some(&expires_at), 1_600)722+ .is_ok()723+ );724+725+ let (_directory, host, peer, skew_store) = store();726+ let exact_skew = token(727+ &skew_store,728+ &host,729+ peer.public(),730+ 1_060,731+ [8; 32],732+ InvitationGrants::VIEW,733+ );734+ assert!(735+ skew_store736+ .authorize_or_redeem(peer.public(), Some(&exact_skew), 1_000)737+ .is_ok()738+ );739+740+ let (_directory, host, peer, outside_store) = store();741+ let outside_skew = token(742+ &outside_store,743+ &host,744+ peer.public(),745+ 1_061,746+ [9; 32],747+ InvitationGrants::VIEW,748+ );749+ let error = outside_store750+ .authorize_or_redeem(peer.public(), Some(&outside_skew), 1_000)751+ .unwrap_err();752+ assert!(error.to_string().contains("too far in the future"));753+ }668754 }
M crates/sigil-protocol/src/invitation.rs +65 -0
@@ -304,6 +304,71 @@ mod tests {
304304 assert!(SignedInvitation::decode(&(token + "=")).is_err());305305 }306306 307+ #[test]308+ fn malformed_ticket_shapes_fail_closed() {309+ let token = invitation().encode();310+ let encoded = token.strip_prefix(INVITATION_TOKEN_PREFIX).unwrap();311+ let bytes = URL_SAFE_NO_PAD.decode(encoded).unwrap();312+313+ let encode =314+ |bytes: &[u8]| format!("{INVITATION_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes));315+ let mut bad_magic = bytes.clone();316+ bad_magic[0] ^= 0xff;317+ let mut bad_version = bytes.clone();318+ bad_version[5] ^= 0xff;319+ let mut truncated = bytes.clone();320+ truncated.pop();321+ let mut oversized = bytes.clone();322+ oversized.push(0);323+324+ let malformed = [325+ (326+ "wrong prefix",327+ token.replacen(INVITATION_TOKEN_PREFIX, "wrong.", 1),328+ ),329+ ("leading whitespace", format!(" {token}")),330+ ("trailing whitespace", format!("{token}\n")),331+ ("invalid base64url", format!("{INVITATION_TOKEN_PREFIX}!")),332+ ("wrong decoded size", encode(&truncated)),333+ ("oversized decoded body", encode(&oversized)),334+ ("wrong magic", encode(&bad_magic)),335+ ("wrong version", encode(&bad_version)),336+ ];337+338+ for (case, malformed) in malformed {339+ assert!(340+ SignedInvitation::decode(&malformed).is_err(),341+ "malformed invitation unexpectedly accepted: {case}"342+ );343+ }344+ }345+346+ #[test]347+ fn every_nonzero_known_grant_combination_round_trips_exactly() {348+ let host = SigningKey::from_bytes(&[7; 32]);349+ let peer = SigningKey::from_bytes(&[9; 32]);350+351+ for bits in 1..=InvitationGrants::ALL.bits() {352+ let grants = InvitationGrants::new(bits).unwrap();353+ let claims = InvitationClaims::new(354+ host.verifying_key().to_bytes(),355+ peer.verifying_key().to_bytes(),356+ 1_700_000_000,357+ 1_700_000_600,358+ 3,359+ [bits; 32],360+ grants,361+ )362+ .unwrap();363+ let token = SignedInvitation::issue(claims, &[7; 32]).unwrap().encode();364+365+ assert_eq!(366+ SignedInvitation::decode(&token).unwrap().claims.grants,367+ grants368+ );369+ }370+ }371+307372 #[test]308373 fn claims_reject_unknown_grants_lifetime_epoch_and_nonce() {309374 assert!(InvitationGrants::new(0).is_err());
M scripts/verify-demo-build.sh +4 -0
M src-tauri/src/commands/enrollment.rs +141 -38
@@ -64,6 +64,12 @@ pub struct ConnectionEnrollment {
6464 pub pending_invitation: Option<String>,6565 }6666 67+#[derive(Clone, Debug, PartialEq, Eq)]68+enum OpenedInvitationSource {69+ DeepLinkToken(String),70+ File(PathBuf),71+}72+6773 fn now_unix() -> Result<u64, String> {6874 SystemTime::now()6975 .duration_since(UNIX_EPOCH)
@@ -181,6 +187,52 @@ fn read_bounded_file(path: &Path, maximum: u64, require_private: bool) -> Result
181187 Ok(bytes)182188 }183189 190+fn invitation_token_from_file(path: &Path) -> Result<String, String> {191+ if path.extension().and_then(|value| value.to_str()) != Some("goq-invite") {192+ return Err("Invitation file must end in .goq-invite".to_string());193+ }194+ let bytes = read_bounded_file(path, MAX_IMPORT_BYTES, false)?;195+ let token = std::str::from_utf8(&bytes)196+ .map_err(|_| "Invitation file is not UTF-8".to_string())?197+ .trim_end_matches(['\r', '\n']);198+ if token.len() != bytes.len() && bytes.len() - token.len() > 2 {199+ return Err("Invitation file has unexpected trailing data".to_string());200+ }201+ Ok(token.to_string())202+}203+204+fn opened_invitation_source(url: &url::Url) -> Result<OpenedInvitationSource, String> {205+ if url.scheme() == "goq" {206+ if url.host_str() != Some("invite")207+ || url.port().is_some()208+ || !url.username().is_empty()209+ || url.password().is_some()210+ || url.query().is_some()211+ || url.fragment().is_some()212+ {213+ return Err("Invitation deep link shape is invalid".to_string());214+ }215+ let mut segments = url216+ .path_segments()217+ .ok_or_else(|| "Invitation deep link has no token".to_string())?218+ .filter(|segment| !segment.is_empty());219+ let token = segments220+ .next()221+ .ok_or_else(|| "Invitation deep link has no token".to_string())?;222+ if segments.next().is_some() {223+ return Err("Invitation deep link has extra path segments".to_string());224+ }225+ Ok(OpenedInvitationSource::DeepLinkToken(token.to_string()))226+ } else if url.scheme() == "file" {227+ let path = url228+ .to_file_path()229+ .map_err(|_| "Invitation file URL is invalid".to_string())?;230+ Ok(OpenedInvitationSource::File(path))231+ } else {232+ Err("Only goq invitation links and invitation files are accepted".to_string())233+ }234+}235+184236 fn load_profile(path: &Path) -> Result<Option<EnrollmentProfile>, String> {185237 match fs::symlink_metadata(path) {186238 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
@@ -331,17 +383,7 @@ pub fn portal_import_invitation_file(
331383 state: State<'_, super::state::AppState>,332384 ) -> Result<InvitationSummary, String> {333385 let path = PathBuf::from(path);334- if path.extension().and_then(|value| value.to_str()) != Some("goq-invite") {335- return Err("Invitation file must end in .goq-invite".to_string());336- }337- let bytes = read_bounded_file(&path, MAX_IMPORT_BYTES, false)?;338- let token = std::str::from_utf8(&bytes)339- .map_err(|_| "Invitation file is not UTF-8".to_string())?340- .trim_end_matches(['\r', '\n']);341- if token.len() != bytes.len() && bytes.len() - token.len() > 2 {342- return Err("Invitation file has unexpected trailing data".to_string());343- }344- stage_token(&state.enrollment, token.to_string())386+ stage_token(&state.enrollment, invitation_token_from_file(&path)?)345387 }346388 347389 fn ensure_profile_can_be_replaced(existing: Option<&EnrollmentProfile>) -> Result<(), String> {
@@ -440,34 +482,11 @@ pub async fn portal_reset_enrollment(
440482 441483 pub fn stage_opened_url(app: &AppHandle, url: &url::Url) -> Result<(), String> {442484 let state = app.state::<super::state::AppState>();443- let summary = if url.scheme() == "goq" {444- if url.host_str() != Some("invite")445- || url.port().is_some()446- || !url.username().is_empty()447- || url.password().is_some()448- || url.query().is_some()449- || url.fragment().is_some()450- {451- return Err("Invitation deep link shape is invalid".to_string());452- }453- let mut segments = url454- .path_segments()455- .ok_or_else(|| "Invitation deep link has no token".to_string())?456- .filter(|segment| !segment.is_empty());457- let token = segments458- .next()459- .ok_or_else(|| "Invitation deep link has no token".to_string())?;460- if segments.next().is_some() {461- return Err("Invitation deep link has extra path segments".to_string());485+ let summary = match opened_invitation_source(url)? {486+ OpenedInvitationSource::DeepLinkToken(token) => stage_token(&state.enrollment, token)?,487+ OpenedInvitationSource::File(path) => {488+ stage_token(&state.enrollment, invitation_token_from_file(&path)?)?462489 }463- stage_token(&state.enrollment, token.to_string())?464- } else if url.scheme() == "file" {465- let path = url466- .to_file_path()467- .map_err(|_| "Invitation file URL is invalid".to_string())?;468- portal_import_invitation_file(path.to_string_lossy().into_owned(), state)?469- } else {470- return Err("Only goq invitation links and invitation files are accepted".to_string());471490 };472491 app.emit("invitation-pending", summary)473492 .map_err(|error| format!("Could not announce invitation: {error}"))?;
@@ -641,4 +660,88 @@ mod tests {
641660 clear_exact_pending_invitation(&mut profile, &expected).unwrap();642661 assert!(ensure_profile_can_be_replaced(Some(&profile)).is_err());643662 }663+664+ #[test]665+ fn strict_deep_link_shape_accepts_only_one_bounded_token_path() {666+ let raw = token(now_unix().unwrap());667+ let accepted = url::Url::parse(&format!("goq://invite/{raw}")).unwrap();668+ assert_eq!(669+ opened_invitation_source(&accepted).unwrap(),670+ OpenedInvitationSource::DeepLinkToken(raw.clone())671+ );672+673+ let rejected = [674+ format!("goq://other/{raw}"),675+ "goq://invite".to_string(),676+ format!("goq://invite/{raw}/extra"),677+ format!("goq://invite/{raw}?unexpected=1"),678+ format!("goq://invite/{raw}#unexpected"),679+ format!("goq://user@invite/{raw}"),680+ format!("goq://invite:123/{raw}"),681+ format!("https://invite/{raw}"),682+ ];683+ for value in rejected {684+ let url = url::Url::parse(&value).unwrap();685+ assert!(686+ opened_invitation_source(&url).is_err(),687+ "unexpected invitation URL accepted: {value}"688+ );689+ }690+ }691+692+ #[test]693+ fn file_ingress_accepts_only_bounded_exact_invitation_text() {694+ let temp = tempfile::tempdir().unwrap();695+ let raw = token(now_unix().unwrap());696+ for (name, suffix) in [697+ ("exact.goq-invite", ""),698+ ("newline.goq-invite", "\n"),699+ ("crlf.goq-invite", "\r\n"),700+ ] {701+ let path = temp.path().join(name);702+ fs::write(&path, format!("{raw}{suffix}")).unwrap();703+ assert_eq!(invitation_token_from_file(&path).unwrap(), raw);704+ }705+706+ let file_url = url::Url::from_file_path(temp.path().join("exact.goq-invite")).unwrap();707+ assert_eq!(708+ opened_invitation_source(&file_url).unwrap(),709+ OpenedInvitationSource::File(temp.path().join("exact.goq-invite"))710+ );711+ }712+713+ #[test]714+ fn file_ingress_rejects_unsafe_or_malformed_sources() {715+ let temp = tempfile::tempdir().unwrap();716+ let raw = token(now_unix().unwrap());717+718+ let wrong_extension = temp.path().join("invitation.txt");719+ fs::write(&wrong_extension, &raw).unwrap();720+ assert!(invitation_token_from_file(&wrong_extension).is_err());721+722+ let trailing = temp.path().join("trailing.goq-invite");723+ fs::write(&trailing, format!("{raw}\n\n\n")).unwrap();724+ assert!(invitation_token_from_file(&trailing).is_err());725+726+ let invalid_utf8 = temp.path().join("invalid-utf8.goq-invite");727+ fs::write(&invalid_utf8, [0xff, 0xfe]).unwrap();728+ assert!(invitation_token_from_file(&invalid_utf8).is_err());729+730+ let oversized = temp.path().join("oversized.goq-invite");731+ fs::write(&oversized, vec![b'x'; MAX_IMPORT_BYTES as usize + 1]).unwrap();732+ assert!(invitation_token_from_file(&oversized).is_err());733+734+ let directory = temp.path().join("directory.goq-invite");735+ fs::create_dir(&directory).unwrap();736+ assert!(invitation_token_from_file(&directory).is_err());737+738+ #[cfg(unix)]739+ {740+ let target = temp.path().join("target.goq-invite");741+ let symlink = temp.path().join("symlink.goq-invite");742+ fs::write(&target, raw).unwrap();743+ std::os::unix::fs::symlink(target, &symlink).unwrap();744+ assert!(invitation_token_from_file(&symlink).is_err());745+ }746+ }644747 }
M src-tauri/src/commands/state.rs +7 -0