cat / goq
clone

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

Harden public-alpha enrollment and release delivery (#13)

FelineStateMachine committed 16ad85a
* feat: add capability enrollment gate

Bind one-time Sigil invitations to the passkey-derived Portal peer, persist grants and replay state fail-closed, and keep reconnects ticket-free.

Add controller-usable import, confirmation, and explicit enrollment reset flows without exposing raw invitation material to the webview.

* build: gate public release artifacts

Create one shared draft transaction for signed Sigil and notarized Portal assets, with offline Minisign verification and native macOS re-verification before prerelease promotion.

Keep website downloads unavailable until a reviewed release manifest is committed.

* test: bind public-alpha hardware evidence

Require exact signed Sigil and notarized Portal release candidates before initializing or verifying the eight-gate hardware UAT evidence bundle.

Reject mutable refs, substituted artifacts, stale records, unsafe files, secrets, and fabricated pass-through fields.

* docs: align public-alpha operations

Document the current capability boundary, one-time Portal enrollment and reset, offline Sigil signing ceremony, and exact tagged package contract.

* fix: keep enrollment reset controller reachable

* build: bind Portal releases to protected provenance

* test: bind UAT to published release provenance

* fix: grant Portal attestation metadata scope
commit
16ad85a3f229853863395ee9bc0817d0c4904eb8
parent
376d2e264430aef42856fdcd03a8509b8c7e37f1
author
FelineStateMachine <dami.etoile@gmail.com>
53 files changed, +7895 -195
A.github/workflows/portal-release.yml197++++++
A.github/workflows/sigil-release.yml229++++++++
MAGENTS.md27+-
MCargo.lock609++++++++++++++++++++-
MREADME.md70++-
Acrates/sigil-host/src/authorization.rs668++++++++++++++++++++++
Mcrates/sigil-host/src/main.rs231++++++++-
Mcrates/sigil-host/src/server.rs260++++++++-
Mcrates/sigil-protocol/Cargo.toml2+
Mcrates/sigil-protocol/src/error.rs3+
Mcrates/sigil-protocol/src/handshake.rs55++-
Acrates/sigil-protocol/src/invitation.rs335+++++++++++
Mcrates/sigil-protocol/src/lib.rs5+
Mdocs/fresh-bazzite-host.md103++-
Adocs/portal-release.md159+++++
Adocs/public-alpha-uat.md330+++++++++++
Mdocs/public-release-delivery.md94+++-
Aportal/enrollment-ui.test.mjs37+
Aportal/enrollment.mjs62++
Aportal/enrollment.test.mjs42+
Mportal/index.html61++-
Mportal/main.js189++++++-
Mportal/style.css48++
Arelease/portal-apple-team-id.txt1+
Arelease/sigil-minisign.pub1+
Mscripts/install-bazzite-package.sh73++-
Mscripts/package-bazzite-release.sh77++-
Mscripts/package-macos-client.sh96++-
Ascripts/public-alpha-uat.sh912++++++++++++++++++++++++++++++
Ascripts/sign-bazzite-release.sh107++++
Ascripts/tests/bazzite-package-install-e2e.sh212+++++++
Ascripts/tests/package-bazzite-product-contract.sh128++++
Mscripts/tests/package-bazzite-provenance.sh18+
Ascripts/tests/portal-release.sh9+
Ascripts/tests/public-alpha-uat.sh503+++++++++++++++++
Ascripts/tests/sigil-release-verifier.sh190++++++
Ascripts/tests/test_portal_release.py248++++++++
Ascripts/verify-macos-portal-signature.sh85+++
Ascripts/verify-portal-release.py331+++++++++++
Ascripts/verify-sigil-release.sh284+++++++++
Mscripts/verify-website.sh10+-
Msrc-tauri/Cargo.toml9+
Msrc-tauri/capabilities/default.json3+-
Msrc-tauri/src/commands/auth.rs35+-
Asrc-tauri/src/commands/enrollment.rs644+++++++++++++++++++++
Msrc-tauri/src/commands/mod.rs1+
Msrc-tauri/src/commands/network.rs131++++-
Msrc-tauri/src/commands/state.rs2+
Msrc-tauri/src/main.rs57++-
Msrc-tauri/tauri.conf.json16+
Mwebsite/_headers6+-
Mwebsite/dither.js75++-
Awebsite/portal-release.json10+
A .github/workflows/portal-release.yml +197 -0
@@ -0,0 +1,197 @@
1+name: Portal release2+3+on:4+ workflow_dispatch:5+ inputs:6+ tag:7+ description: Existing immutable vVERSION tag with a Sigil draft release8+ required: true9+ type: string10+11+permissions:12+ contents: read13+14+concurrency:15+ group: public-release-${{ inputs.tag }}16+ cancel-in-progress: false17+18+jobs:19+ policy:20+ name: Verify release policy21+ runs-on: ubuntu-24.0422+ timeout-minutes: 1023+ steps:24+ - name: Check out exact tag25+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.126+ with:27+ ref: ${{ inputs.tag }}28+ fetch-depth: 029+30+ - name: Verify source, tests, and website31+ env:32+ RELEASE_TAG: ${{ inputs.tag }}33+ run: |34+ python3 scripts/verify-portal-release.py source \35+ --repo-dir "$GITHUB_WORKSPACE" \36+ --release-tag "$RELEASE_TAG"37+ ./scripts/tests/portal-release.sh38+ ./scripts/verify-website.sh39+40+ publish:41+ name: Sign, notarize, and publish macOS arm6442+ needs: policy43+ runs-on: macos-2644+ timeout-minutes: 9045+ environment:46+ name: main47+ permissions:48+ artifact-metadata: write49+ attestations: write50+ contents: write51+ id-token: write52+ env:53+ RELEASE_TAG: ${{ inputs.tag }}54+ steps:55+ - name: Check out exact tag56+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.157+ with:58+ ref: ${{ inputs.tag }}59+ fetch-depth: 060+61+ - name: Require workflow execution from the exact tag62+ run: |63+ test "$GITHUB_REF" = "refs/tags/$RELEASE_TAG"64+ test "$GITHUB_SHA" = "$(git rev-parse HEAD)"65+66+ - name: Verify native arm64 runner67+ run: |68+ test "$(uname -s)" = Darwin69+ test "$(uname -m)" = arm6470+ rustup target add aarch64-apple-darwin71+72+ - name: Install pinned Tauri CLI73+ run: cargo install tauri-cli --version 2.11.3 --locked74+75+ - name: Import Developer ID certificate76+ env:77+ APPLE_CERTIFICATE_BASE64: ${{ secrets.PORTAL_APPLE_CERTIFICATE_BASE64 }}78+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.PORTAL_APPLE_CERTIFICATE_PASSWORD }}79+ run: |80+ certificate_path="$RUNNER_TEMP/portal-developer-id.p12"81+ keychain_path="$RUNNER_TEMP/portal-signing.keychain-db"82+ keychain_password="$(openssl rand -hex 32)"83+ echo "::add-mask::$keychain_password"84+ python3 - "$certificate_path" <<'PY'85+ import base6486+ import os87+ import pathlib88+ import sys89+90+ encoded = os.environ.get("APPLE_CERTIFICATE_BASE64", "")91+ if not encoded:92+ raise SystemExit("PORTAL_APPLE_CERTIFICATE_BASE64 is not configured")93+ pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(encoded, validate=True))94+ PY95+ chmod 0600 "$certificate_path"96+ security create-keychain -p "$keychain_password" "$keychain_path"97+ security set-keychain-settings -lut 21600 "$keychain_path"98+ security unlock-keychain -p "$keychain_password" "$keychain_path"99+ security default-keychain -s "$keychain_path"100+ security import "$certificate_path" \101+ -k "$keychain_path" \102+ -P "$APPLE_CERTIFICATE_PASSWORD" \103+ -T /usr/bin/codesign \104+ -T /usr/bin/security105+ security set-key-partition-list \106+ -S apple-tool:,apple: \107+ -s \108+ -k "$keychain_password" \109+ "$keychain_path" >/dev/null110+111+ - name: Build and verify signed release assets112+ env:113+ APPLE_SIGNING_IDENTITY: ${{ secrets.PORTAL_APPLE_SIGNING_IDENTITY }}114+ APPLE_ID: ${{ secrets.PORTAL_APPLE_ID }}115+ APPLE_PASSWORD: ${{ secrets.PORTAL_APPLE_PASSWORD }}116+ APPLE_TEAM_ID: ${{ secrets.PORTAL_APPLE_TEAM_ID }}117+ run: |118+ release_dir="$RUNNER_TEMP/portal-release"119+ mkdir -m 0700 "$release_dir"120+ ./scripts/package-macos-client.sh \121+ --release-tag "$RELEASE_TAG" \122+ --output-dir "$release_dir"123+124+ - name: Attest exact Portal build provenance125+ uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.1.0126+ with:127+ subject-path: '${{ runner.temp }}/portal-release/Portal-*'128+129+ - name: Require the exact Sigil candidate draft130+ env:131+ GH_TOKEN: ${{ github.token }}132+ run: |133+ release_json="$(gh release view "$RELEASE_TAG" --json isDraft,tagName,assets)"134+ test "$(jq -r '.isDraft' <<<"$release_json")" = true135+ test "$(jq -r '.tagName' <<<"$release_json")" = "$RELEASE_TAG"136+ sigil_asset="sigil-$RELEASE_TAG-bazzite-x86_64.tar.gz"137+ expected="$(printf '%s\n' "$sigil_asset" "$sigil_asset.sha256" | sort)"138+ actual="$(jq -r '.assets[].name' <<<"$release_json" | sort)"139+ test "$actual" = "$expected" || {140+ echo "draft must contain only the two verified Sigil candidate assets before Portal upload" >&2141+ exit 1142+ }143+144+ - name: Upload exact Portal assets145+ env:146+ GH_TOKEN: ${{ github.token }}147+ run: |148+ version="${RELEASE_TAG#v}"149+ release_dir="$RUNNER_TEMP/portal-release"150+ dmg="Portal-${version}-arm64.dmg"151+ checksum="${dmg}.sha256"152+ manifest="Portal-${version}-arm64.json"153+ gh release upload "$RELEASE_TAG" \154+ "$release_dir/$dmg" \155+ "$release_dir/$checksum" \156+ "$release_dir/$manifest"157+158+ - name: Verify the combined draft remains complete and unpublished159+ env:160+ GH_TOKEN: ${{ github.token }}161+ run: |162+ release_json="$(gh release view "$RELEASE_TAG" --json isDraft,tagName,assets)"163+ test "$(jq -r '.isDraft' <<<"$release_json")" = true164+ test "$(jq -r '.tagName' <<<"$release_json")" = "$RELEASE_TAG"165+ version="${RELEASE_TAG#v}"166+ sigil_asset="sigil-$RELEASE_TAG-bazzite-x86_64.tar.gz"167+ expected="$(printf '%s\n' \168+ "$sigil_asset" \169+ "$sigil_asset.sha256" \170+ "Portal-${version}-arm64.dmg" \171+ "Portal-${version}-arm64.dmg.sha256" \172+ "Portal-${version}-arm64.json" | sort)"173+ actual="$(jq -r '.assets[].name' <<<"$release_json" | sort)"174+ test "$actual" = "$expected" || {175+ echo "combined draft does not match the five-asset pre-signature contract" >&2176+ exit 1177+ }178+ asset_names="$RUNNER_TEMP/portal-release-assets.json"179+ jq '[.assets[].name | select(startswith("Portal-"))]' \180+ <<<"$release_json" >"$asset_names"181+ python3 scripts/verify-portal-release.py release-assets \182+ --release-tag "$RELEASE_TAG" \183+ --names-json "$asset_names"184+185+ - name: Remove signing keychain186+ if: always()187+ run: |188+ security delete-keychain "$RUNNER_TEMP/portal-signing.keychain-db" 2>/dev/null || true189+ python3 - "$RUNNER_TEMP/portal-developer-id.p12" <<'PY'190+ import pathlib191+ import sys192+193+ path = pathlib.Path(sys.argv[1])194+ if path.exists():195+ path.write_bytes(b"\0" * path.stat().st_size)196+ path.unlink()197+ PY
A .github/workflows/sigil-release.yml +229 -0
@@ -0,0 +1,229 @@
1+name: Sigil Release2+3+on:4+ workflow_dispatch:5+ inputs:6+ operation:7+ description: Build a draft candidate or verify and promote an offline-signed draft8+ required: true9+ type: choice10+ options:11+ - build-candidate12+ - promote-signed-draft13+ tag:14+ description: Existing immutable vVERSION tag15+ required: true16+ type: string17+18+permissions:19+ contents: read20+21+concurrency:22+ group: public-release-${{ inputs.tag }}23+ cancel-in-progress: false24+25+jobs:26+ candidate:27+ name: Build clean Sigil candidate28+ if: inputs.operation == 'build-candidate'29+ runs-on: ubuntu-24.0430+ timeout-minutes: 6031+ environment: main32+ permissions:33+ contents: write34+ env:35+ CARGO_TERM_COLOR: always36+ GH_TOKEN: ${{ github.token }}37+ RELEASE_TAG: ${{ inputs.tag }}38+ steps:39+ - name: Check out exact release tag40+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.141+ with:42+ ref: ${{ inputs.tag }}43+ fetch-depth: 044+45+ - name: Verify immutable source identity46+ run: |47+ git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"48+ test "$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")" = "$(git rev-parse HEAD)"49+ test -z "$(git status --porcelain=v1)"50+ sigil_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' crates/sigil-host/Cargo.toml | head -n 1)"51+ test "$RELEASE_TAG" = "v$sigil_version"52+53+ - name: Install release build dependencies54+ run: |55+ sudo apt-get update56+ sudo apt-get install --yes --no-install-recommends \57+ build-essential \58+ ffmpeg \59+ libayatana-appindicator3-dev \60+ librsvg2-dev \61+ libssl-dev \62+ libudev-dev \63+ libwebkit2gtk-4.1-dev \64+ libxdo-dev \65+ pkg-config \66+ python3-venv \67+ shellcheck68+ python3 -m venv "$RUNNER_TEMP/zig-venv"69+ "$RUNNER_TEMP/zig-venv/bin/pip" install --disable-pip-version-check 'ziglang==0.16.0'70+ install -d "$RUNNER_TEMP/release-bin"71+ # Expand runner paths when the generated wrapper executes.72+ # shellcheck disable=SC201673+ printf '%s\n' \74+ '#!/usr/bin/env bash' \75+ 'exec "$RUNNER_TEMP/zig-venv/bin/python" -m ziglang "$@"' \76+ >"$RUNNER_TEMP/release-bin/zig"77+ chmod 0755 "$RUNNER_TEMP/release-bin/zig"78+ echo "$RUNNER_TEMP/release-bin" >>"$GITHUB_PATH"79+ cargo install cargo-zigbuild --locked --version 0.23.080+81+ - name: Run complete repository gate82+ run: ./scripts/verify-demo-build.sh83+84+ - name: Build and verify unsigned candidate85+ id: candidate86+ run: |87+ source_commit="$(git rev-parse HEAD)"88+ asset_name="sigil-$RELEASE_TAG-bazzite-x86_64.tar.gz"89+ output_dir="$RUNNER_TEMP/sigil-release"90+ install -d -m 0700 "$output_dir"91+ archive="$output_dir/$asset_name"92+ ./scripts/package-bazzite-release.sh \93+ --release-tag "$RELEASE_TAG" \94+ --output "$archive"95+ ./scripts/verify-sigil-release.sh \96+ --tag "$RELEASE_TAG" \97+ --archive "$archive" \98+ --source-commit "$source_commit" \99+ --candidate100+ echo "archive=$archive" >>"$GITHUB_OUTPUT"101+ echo "asset_name=$asset_name" >>"$GITHUB_OUTPUT"102+103+ - name: Refuse to replace an existing release104+ run: |105+ if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then106+ echo "release $RELEASE_TAG already exists; refusing to replace its assets" >&2107+ exit 1108+ fi109+110+ - name: Create draft with unsigned candidate111+ run: |112+ gh release create "$RELEASE_TAG" \113+ --draft \114+ --verify-tag \115+ --title "goq $RELEASE_TAG public alpha" \116+ --notes "Incomplete public-alpha draft. Promotion remains blocked until signed Portal assets and the offline Sigil Minisign signature are attached and jointly verified." \117+ "${{ steps.candidate.outputs.archive }}" \118+ "${{ steps.candidate.outputs.archive }}.sha256"119+120+ - name: Retain candidate for offline transfer121+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2122+ with:123+ name: sigil-${{ inputs.tag }}-offline-signing-candidate124+ path: |125+ ${{ steps.candidate.outputs.archive }}126+ ${{ steps.candidate.outputs.archive }}.sha256127+ if-no-files-found: error128+ retention-days: 14129+130+ promote:131+ name: Verify offline signature and promote132+ if: inputs.operation == 'promote-signed-draft'133+ runs-on: macos-26134+ timeout-minutes: 30135+ environment: main136+ permissions:137+ contents: write138+ env:139+ GH_TOKEN: ${{ github.token }}140+ RELEASE_TAG: ${{ inputs.tag }}141+ steps:142+ - name: Check out exact release tag143+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1144+ with:145+ ref: ${{ inputs.tag }}146+ fetch-depth: 0147+148+ - name: Verify immutable source identity149+ id: source150+ run: |151+ git show-ref --verify --quiet "refs/tags/$RELEASE_TAG"152+ source_commit="$(git rev-parse "refs/tags/$RELEASE_TAG^{commit}")"153+ test "$source_commit" = "$(git rev-parse HEAD)"154+ test -z "$(git status --porcelain=v1)"155+ sigil_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' crates/sigil-host/Cargo.toml | head -n 1)"156+ test "$RELEASE_TAG" = "v$sigil_version"157+ echo "commit=$source_commit" >>"$GITHUB_OUTPUT"158+159+ - name: Install public verification tools160+ run: brew install minisign161+162+ - name: Require the complete signed public-alpha draft163+ id: assets164+ run: |165+ release_json="$(gh release view "$RELEASE_TAG" --json isDraft,tagName,assets)"166+ test "$(jq -r '.isDraft' <<<"$release_json")" = true167+ test "$(jq -r '.tagName' <<<"$release_json")" = "$RELEASE_TAG"168+ asset_name="sigil-$RELEASE_TAG-bazzite-x86_64.tar.gz"169+ version="${RELEASE_TAG#v}"170+ expected="$(printf '%s\n' \171+ "$asset_name" \172+ "$asset_name.minisig" \173+ "$asset_name.sha256" \174+ "Portal-${version}-arm64.dmg" \175+ "Portal-${version}-arm64.dmg.sha256" \176+ "Portal-${version}-arm64.json" | sort)"177+ actual="$(jq -r '.assets[].name' <<<"$release_json" | sort)"178+ test "$actual" = "$expected" || {179+ echo "draft release assets do not match the complete six-asset public-alpha contract" >&2180+ diff -u <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") >&2 || true181+ exit 1182+ }183+ output_dir="$RUNNER_TEMP/sigil-release"184+ install -d -m 0700 "$output_dir"185+ gh release download "$RELEASE_TAG" --dir "$output_dir"186+ echo "archive=$output_dir/$asset_name" >>"$GITHUB_OUTPUT"187+188+ - name: Verify detached signature, checksum, archive, and provenance189+ run: |190+ ./scripts/verify-sigil-release.sh \191+ --tag "$RELEASE_TAG" \192+ --archive "${{ steps.assets.outputs.archive }}" \193+ --source-commit "${{ steps.source.outputs.commit }}" \194+ --public-key-file "$GITHUB_WORKSPACE/release/sigil-minisign.pub"195+196+ - name: Verify signed Portal assets against the same source tag197+ env:198+ PORTAL_APPLE_TEAM_ID: ${{ secrets.PORTAL_APPLE_TEAM_ID }}199+ run: |200+ portal_dir="$RUNNER_TEMP/portal-release"201+ install -d -m 0700 "$portal_dir"202+ gh release download "$RELEASE_TAG" --dir "$portal_dir" --pattern 'Portal-*'203+ expected_team_id="$(<release/portal-apple-team-id.txt)"204+ test "$PORTAL_APPLE_TEAM_ID" = "$expected_team_id"205+ python3 scripts/verify-portal-release.py assets \206+ --repo-dir "$GITHUB_WORKSPACE" \207+ --release-tag "$RELEASE_TAG" \208+ --asset-dir "$portal_dir"209+ for asset in "$portal_dir"/*; do210+ gh attestation verify "$asset" \211+ --repo "$GITHUB_REPOSITORY" \212+ --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/portal-release.yml" \213+ --source-ref "refs/tags/$RELEASE_TAG" \214+ --source-digest "${{ steps.source.outputs.commit }}" \215+ --deny-self-hosted-runners > /dev/null216+ done217+ ./scripts/verify-macos-portal-signature.sh \218+ --dmg "$portal_dir/Portal-${RELEASE_TAG#v}-arm64.dmg" \219+ --expected-version "${RELEASE_TAG#v}" \220+ --expected-team-id "$expected_team_id"221+222+ - name: Publish verified prerelease223+ run: |224+ gh release edit "$RELEASE_TAG" \225+ --draft=false \226+ --prerelease \227+ --notes "Verified public alpha for $RELEASE_TAG: signed Bazzite Sigil host package plus Developer ID signed, notarized, stapled macOS arm64 Portal. Hardware UAT remains tracked separately."228+ test "$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')" = false229+ test "$(gh release view "$RELEASE_TAG" --json isPrerelease --jq '.isPrerelease')" = true
M AGENTS.md +15 -12
@@ -69,20 +69,19 @@ Sigil host.
6969 7070 ## Security boundary7171 72-- The normal inherited FIDO flow discovers the host identity; it is not yet73- host-side client authorization. A peer that already knows the node ID and74- ALPN can currently attempt a connection.72+- Portal's FIDO `hmac-secret` derives its stable Iroh peer identity. Sigil signs73+ a short-lived, host-bound invitation for that exact peer and atomically74+ persists the enrolled grants before accepting the first media session.7575 - `--dev-connect` is a test-only routing bypass, never an authorization claim.7676 Debug builds and the explicit `demo-direct-node` feature may accept it and7777 must display the development warning. Ordinary release builds must reject it.78-- Production authorization remains tracked in issue #3. The intended UX is a79- one-time, short-lived, peer-bound capability enrollment; normal startup must80- remain **PIN -> tap -> play**.78+- Enrollment is one-time; ordinary startup remains **PIN -> tap -> play** and79+ reconnects authenticate the enrolled Iroh peer without replaying a ticket.8180 - View, pointer/keyboard, and gamepad permissions must be independently8281 grantable. Replay, expiry, peer mismatch, cross-host use, and capability8382 escalation must fail closed.84-- Treat node IDs and connection metadata as operationally sensitive until that85- authorization boundary lands.83+- The authorization state and replay ledger are security-sensitive host state;84+ preserve their owner-only permissions and atomic update rules.8685 8786 ## Release and installation boundary8887
@@ -96,7 +95,9 @@ Sigil host.
9695 configuration and must not silently start, restart, or enable the service.9796 - Portal is a compiled desktop download, never a shell install. The first9897 public target is macOS arm64 and requires Developer ID signing, hardened99- runtime, notarization, stapling, and strict Gatekeeper verification. Do not98+ runtime, notarization, stapling, and strict Gatekeeper verification. Its99+ TeamIdentifier must equal `release/portal-apple-team-id.txt`, and every100+ Portal asset must carry protected exact-tag GitHub build provenance. Do not100101 advertise an unavailable platform/architecture as a download.101102 - The Minisign secret, Apple certificate, notarization credentials, host102103 identity, and FIDO-derived secrets must never enter the repository, release
@@ -120,12 +121,14 @@ Sigil host.
120121 121122 ## Remaining public-alpha gates122123 123-- Issue #3: one-time capability enrollment and controller-usable Portal import.124+- Issue #3's repository work is implemented: one-time capability enrollment,125+ replay protection, grant intersection, and controller-usable Portal import.126+ Its exact release candidates still participate in the issue #6 hardware UAT.124127 - Issue #4: configure the offline Minisign trust root, publish the signed Sigil125128 asset set, and prove clean install plus upgrade/rollback from the public126129 command.127-- Issue #5: publish the signed/notarized macOS arm64 Portal DMG, digest, and128- manifest.130+- Issue #5: configure the committed Apple TeamIdentifier pin, then publish the131+ signed/notarized/attested macOS arm64 Portal DMG, digest, and manifest.129132 - Issue #6: prove physically headless cold boot, physical client controller130133 gameplay, mouse buttons consumed by Gamescope/an actual game, sustained A/V131134 and resource percentiles without latency growth, difficult-NAT relay
M Cargo.lock +607 -2
@@ -226,6 +226,126 @@ dependencies = [
226226 "syn 2.0.119",227227 ]228228 229+[[package]]230+name = "async-broadcast"231+version = "0.7.2"232+source = "registry+https://github.com/rust-lang/crates.io-index"233+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"234+dependencies = [235+ "event-listener",236+ "event-listener-strategy",237+ "futures-core",238+ "pin-project-lite",239+]240+241+[[package]]242+name = "async-channel"243+version = "2.5.0"244+source = "registry+https://github.com/rust-lang/crates.io-index"245+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"246+dependencies = [247+ "concurrent-queue",248+ "event-listener-strategy",249+ "futures-core",250+ "pin-project-lite",251+]252+253+[[package]]254+name = "async-executor"255+version = "1.14.0"256+source = "registry+https://github.com/rust-lang/crates.io-index"257+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"258+dependencies = [259+ "async-task",260+ "concurrent-queue",261+ "fastrand",262+ "futures-lite",263+ "pin-project-lite",264+ "slab",265+]266+267+[[package]]268+name = "async-io"269+version = "2.6.0"270+source = "registry+https://github.com/rust-lang/crates.io-index"271+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"272+dependencies = [273+ "autocfg",274+ "cfg-if",275+ "concurrent-queue",276+ "futures-io",277+ "futures-lite",278+ "parking",279+ "polling",280+ "rustix",281+ "slab",282+ "windows-sys 0.61.2",283+]284+285+[[package]]286+name = "async-lock"287+version = "3.4.2"288+source = "registry+https://github.com/rust-lang/crates.io-index"289+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"290+dependencies = [291+ "event-listener",292+ "event-listener-strategy",293+ "pin-project-lite",294+]295+296+[[package]]297+name = "async-process"298+version = "2.5.0"299+source = "registry+https://github.com/rust-lang/crates.io-index"300+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"301+dependencies = [302+ "async-channel",303+ "async-io",304+ "async-lock",305+ "async-signal",306+ "async-task",307+ "blocking",308+ "cfg-if",309+ "event-listener",310+ "futures-lite",311+ "rustix",312+]313+314+[[package]]315+name = "async-recursion"316+version = "1.1.1"317+source = "registry+https://github.com/rust-lang/crates.io-index"318+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"319+dependencies = [320+ "proc-macro2",321+ "quote",322+ "syn 2.0.119",323+]324+325+[[package]]326+name = "async-signal"327+version = "0.2.14"328+source = "registry+https://github.com/rust-lang/crates.io-index"329+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"330+dependencies = [331+ "async-io",332+ "async-lock",333+ "atomic-waker",334+ "cfg-if",335+ "futures-core",336+ "futures-io",337+ "rustix",338+ "signal-hook-registry",339+ "slab",340+ "windows-sys 0.61.2",341+]342+343+[[package]]344+name = "async-task"345+version = "4.7.1"346+source = "registry+https://github.com/rust-lang/crates.io-index"347+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"348+229349 [[package]]230350 name = "async-trait"231351 version = "0.1.91"
@@ -410,6 +530,19 @@ dependencies = [
410530 "objc2",411531 ]412532 533+[[package]]534+name = "blocking"535+version = "1.6.2"536+source = "registry+https://github.com/rust-lang/crates.io-index"537+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"538+dependencies = [539+ "async-channel",540+ "async-task",541+ "futures-io",542+ "futures-lite",543+ "piper",544+]545+413546 [[package]]414547 name = "brotli"415548 version = "8.0.4"
@@ -741,12 +874,41 @@ dependencies = [
741874 "memchr",742875 ]743876 877+[[package]]878+name = "concurrent-queue"879+version = "2.5.0"880+source = "registry+https://github.com/rust-lang/crates.io-index"881+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"882+dependencies = [883+ "crossbeam-utils",884+]885+744886 [[package]]745887 name = "const-oid"746888 version = "0.10.2"747889 source = "registry+https://github.com/rust-lang/crates.io-index"748890 checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"749891 892+[[package]]893+name = "const-random"894+version = "0.1.18"895+source = "registry+https://github.com/rust-lang/crates.io-index"896+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"897+dependencies = [898+ "const-random-macro",899+]900+901+[[package]]902+name = "const-random-macro"903+version = "0.1.16"904+source = "registry+https://github.com/rust-lang/crates.io-index"905+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"906+dependencies = [907+ "getrandom 0.2.17",908+ "once_cell",909+ "tiny-keccak",910+]911+750912 [[package]]751913 name = "constant_time_eq"752914 version = "0.4.2"
@@ -1250,6 +1412,15 @@ dependencies = [
12501412 "syn 2.0.119",12511413 ]12521414 1415+[[package]]1416+name = "dlv-list"1417+version = "0.5.2"1418+source = "registry+https://github.com/rust-lang/crates.io-index"1419+checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"1420+dependencies = [1421+ "const-random",1422+]1423+12531424 [[package]]12541425 name = "dom_query"12551426 version = "0.27.0"
@@ -1381,6 +1552,12 @@ version = "0.6.1"
13811552 source = "registry+https://github.com/rust-lang/crates.io-index"13821553 checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"13831554 1555+[[package]]1556+name = "endi"1557+version = "1.1.1"1558+source = "registry+https://github.com/rust-lang/crates.io-index"1559+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"1560+13841561 [[package]]13851562 name = "enum-assoc"13861563 version = "1.3.0"
@@ -1392,6 +1569,27 @@ dependencies = [
13921569 "syn 2.0.119",13931570 ]13941571 1572+[[package]]1573+name = "enumflags2"1574+version = "0.7.12"1575+source = "registry+https://github.com/rust-lang/crates.io-index"1576+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"1577+dependencies = [1578+ "enumflags2_derive",1579+ "serde",1580+]1581+1582+[[package]]1583+name = "enumflags2_derive"1584+version = "0.7.12"1585+source = "registry+https://github.com/rust-lang/crates.io-index"1586+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"1587+dependencies = [1588+ "proc-macro2",1589+ "quote",1590+ "syn 2.0.119",1591+]1592+13951593 [[package]]13961594 name = "env_filter"13971595 version = "0.1.4"
@@ -1429,6 +1627,27 @@ dependencies = [
14291627 "windows-sys 0.61.2",14301628 ]14311629 1630+[[package]]1631+name = "event-listener"1632+version = "5.4.1"1633+source = "registry+https://github.com/rust-lang/crates.io-index"1634+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"1635+dependencies = [1636+ "concurrent-queue",1637+ "parking",1638+ "pin-project-lite",1639+]1640+1641+[[package]]1642+name = "event-listener-strategy"1643+version = "0.5.4"1644+source = "registry+https://github.com/rust-lang/crates.io-index"1645+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"1646+dependencies = [1647+ "event-listener",1648+ "pin-project-lite",1649+]1650+14321651 [[package]]14331652 name = "fastrand"14341653 version = "2.5.0"
@@ -2026,6 +2245,12 @@ version = "0.12.3"
20262245 source = "registry+https://github.com/rust-lang/crates.io-index"20272246 checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"20282247 2248+[[package]]2249+name = "hashbrown"2250+version = "0.14.5"2251+source = "registry+https://github.com/rust-lang/crates.io-index"2252+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"2253+20292254 [[package]]20302255 name = "hashbrown"20312256 version = "0.17.1"
@@ -2049,6 +2274,12 @@ version = "0.5.0"
20492274 source = "registry+https://github.com/rust-lang/crates.io-index"20502275 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"20512276 2277+[[package]]2278+name = "hermit-abi"2279+version = "0.5.2"2280+source = "registry+https://github.com/rust-lang/crates.io-index"2281+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"2282+20522283 [[package]]20532284 name = "hex"20542285 version = "0.4.3"
@@ -2531,7 +2762,7 @@ checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
25312762 dependencies = [25322763 "socket2",25332764 "widestring",2534- "windows-registry",2765+ "windows-registry 0.6.1",25352766 "windows-result 0.4.1",25362767 "windows-sys 0.61.2",25372768 ]
@@ -3838,6 +4069,26 @@ version = "0.2.0"
38384069 source = "registry+https://github.com/rust-lang/crates.io-index"38394070 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"38404071 4072+[[package]]4073+name = "ordered-multimap"4074+version = "0.7.3"4075+source = "registry+https://github.com/rust-lang/crates.io-index"4076+checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"4077+dependencies = [4078+ "dlv-list",4079+ "hashbrown 0.14.5",4080+]4081+4082+[[package]]4083+name = "ordered-stream"4084+version = "0.2.0"4085+source = "registry+https://github.com/rust-lang/crates.io-index"4086+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"4087+dependencies = [4088+ "futures-core",4089+ "pin-project-lite",4090+]4091+38414092 [[package]]38424093 name = "pad"38434094 version = "0.1.6"
@@ -4021,6 +4272,17 @@ version = "0.2.17"
40214272 source = "registry+https://github.com/rust-lang/crates.io-index"40224273 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"40234274 4275+[[package]]4276+name = "piper"4277+version = "0.2.5"4278+source = "registry+https://github.com/rust-lang/crates.io-index"4279+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"4280+dependencies = [4281+ "atomic-waker",4282+ "fastrand",4283+ "futures-io",4284+]4285+40244286 [[package]]40254287 name = "pkcs8"40264288 version = "0.11.0"
@@ -4076,6 +4338,20 @@ dependencies = [
40764338 "miniz_oxide",40774339 ]40784340 4341+[[package]]4342+name = "polling"4343+version = "3.11.0"4344+source = "registry+https://github.com/rust-lang/crates.io-index"4345+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"4346+dependencies = [4347+ "cfg-if",4348+ "concurrent-queue",4349+ "hermit-abi",4350+ "pin-project-lite",4351+ "rustix",4352+ "windows-sys 0.61.2",4353+]4354+40794355 [[package]]40804356 name = "polyval"40814357 version = "0.6.2"
@@ -4104,9 +4380,11 @@ dependencies = [
41044380 "anyhow",41054381 "base64 0.22.1",41064382 "ctap-hid-fido2",4383+ "ed25519-dalek",41074384 "getrandom 0.4.3",41084385 "image",41094386 "iroh",4387+ "libc",41104388 "log",41114389 "openh264",41124390 "serde",
@@ -4115,8 +4393,13 @@ dependencies = [
41154393 "sigil-protocol",41164394 "tauri",41174395 "tauri-build",4396+ "tauri-plugin-deep-link",4397+ "tauri-plugin-dialog",41184398 "tauri-plugin-log",4399+ "tauri-plugin-single-instance",4400+ "tempfile",41194401 "tokio",4402+ "url",41204403 ]41214404 41224405 [[package]]
@@ -4448,6 +4731,30 @@ version = "0.7.6"
44484731 source = "registry+https://github.com/rust-lang/crates.io-index"44494732 checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"44504733 4734+[[package]]4735+name = "rfd"4736+version = "0.16.0"4737+source = "registry+https://github.com/rust-lang/crates.io-index"4738+checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"4739+dependencies = [4740+ "block2",4741+ "dispatch2",4742+ "glib-sys",4743+ "gobject-sys",4744+ "gtk-sys",4745+ "js-sys",4746+ "log",4747+ "objc2",4748+ "objc2-app-kit",4749+ "objc2-core-foundation",4750+ "objc2-foundation",4751+ "raw-window-handle",4752+ "wasm-bindgen",4753+ "wasm-bindgen-futures",4754+ "web-sys",4755+ "windows-sys 0.60.2",4756+]4757+44514758 [[package]]44524759 name = "ring"44534760 version = "0.17.14"
@@ -4462,6 +4769,16 @@ dependencies = [
44624769 "windows-sys 0.52.0",44634770 ]44644771 4772+[[package]]4773+name = "rust-ini"4774+version = "0.21.3"4775+source = "registry+https://github.com/rust-lang/crates.io-index"4776+checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"4777+dependencies = [4778+ "cfg-if",4779+ "ordered-multimap",4780+]4781+44654782 [[package]]44664783 name = "rustc-hash"44674784 version = "2.1.3"
@@ -4990,6 +5307,8 @@ dependencies = [
49905307 name = "sigil-protocol"49915308 version = "0.1.0"49925309 dependencies = [5310+ "base64 0.22.1",5311+ "ed25519-dalek",49935312 "serde",49945313 "serde_json",49955314 "thiserror 2.0.19",
@@ -5507,6 +5826,69 @@ dependencies = [
55075826 "walkdir",55085827 ]55095828 5829+[[package]]5830+name = "tauri-plugin-deep-link"5831+version = "2.4.9"5832+source = "registry+https://github.com/rust-lang/crates.io-index"5833+checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"5834+dependencies = [5835+ "dunce",5836+ "plist",5837+ "rust-ini",5838+ "serde",5839+ "serde_json",5840+ "tauri",5841+ "tauri-plugin",5842+ "tauri-utils",5843+ "thiserror 2.0.19",5844+ "tracing",5845+ "url",5846+ "windows-registry 0.5.3",5847+ "windows-result 0.3.4",5848+]5849+5850+[[package]]5851+name = "tauri-plugin-dialog"5852+version = "2.7.2"5853+source = "registry+https://github.com/rust-lang/crates.io-index"5854+checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"5855+dependencies = [5856+ "log",5857+ "raw-window-handle",5858+ "rfd",5859+ "serde",5860+ "serde_json",5861+ "tauri",5862+ "tauri-plugin",5863+ "tauri-plugin-fs",5864+ "thiserror 2.0.19",5865+ "url",5866+]5867+5868+[[package]]5869+name = "tauri-plugin-fs"5870+version = "2.5.1"5871+source = "registry+https://github.com/rust-lang/crates.io-index"5872+checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"5873+dependencies = [5874+ "anyhow",5875+ "dunce",5876+ "glob",5877+ "log",5878+ "objc2-foundation",5879+ "percent-encoding",5880+ "schemars 0.8.22",5881+ "serde",5882+ "serde_json",5883+ "serde_repr",5884+ "tauri",5885+ "tauri-plugin",5886+ "tauri-utils",5887+ "thiserror 2.0.19",5888+ "toml 1.1.3+spec-1.1.0",5889+ "url",5890+]5891+55105892 [[package]]55115893 name = "tauri-plugin-log"55125894 version = "2.9.0"
@@ -5528,6 +5910,23 @@ dependencies = [
55285910 "time",55295911 ]55305912 5913+[[package]]5914+name = "tauri-plugin-single-instance"5915+version = "2.4.3"5916+source = "registry+https://github.com/rust-lang/crates.io-index"5917+checksum = "b3214becf9ef5783c0ae99a3bb25adf5353a7a16ebf53e74b909e29205735c6c"5918+dependencies = [5919+ "serde",5920+ "serde_json",5921+ "tauri",5922+ "tauri-plugin-deep-link",5923+ "thiserror 2.0.19",5924+ "tokio",5925+ "tracing",5926+ "windows-sys 0.60.2",5927+ "zbus",5928+]5929+55315930 [[package]]55325931 name = "tauri-runtime"55335932 version = "2.11.3"
@@ -5732,6 +6131,15 @@ dependencies = [
57326131 "time-core",57336132 ]57346133 6134+[[package]]6135+name = "tiny-keccak"6136+version = "2.0.2"6137+source = "registry+https://github.com/rust-lang/crates.io-index"6138+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"6139+dependencies = [6140+ "crunchy",6141+]6142+57356143 [[package]]57366144 name = "tinystr"57376145 version = "0.8.3"
@@ -6110,6 +6518,17 @@ version = "1.20.1"
61106518 source = "registry+https://github.com/rust-lang/crates.io-index"61116519 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"61126520 6521+[[package]]6522+name = "uds_windows"6523+version = "1.2.1"6524+source = "registry+https://github.com/rust-lang/crates.io-index"6525+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"6526+dependencies = [6527+ "memoffset",6528+ "tempfile",6529+ "windows-sys 0.61.2",6530+]6531+61136532 [[package]]61146533 name = "unic-char-property"61156534 version = "0.9.0"
@@ -6717,6 +7136,17 @@ dependencies = [
67177136 "windows-link 0.2.1",67187137 ]67197138 7139+[[package]]7140+name = "windows-registry"7141+version = "0.5.3"7142+source = "registry+https://github.com/rust-lang/crates.io-index"7143+checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"7144+dependencies = [7145+ "windows-link 0.1.3",7146+ "windows-result 0.3.4",7147+ "windows-strings 0.4.2",7148+]7149+67207150 [[package]]67217151 name = "windows-registry"67227152 version = "0.6.1"
@@ -6791,6 +7221,15 @@ dependencies = [
67917221 "windows-targets 0.52.6",67927222 ]67937223 7224+[[package]]7225+name = "windows-sys"7226+version = "0.60.2"7227+source = "registry+https://github.com/rust-lang/crates.io-index"7228+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"7229+dependencies = [7230+ "windows-targets 0.53.5",7231+]7232+67947233 [[package]]67957234 name = "windows-sys"67967235 version = "0.61.2"
@@ -6824,13 +7263,30 @@ dependencies = [
68247263 "windows_aarch64_gnullvm 0.52.6",68257264 "windows_aarch64_msvc 0.52.6",68267265 "windows_i686_gnu 0.52.6",6827- "windows_i686_gnullvm",7266+ "windows_i686_gnullvm 0.52.6",68287267 "windows_i686_msvc 0.52.6",68297268 "windows_x86_64_gnu 0.52.6",68307269 "windows_x86_64_gnullvm 0.52.6",68317270 "windows_x86_64_msvc 0.52.6",68327271 ]68337272 7273+[[package]]7274+name = "windows-targets"7275+version = "0.53.5"7276+source = "registry+https://github.com/rust-lang/crates.io-index"7277+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"7278+dependencies = [7279+ "windows-link 0.2.1",7280+ "windows_aarch64_gnullvm 0.53.1",7281+ "windows_aarch64_msvc 0.53.1",7282+ "windows_i686_gnu 0.53.1",7283+ "windows_i686_gnullvm 0.53.1",7284+ "windows_i686_msvc 0.53.1",7285+ "windows_x86_64_gnu 0.53.1",7286+ "windows_x86_64_gnullvm 0.53.1",7287+ "windows_x86_64_msvc 0.53.1",7288+]7289+68347290 [[package]]68357291 name = "windows-threading"68367292 version = "0.1.0"
@@ -6870,6 +7326,12 @@ version = "0.52.6"
68707326 source = "registry+https://github.com/rust-lang/crates.io-index"68717327 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"68727328 7329+[[package]]7330+name = "windows_aarch64_gnullvm"7331+version = "0.53.1"7332+source = "registry+https://github.com/rust-lang/crates.io-index"7333+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"7334+68737335 [[package]]68747336 name = "windows_aarch64_msvc"68757337 version = "0.42.2"
@@ -6882,6 +7344,12 @@ version = "0.52.6"
68827344 source = "registry+https://github.com/rust-lang/crates.io-index"68837345 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"68847346 7347+[[package]]7348+name = "windows_aarch64_msvc"7349+version = "0.53.1"7350+source = "registry+https://github.com/rust-lang/crates.io-index"7351+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"7352+68857353 [[package]]68867354 name = "windows_i686_gnu"68877355 version = "0.42.2"
@@ -6894,12 +7362,24 @@ version = "0.52.6"
68947362 source = "registry+https://github.com/rust-lang/crates.io-index"68957363 checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"68967364 7365+[[package]]7366+name = "windows_i686_gnu"7367+version = "0.53.1"7368+source = "registry+https://github.com/rust-lang/crates.io-index"7369+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"7370+68977371 [[package]]68987372 name = "windows_i686_gnullvm"68997373 version = "0.52.6"69007374 source = "registry+https://github.com/rust-lang/crates.io-index"69017375 checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"69027376 7377+[[package]]7378+name = "windows_i686_gnullvm"7379+version = "0.53.1"7380+source = "registry+https://github.com/rust-lang/crates.io-index"7381+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"7382+69037383 [[package]]69047384 name = "windows_i686_msvc"69057385 version = "0.42.2"
@@ -6912,6 +7392,12 @@ version = "0.52.6"
69127392 source = "registry+https://github.com/rust-lang/crates.io-index"69137393 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"69147394 7395+[[package]]7396+name = "windows_i686_msvc"7397+version = "0.53.1"7398+source = "registry+https://github.com/rust-lang/crates.io-index"7399+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"7400+69157401 [[package]]69167402 name = "windows_x86_64_gnu"69177403 version = "0.42.2"
@@ -6924,6 +7410,12 @@ version = "0.52.6"
69247410 source = "registry+https://github.com/rust-lang/crates.io-index"69257411 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"69267412 7413+[[package]]7414+name = "windows_x86_64_gnu"7415+version = "0.53.1"7416+source = "registry+https://github.com/rust-lang/crates.io-index"7417+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"7418+69277419 [[package]]69287420 name = "windows_x86_64_gnullvm"69297421 version = "0.42.2"
@@ -6936,6 +7428,12 @@ version = "0.52.6"
69367428 source = "registry+https://github.com/rust-lang/crates.io-index"69377429 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"69387430 7431+[[package]]7432+name = "windows_x86_64_gnullvm"7433+version = "0.53.1"7434+source = "registry+https://github.com/rust-lang/crates.io-index"7435+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"7436+69397437 [[package]]69407438 name = "windows_x86_64_msvc"69417439 version = "0.42.2"
@@ -6948,6 +7446,12 @@ version = "0.52.6"
69487446 source = "registry+https://github.com/rust-lang/crates.io-index"69497447 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"69507448 7449+[[package]]7450+name = "windows_x86_64_msvc"7451+version = "0.53.1"7452+source = "registry+https://github.com/rust-lang/crates.io-index"7453+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"7454+69517455 [[package]]69527456 name = "winnow"69537457 version = "0.5.40"
@@ -7165,6 +7669,67 @@ dependencies = [
71657669 "synstructure",71667670 ]71677671 7672+[[package]]7673+name = "zbus"7674+version = "5.18.0"7675+source = "registry+https://github.com/rust-lang/crates.io-index"7676+checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"7677+dependencies = [7678+ "async-broadcast",7679+ "async-executor",7680+ "async-io",7681+ "async-lock",7682+ "async-process",7683+ "async-recursion",7684+ "async-task",7685+ "async-trait",7686+ "blocking",7687+ "enumflags2",7688+ "event-listener",7689+ "futures-core",7690+ "futures-lite",7691+ "hex",7692+ "libc",7693+ "ordered-stream",7694+ "rustix",7695+ "serde",7696+ "serde_repr",7697+ "tracing",7698+ "uds_windows",7699+ "uuid",7700+ "windows-sys 0.61.2",7701+ "winnow 1.0.4",7702+ "zbus_macros",7703+ "zbus_names",7704+ "zvariant",7705+]7706+7707+[[package]]7708+name = "zbus_macros"7709+version = "5.18.0"7710+source = "registry+https://github.com/rust-lang/crates.io-index"7711+checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"7712+dependencies = [7713+ "proc-macro-crate 3.5.0",7714+ "proc-macro2",7715+ "quote",7716+ "syn 2.0.119",7717+ "zbus_names",7718+ "zvariant",7719+ "zvariant_utils",7720+]7721+7722+[[package]]7723+name = "zbus_names"7724+version = "4.3.4"7725+source = "registry+https://github.com/rust-lang/crates.io-index"7726+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"7727+dependencies = [7728+ "serde",7729+ "winnow 1.0.4",7730+ "zvariant",7731+]7732+71687733 [[package]]71697734 name = "zerocopy"71707735 version = "0.8.55"
@@ -7279,3 +7844,43 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
72797844 dependencies = [72807845 "zune-core",72817846 ]7847+7848+[[package]]7849+name = "zvariant"7850+version = "5.13.1"7851+source = "registry+https://github.com/rust-lang/crates.io-index"7852+checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"7853+dependencies = [7854+ "endi",7855+ "enumflags2",7856+ "serde",7857+ "winnow 1.0.4",7858+ "zvariant_derive",7859+ "zvariant_utils",7860+]7861+7862+[[package]]7863+name = "zvariant_derive"7864+version = "5.13.1"7865+source = "registry+https://github.com/rust-lang/crates.io-index"7866+checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"7867+dependencies = [7868+ "proc-macro-crate 3.5.0",7869+ "proc-macro2",7870+ "quote",7871+ "syn 2.0.119",7872+ "zvariant_utils",7873+]7874+7875+[[package]]7876+name = "zvariant_utils"7877+version = "3.5.0"7878+source = "registry+https://github.com/rust-lang/crates.io-index"7879+checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"7880+dependencies = [7881+ "proc-macro2",7882+ "quote",7883+ "serde",7884+ "syn 2.0.119",7885+ "winnow 1.0.4",7886+]
M README.md +54 -16
@@ -50,9 +50,11 @@ Steam and games.
5050 ownership/mode/ACL preflight and neutralization when a session ends.5151 - The installed Portal application is client-only: it contains no host daemon,5252 host registration, capture, encoder, or desktop input-injection path.53-- FIDO2 `hmac-secret` identity derivation remains in the normal client flow.54- Debug builds have an explicit, visibly labeled direct-node bypass for test55- hosts; release builds reject it.53+- FIDO2 `hmac-secret` derives Portal's stable Iroh peer identity. A signed,54+ short-lived Sigil invitation enrolls that peer once with exact view,55+ pointer/keyboard, and gamepad grants; ordinary reconnects remain56+ **PIN -> tap -> play**. Debug builds retain a visibly labeled routing bypass,57+ while release builds reject it.5658 - Controller-first client navigation includes a D-pad PIN pad, negotiated5759 latest-state gamepad routing, and a one-second Back+Start escape chord.5860 - The Bazzite host has an allowlisted deterministic runtime package with
@@ -65,8 +67,7 @@ keyframes, dependent-object cancellation, and discontinuity recovery; full MoQ
6567 group/control semantics remain promoted hardening work. A dedicated persistent6668 PipeWire sink, bounded Opus datagrams, WebCodecs decode, and AudioWorklet6769 playback are implemented; longer-run audio/video synchronization measurement68-remains. Client authorization still needs short-lived capability enrollment;69-the debug direct-node bypass is routing, not authentication.70+remains. The debug direct-node bypass is routing, not authentication.7071 7172 ## Immediate milestones7273
@@ -88,7 +89,8 @@ the debug direct-node bypass is routing, not authentication.
8889 relative mouse replaces Gamescope-incompatible absolute motion.8990 7. **Live-proven:** bounded PipeWire audio capture, a persistent headless sink,9091 Opus delivery, and client playback. Quantify longer-run A/V synchronization.91-8. Replace or supplement FIDO pairing with short-lived capability tickets.92+8. **Done:** add signed, peer-bound, one-time capability enrollment with93+ durable replay protection and controller-usable Portal onboarding.9294 9395 ## Development9496
@@ -98,6 +100,31 @@ Requirements:
98100 - Tauri v2 system dependencies99101 - A FIDO2 key with `hmac-secret` support for the normal identity flow100102 103+### Enroll one Portal104+105+On first launch, enter the security-key PIN and choose **show portal id**. On106+the Sigil host, create an invitation for that exact peer:107+108+```bash109+sigil invitation create \110+ --config ~/.config/sigil-spark/host.toml \111+ --peer PORTAL_PEER_ID \112+ --pointer-keyboard \113+ --gamepad \114+ --output ~/portal.goq-invite115+```116+117+Move the owner-only invitation file to the client, open it with Portal, and118+confirm the displayed host, peer, expiry, and grants. Sigil consumes it once;119+future launches are simply **PIN -> tap -> play**. `--print-deep-link` is120+available for an explicit `goq://invite/...` handoff, but prints the short-lived121+credential to the terminal and should not be used in recorded shells.122+123+To move Portal to another Sigil, revoke the host enrollment first, disconnect124+Portal, then use **client -> reset enrollment** and confirm the displayed host.125+The reset is intentionally explicit and controller reachable; importing a new126+invitation never silently replaces a working enrollment.127+101128 Provision a dedicated AMD host with the102129 [fresh Bazzite host runbook](docs/fresh-bazzite-host.md). The runbook also103130 defines the temporary `slate` stand-in used for protocol and daemon extraction.
@@ -133,16 +160,16 @@ activation when package-managed service, audio, rollback, or udev links follow
133160 active release always includes matching assets and rollback metadata.134161 135162 Create the Bazzite runtime package from a committed source revision. Product136-mode exports clean `HEAD` and builds the host and probe itself with locked137-`cargo-zigbuild` in an isolated target directory; it never accepts externally138-supplied binaries. Product packages require a clean worktree and detached139-Minisign signature; the explicit development flags are only for temporary140-testing:163+mode exports clean tagged `HEAD` and builds the host and probe itself with164+locked `cargo-zigbuild` in an isolated target directory; it never accepts165+externally supplied binaries. The builder emits an unsigned candidate for the166+separate offline signing ceremony; the explicit development flags are only for167+temporary testing:141168 142169 ```bash143170 scripts/package-bazzite-release.sh \144- --output /tmp/sigil-spark-host.tar.gz \145- --minisign-key /absolute/path/to/release.key171+ --release-tag v0.1.0 \172+ --output /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz146173 147174 # Temporary development package only:148175 scripts/package-bazzite-release.sh \
@@ -157,6 +184,10 @@ scripts/package-bazzite-release.sh \
157184 --probe-binary /absolute/path/to/sigil-probe158185 ```159186 187+Verify the unsigned candidate, then follow the offline signing and protected188+draft-promotion ceremony in [public release delivery](docs/public-release-delivery.md).189+The GitHub workflow and package builder never receive the Minisign secret.190+160191 The package contains the primary `sigil` host executable, its byte-identical161192 `sigil-host` compatibility copy, the `sigil-probe` diagnostic, installer and162193 rollback tools, service/audio/udev assets, license, checksums, and build
@@ -181,14 +212,21 @@ service interruption is intended.
181212 The macOS Portal build currently produces an arm64 DMG for development. A public182213 Portal release additionally requires Developer ID signing with hardened183214 runtime, notarization, stapling, and strict Gatekeeper verification; ad-hoc184-development signatures are not a distributable package. With Apple credentials185-configured as described by the official215+development signatures are not a distributable package. Before tagging, the216+public TeamIdentifier in `release/portal-apple-team-id.txt` must match the217+protected certificate, and the tag-ref workflow attests every published Portal218+asset. With Apple credentials configured as described by the official186219 [Tauri macOS signing guide](https://v2.tauri.app/distribute/sign/macos/), run:187220 188221 ```bash189-scripts/package-macos-client.sh --output-dir /absolute/release/directory222+scripts/package-macos-client.sh \223+ --release-tag v0.1.0 \224+ --output-dir /absolute/release/directory190225 ```191226 227+The repository publication and website-manifest procedure is documented in the228+[Portal release runbook](docs/portal-release.md).229+192230 Run Portal against Sigil during development:193231 194232 ```bash
A crates/sigil-host/src/authorization.rs +668 -0
@@ -0,0 +1,668 @@
1+use std::fs::{self, File, OpenOptions};2+use std::io::{Read, Write};3+use std::path::{Path, PathBuf};4+use std::time::{SystemTime, UNIX_EPOCH};5+6+use anyhow::{Context, Result, ensure};7+use iroh::EndpointId;8+use serde::{Deserialize, Serialize};9+use sigil_protocol::{10+ INVITATION_CLOCK_SKEW_SECS, InvitationClaims, InvitationGrants, MAX_INVITATION_TTL_SECS,11+ SignedInvitation,12+};13+14+#[cfg(unix)]15+use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};16+17+const STATE_VERSION: u16 = 1;18+const STATE_FILE_NAME: &str = "authorization-v1.json";19+const LOCK_FILE_NAME: &str = "authorization-v1.lock";20+const MAX_STATE_FILE_LEN: u64 = 64 * 1024;21+const MAX_CONSUMED_INVITATIONS: usize = 64;22+23+#[derive(Clone, Debug)]24+pub struct AuthorizationStore {25+ state_directory: PathBuf,26+ host: EndpointId,27+}28+29+#[derive(Clone, Debug)]30+pub enum AuthorizationPolicy {31+ Required(AuthorizationStore),32+ /// Explicit direct `sigil serve --identity ... --source test-pattern`33+ /// proof mode. Configured hosts never select this branch.34+ TestPatternProof,35+}36+37+#[derive(Clone, Copy, Debug, PartialEq, Eq)]38+pub struct AuthorizationSnapshot {39+ pub epoch: u64,40+ pub peer: Option<EndpointId>,41+ pub grants: Option<InvitationGrants>,42+}43+44+#[derive(Debug, Serialize, Deserialize)]45+#[serde(deny_unknown_fields)]46+struct AuthorizationState {47+ version: u16,48+ host_node_id: String,49+ enrollment_epoch: u64,50+ enrollment: Option<Enrollment>,51+ consumed_invitations: Vec<ConsumedInvitation>,52+}53+54+#[derive(Debug, Serialize, Deserialize)]55+#[serde(deny_unknown_fields)]56+struct Enrollment {57+ peer_node_id: String,58+ grants: InvitationGrants,59+ enrolled_at_unix: u64,60+}61+62+#[derive(Debug, Serialize, Deserialize)]63+#[serde(deny_unknown_fields)]64+struct ConsumedInvitation {65+ nonce: [u8; 32],66+ expires_at_unix: u64,67+}68+69+impl AuthorizationState {70+ fn empty(host: EndpointId) -> Self {71+ Self {72+ version: STATE_VERSION,73+ host_node_id: host.to_string(),74+ enrollment_epoch: 1,75+ enrollment: None,76+ consumed_invitations: Vec::new(),77+ }78+ }79+80+ fn validate(&self, expected_host: EndpointId) -> Result<()> {81+ ensure!(82+ self.version == STATE_VERSION,83+ "unsupported authorization state version"84+ );85+ ensure!(86+ self.host_node_id.parse::<EndpointId>().ok() == Some(expected_host),87+ "authorization state belongs to a different Sigil host"88+ );89+ ensure!(90+ self.enrollment_epoch != 0,91+ "authorization epoch must be non-zero"92+ );93+ ensure!(94+ self.consumed_invitations.len() <= MAX_CONSUMED_INVITATIONS,95+ "authorization replay ledger exceeds its fixed bound"96+ );97+ if let Some(enrollment) = &self.enrollment {98+ enrollment99+ .peer_node_id100+ .parse::<EndpointId>()101+ .context("authorization enrollment contains an invalid peer node ID")?;102+ enrollment.grants.validate()?;103+ ensure!(104+ enrollment.grants.contains(InvitationGrants::VIEW),105+ "authorization enrollment must include view permission"106+ );107+ }108+ for (index, consumed) in self.consumed_invitations.iter().enumerate() {109+ ensure!(110+ consumed.nonce != [0; 32],111+ "consumed invitation nonce is zero"112+ );113+ ensure!(114+ !self.consumed_invitations[..index]115+ .iter()116+ .any(|earlier| earlier.nonce == consumed.nonce),117+ "authorization replay ledger contains a duplicate nonce"118+ );119+ }120+ Ok(())121+ }122+123+ fn prune_expired(&mut self, now: u64) -> bool {124+ let before = self.consumed_invitations.len();125+ self.consumed_invitations.retain(|consumed| {126+ consumed127+ .expires_at_unix128+ .saturating_add(INVITATION_CLOCK_SKEW_SECS)129+ >= now130+ });131+ before != self.consumed_invitations.len()132+ }133+}134+135+impl AuthorizationStore {136+ pub fn open(state_directory: impl Into<PathBuf>, host: EndpointId) -> Result<Self> {137+ let store = Self {138+ state_directory: state_directory.into(),139+ host,140+ };141+ store.ensure_state_directory()?;142+ let lock = store.open_lock()?;143+ store.lock_exclusive(&lock)?;144+ let state = store.load_state()?;145+ state.validate(host)?;146+ Ok(store)147+ }148+149+ pub fn issue_claims(150+ &self,151+ peer: EndpointId,152+ grants: InvitationGrants,153+ ttl_seconds: u64,154+ now: u64,155+ nonce: [u8; 32],156+ ) -> Result<InvitationClaims> {157+ grants.validate()?;158+ ensure!(159+ grants.contains(InvitationGrants::VIEW),160+ "invitations must grant view permission"161+ );162+ ensure!(163+ (1..=MAX_INVITATION_TTL_SECS).contains(&ttl_seconds),164+ "invitation TTL must be between 1 and {MAX_INVITATION_TTL_SECS} seconds"165+ );166+ let lock = self.open_lock()?;167+ self.lock_exclusive(&lock)?;168+ let state = self.load_state()?;169+ state.validate(self.host)?;170+ ensure!(171+ state.enrollment.is_none(),172+ "a Portal peer is already enrolled; revoke it before issuing another invitation"173+ );174+ InvitationClaims::new(175+ *self.host.as_bytes(),176+ *peer.as_bytes(),177+ now,178+ now.checked_add(ttl_seconds)179+ .context("invitation expiry overflow")?,180+ state.enrollment_epoch,181+ nonce,182+ grants,183+ )184+ .map_err(Into::into)185+ }186+187+ pub fn authorize_or_redeem(188+ &self,189+ remote: EndpointId,190+ invitation_token: Option<&str>,191+ now: u64,192+ ) -> Result<InvitationGrants> {193+ let lock = self.open_lock()?;194+ self.lock_exclusive(&lock)?;195+ let mut state = self.load_state()?;196+ state.validate(self.host)?;197+ let mut changed = state.prune_expired(now);198+199+ let result = match invitation_token {200+ None => {201+ let enrollment = state202+ .enrollment203+ .as_ref()204+ .context("Portal peer is not enrolled")?;205+ ensure!(206+ enrollment.peer_node_id.parse::<EndpointId>()? == remote,207+ "Portal peer is not enrolled"208+ );209+ enrollment.grants210+ }211+ Some(token) => {212+ ensure!(213+ state.enrollment.is_none(),214+ "an invitation cannot be used after enrollment"215+ );216+ let invitation = SignedInvitation::decode(token)217+ .context("decoding and verifying enrollment invitation")?;218+ let claims = invitation.claims;219+ ensure!(220+ claims.host_node_id == *self.host.as_bytes(),221+ "invitation belongs to a different Sigil host"222+ );223+ ensure!(224+ claims.intended_peer_id == *remote.as_bytes(),225+ "invitation is bound to a different Portal peer"226+ );227+ ensure!(228+ claims.enrollment_epoch == state.enrollment_epoch,229+ "invitation enrollment epoch is stale"230+ );231+ ensure!(232+ claims.issued_at_unix <= now.saturating_add(INVITATION_CLOCK_SKEW_SECS),233+ "invitation was issued too far in the future"234+ );235+ ensure!(now <= claims.expires_at_unix, "invitation has expired");236+ ensure!(237+ claims.grants.contains(InvitationGrants::VIEW),238+ "invitation does not grant view permission"239+ );240+ ensure!(241+ !state242+ .consumed_invitations243+ .iter()244+ .any(|consumed| consumed.nonce == claims.nonce),245+ "invitation has already been consumed"246+ );247+ ensure!(248+ state.consumed_invitations.len() < MAX_CONSUMED_INVITATIONS,249+ "authorization replay ledger is full"250+ );251+ state.enrollment = Some(Enrollment {252+ peer_node_id: remote.to_string(),253+ grants: claims.grants,254+ enrolled_at_unix: now,255+ });256+ state.consumed_invitations.push(ConsumedInvitation {257+ nonce: claims.nonce,258+ expires_at_unix: claims.expires_at_unix,259+ });260+ changed = true;261+ claims.grants262+ }263+ };264+265+ if changed {266+ self.write_state(&state)?;267+ }268+ Ok(result)269+ }270+271+ pub fn snapshot(&self) -> Result<AuthorizationSnapshot> {272+ let lock = self.open_lock()?;273+ self.lock_exclusive(&lock)?;274+ let state = self.load_state()?;275+ state.validate(self.host)?;276+ let (peer, grants) = match state.enrollment {277+ Some(enrollment) => (278+ Some(enrollment.peer_node_id.parse::<EndpointId>()?),279+ Some(enrollment.grants),280+ ),281+ None => (None, None),282+ };283+ Ok(AuthorizationSnapshot {284+ epoch: state.enrollment_epoch,285+ peer,286+ grants,287+ })288+ }289+290+ pub fn revoke(&self, now: u64) -> Result<bool> {291+ let lock = self.open_lock()?;292+ self.lock_exclusive(&lock)?;293+ let mut state = self.load_state()?;294+ state.validate(self.host)?;295+ let had_enrollment = state.enrollment.take().is_some();296+ state.enrollment_epoch = state297+ .enrollment_epoch298+ .checked_add(1)299+ .context("authorization epoch exhausted")?;300+ state.prune_expired(now);301+ self.write_state(&state)?;302+ Ok(had_enrollment)303+ }304+305+ fn state_path(&self) -> PathBuf {306+ self.state_directory.join(STATE_FILE_NAME)307+ }308+309+ fn lock_path(&self) -> PathBuf {310+ self.state_directory.join(LOCK_FILE_NAME)311+ }312+313+ fn ensure_state_directory(&self) -> Result<()> {314+ if self.state_directory.exists() {315+ let metadata = fs::symlink_metadata(&self.state_directory)?;316+ ensure!(317+ !metadata.file_type().is_symlink(),318+ "authorization state directory is a symlink"319+ );320+ ensure!(321+ metadata.is_dir(),322+ "authorization state path is not a directory"323+ );324+ #[cfg(unix)]325+ {326+ ensure!(327+ metadata.mode() & 0o077 == 0,328+ "authorization state directory is accessible by group or others"329+ );330+ ensure!(331+ metadata.uid() == unsafe { libc::geteuid() },332+ "authorization state directory has the wrong owner"333+ );334+ }335+ } else {336+ #[cfg(unix)]337+ {338+ let mut builder = fs::DirBuilder::new();339+ builder340+ .recursive(true)341+ .mode(0o700)342+ .create(&self.state_directory)?;343+ }344+ #[cfg(not(unix))]345+ fs::create_dir_all(&self.state_directory)?;346+ }347+ Ok(())348+ }349+350+ fn open_lock(&self) -> Result<File> {351+ let mut options = OpenOptions::new();352+ options.read(true).write(true).create(true);353+ #[cfg(unix)]354+ options355+ .mode(0o600)356+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);357+ let file = options358+ .open(self.lock_path())359+ .context("opening authorization lock")?;360+ self.validate_secure_file(&file, &self.lock_path())?;361+ Ok(file)362+ }363+364+ fn lock_exclusive(&self, file: &File) -> Result<()> {365+ #[cfg(unix)]366+ {367+ use std::os::fd::AsRawFd;368+ let status = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };369+ ensure!(370+ status == 0,371+ "locking authorization state failed: {}",372+ std::io::Error::last_os_error()373+ );374+ }375+ Ok(())376+ }377+378+ fn load_state(&self) -> Result<AuthorizationState> {379+ let path = self.state_path();380+ let mut options = OpenOptions::new();381+ options.read(true);382+ #[cfg(unix)]383+ options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);384+ let mut file = match options.open(&path) {385+ Ok(file) => file,386+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {387+ return Ok(AuthorizationState::empty(self.host));388+ }389+ Err(error) => return Err(error).context("opening authorization state"),390+ };391+ self.validate_secure_file(&file, &path)?;392+ let metadata = file.metadata()?;393+ ensure!(394+ metadata.len() <= MAX_STATE_FILE_LEN,395+ "authorization state exceeds its fixed size bound"396+ );397+ let mut bytes = Vec::with_capacity(metadata.len() as usize);398+ file.read_to_end(&mut bytes)?;399+ ensure!(!bytes.is_empty(), "authorization state is empty");400+ serde_json::from_slice(&bytes).context("parsing authorization state")401+ }402+403+ fn write_state(&self, state: &AuthorizationState) -> Result<()> {404+ state.validate(self.host)?;405+ let bytes = serde_json::to_vec_pretty(state)?;406+ ensure!(407+ bytes.len() as u64 <= MAX_STATE_FILE_LEN,408+ "authorization state exceeds its fixed size bound"409+ );410+ let mut random = [0_u8; 8];411+ getrandom::fill(&mut random).context("generating authorization temporary-file name")?;412+ let temporary = self.state_directory.join(format!(413+ ".{STATE_FILE_NAME}.{:016x}.tmp",414+ u64::from_be_bytes(random)415+ ));416+ let mut options = OpenOptions::new();417+ options.write(true).create_new(true);418+ #[cfg(unix)]419+ options420+ .mode(0o600)421+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);422+ let mut file = options423+ .open(&temporary)424+ .context("creating authorization state temporary file")?;425+ let write_result = (|| -> Result<()> {426+ file.write_all(&bytes)?;427+ file.write_all(b"\n")?;428+ file.sync_all()?;429+ fs::rename(&temporary, self.state_path())?;430+ File::open(&self.state_directory)?.sync_all()?;431+ Ok(())432+ })();433+ if write_result.is_err() {434+ let _ = fs::remove_file(&temporary);435+ }436+ write_result437+ }438+439+ fn validate_secure_file(&self, file: &File, path: &Path) -> Result<()> {440+ let metadata = file.metadata()?;441+ ensure!(442+ metadata.is_file(),443+ "{} is not a regular file",444+ path.display()445+ );446+ #[cfg(unix)]447+ {448+ ensure!(449+ metadata.mode() & 0o077 == 0,450+ "{} is accessible by group or others",451+ path.display()452+ );453+ ensure!(454+ metadata.uid() == unsafe { libc::geteuid() },455+ "{} has the wrong owner",456+ path.display()457+ );458+ }459+ Ok(())460+ }461+}462+463+impl AuthorizationPolicy {464+ pub fn authorize_or_redeem(465+ &self,466+ remote: EndpointId,467+ invitation_token: Option<&str>,468+ now: u64,469+ ) -> Result<InvitationGrants> {470+ match self {471+ Self::Required(store) => store.authorize_or_redeem(remote, invitation_token, now),472+ Self::TestPatternProof => {473+ ensure!(474+ invitation_token.is_none(),475+ "proof mode does not accept invitations"476+ );477+ Ok(InvitationGrants::ALL)478+ }479+ }480+ }481+}482+483+pub fn unix_timestamp_now() -> Result<u64> {484+ Ok(SystemTime::now()485+ .duration_since(UNIX_EPOCH)486+ .context("system clock is before the Unix epoch")?487+ .as_secs())488+}489+490+pub fn grant_names(grants: InvitationGrants) -> &'static str {491+ match (492+ grants.contains(InvitationGrants::POINTER_KEYBOARD),493+ grants.contains(InvitationGrants::GAMEPAD),494+ ) {495+ (false, false) => "view",496+ (true, false) => "view,pointer-keyboard",497+ (false, true) => "view,gamepad",498+ (true, true) => "view,pointer-keyboard,gamepad",499+ }500+}501+502+#[cfg(test)]503+mod tests {504+ use super::*;505+ use iroh::SecretKey;506+ use sigil_protocol::SignedInvitation;507+ #[cfg(unix)]508+ use std::os::unix::fs::PermissionsExt;509+510+ fn store() -> (tempfile::TempDir, SecretKey, SecretKey, AuthorizationStore) {511+ let directory = tempfile::tempdir().unwrap();512+ #[cfg(unix)]513+ fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)).unwrap();514+ let host = SecretKey::from_bytes(&[7; 32]);515+ let peer = SecretKey::from_bytes(&[9; 32]);516+ let store = AuthorizationStore::open(directory.path(), host.public()).unwrap();517+ (directory, host, peer, store)518+ }519+520+ fn token(521+ store: &AuthorizationStore,522+ host: &SecretKey,523+ peer: EndpointId,524+ now: u64,525+ nonce: [u8; 32],526+ grants: InvitationGrants,527+ ) -> String {528+ let claims = store.issue_claims(peer, grants, 600, now, nonce).unwrap();529+ SignedInvitation::issue(claims, &host.to_bytes())530+ .unwrap()531+ .encode()532+ }533+534+ #[test]535+ fn invitation_enrolls_once_and_reconnects_ticket_free() {536+ let (_directory, host, peer, store) = store();537+ let grants = InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD);538+ let token = token(&store, &host, peer.public(), 1_000, [1; 32], grants);539+ assert_eq!(540+ store541+ .authorize_or_redeem(peer.public(), Some(&token), 1_001)542+ .unwrap(),543+ grants544+ );545+ assert_eq!(546+ store547+ .authorize_or_redeem(peer.public(), None, 1_002)548+ .unwrap(),549+ grants550+ );551+ assert!(552+ store553+ .authorize_or_redeem(peer.public(), Some(&token), 1_002)554+ .unwrap_err()555+ .to_string()556+ .contains("after enrollment")557+ );558+559+ let reopened =560+ AuthorizationStore::open(store.state_directory.clone(), host.public()).unwrap();561+ assert_eq!(562+ reopened563+ .authorize_or_redeem(peer.public(), None, 1_003)564+ .unwrap(),565+ grants566+ );567+ }568+569+ #[test]570+ fn peer_host_expiry_and_future_time_fail_closed() {571+ let (_directory, host, peer, store) = store();572+ let other_peer = SecretKey::from_bytes(&[10; 32]);573+ let token = token(574+ &store,575+ &host,576+ peer.public(),577+ 1_000,578+ [2; 32],579+ InvitationGrants::VIEW,580+ );581+ assert!(582+ store583+ .authorize_or_redeem(other_peer.public(), Some(&token), 1_001)584+ .is_err()585+ );586+ assert!(587+ store588+ .authorize_or_redeem(peer.public(), Some(&token), 1_601)589+ .is_err()590+ );591+ assert!(592+ store593+ .authorize_or_redeem(peer.public(), Some(&token), 900)594+ .is_err()595+ );596+597+ let other_host = SecretKey::from_bytes(&[11; 32]);598+ let claims = InvitationClaims::new(599+ *other_host.public().as_bytes(),600+ *peer.public().as_bytes(),601+ 1_000,602+ 1_600,603+ 1,604+ [3; 32],605+ InvitationGrants::VIEW,606+ )607+ .unwrap();608+ let cross_host = SignedInvitation::issue(claims, &other_host.to_bytes())609+ .unwrap()610+ .encode();611+ assert!(612+ store613+ .authorize_or_redeem(peer.public(), Some(&cross_host), 1_001)614+ .is_err()615+ );616+ }617+618+ #[test]619+ fn revoke_invalidates_outstanding_epoch_and_clears_peer() {620+ let (_directory, host, peer, store) = store();621+ let token = token(622+ &store,623+ &host,624+ peer.public(),625+ 1_000,626+ [4; 32],627+ InvitationGrants::VIEW,628+ );629+ assert!(!store.revoke(1_001).unwrap());630+ assert!(631+ store632+ .authorize_or_redeem(peer.public(), Some(&token), 1_002)633+ .unwrap_err()634+ .to_string()635+ .contains("epoch")636+ );637+ assert_eq!(store.snapshot().unwrap().epoch, 2);638+ assert!(store.snapshot().unwrap().peer.is_none());639+ }640+641+ #[test]642+ fn copied_state_is_bound_to_host_and_permissions_are_strict() {643+ let (_directory, host, peer, store) = store();644+ let token = token(645+ &store,646+ &host,647+ peer.public(),648+ 1_000,649+ [5; 32],650+ InvitationGrants::VIEW.union(InvitationGrants::POINTER_KEYBOARD),651+ );652+ store653+ .authorize_or_redeem(peer.public(), Some(&token), 1_001)654+ .unwrap();655+ let other_host = SecretKey::from_bytes(&[12; 32]);656+ assert!(657+ AuthorizationStore::open(store.state_directory.clone(), other_host.public()).is_err()658+ );659+ let snapshot = store.snapshot().unwrap();660+ assert!(661+ snapshot662+ .grants663+ .unwrap()664+ .contains(InvitationGrants::POINTER_KEYBOARD)665+ );666+ assert!(!snapshot.grants.unwrap().contains(InvitationGrants::GAMEPAD));667+ }668+}
M crates/sigil-host/src/main.rs +229 -2
@@ -1,4 +1,5 @@
11 mod audio;2+mod authorization;23 mod clock;34 mod config;45 mod cursor;
@@ -7,19 +8,30 @@ mod input;
78 mod server;89 mod source;910 11+use std::fs::OpenOptions;12+use std::io::Write as _;1013 use std::path::{Path, PathBuf};1114 use std::sync::Arc;1215 use std::time::{Duration, Instant};1316 17+#[cfg(unix)]18+use std::os::unix::fs::OpenOptionsExt;19+1420 use anyhow::{Context, Result, ensure};1521 use clap::{Args, Parser, Subcommand, ValueEnum};16-use iroh::Endpoint;1722 use iroh::endpoint::{QuicTransportConfig, presets};1823 use iroh::protocol::Router;19-use sigil_protocol::{AUDIO_ALPN_V1, INPUT_ALPN_V1, MEDIA_ALPN_V1, MEDIA_ALPN_V2};24+use iroh::{Endpoint, EndpointId};25+use sigil_protocol::{26+ AUDIO_ALPN_V1, INPUT_ALPN_V1, InvitationGrants, MAX_INVITATION_TTL_SECS, MEDIA_ALPN_V1,27+ MEDIA_ALPN_V2, SignedInvitation,28+};2029 use tracing::{info, warn};2130 use tracing_subscriber::EnvFilter;2231 32+use crate::authorization::{33+ AuthorizationPolicy, AuthorizationStore, grant_names, unix_timestamp_now,34+};2335 use crate::config::{HostConfig, InputMode, VideoSource};2436 use crate::cursor::PointerPositionTracker;2537 use crate::input::InputBackend;
@@ -52,6 +64,16 @@ enum Command {
5264 #[command(subcommand)]5365 command: CaptureCommand,5466 },67+ /// Create a short-lived, peer-bound Portal enrollment invitation.68+ Invitation {69+ #[command(subcommand)]70+ command: InvitationCommand,71+ },72+ /// Inspect or revoke the one enrolled Portal peer.73+ Enrollment {74+ #[command(subcommand)]75+ command: EnrollmentCommand,76+ },5577 /// Run the headless host daemon in the foreground.5678 Serve(ServeArgs),5779 }
@@ -85,6 +107,42 @@ enum CaptureCommand {
85107 Probe(CaptureProbeArgs),86108 }87109 110+#[derive(Debug, Subcommand)]111+enum InvitationCommand {112+ /// Create a signed invitation file using create-new semantics.113+ Create {114+ #[arg(long)]115+ config: PathBuf,116+ #[arg(long)]117+ peer: EndpointId,118+ #[arg(long, default_value_t = 600, value_parser = clap::value_parser!(u64).range(1..=MAX_INVITATION_TTL_SECS))]119+ expires_in_seconds: u64,120+ #[arg(long)]121+ pointer_keyboard: bool,122+ #[arg(long)]123+ gamepad: bool,124+ #[arg(long)]125+ output: PathBuf,126+ /// Also print the short-lived invitation as a goq:// deep link.127+ #[arg(long)]128+ print_deep_link: bool,129+ },130+}131+132+#[derive(Debug, Subcommand)]133+enum EnrollmentCommand {134+ /// Print the current enrollment without exposing secret material.135+ Show {136+ #[arg(long)]137+ config: PathBuf,138+ },139+ /// Remove the enrolled peer and invalidate every outstanding invitation.140+ Revoke {141+ #[arg(long)]142+ config: PathBuf,143+ },144+}145+88146 #[derive(Clone, Copy, Debug, ValueEnum)]89147 enum SourceArg {90148 TestPattern,
@@ -162,10 +220,105 @@ async fn main() -> Result<()> {
162220 Command::Identity { command } => identity_command(command),163221 Command::Config { command } => config_command(command).await,164222 Command::Capture { command } => capture_command(command).await,223+ Command::Invitation { command } => invitation_command(command),224+ Command::Enrollment { command } => enrollment_command(command),165225 Command::Serve(args) => serve_command(args).await,166226 }167227 }168228 229+fn authorization_store_from_config(230+ path: &Path,231+) -> Result<(HostConfig, iroh::SecretKey, AuthorizationStore)> {232+ let config = HostConfig::load(path)?;233+ config.ensure_runtime_directory()?;234+ let secret = identity::load(&config.identity_path)?;235+ let store = AuthorizationStore::open(config.state_path.clone(), secret.public())?;236+ Ok((config, secret, store))237+}238+239+fn invitation_command(command: InvitationCommand) -> Result<()> {240+ match command {241+ InvitationCommand::Create {242+ config,243+ peer,244+ expires_in_seconds,245+ pointer_keyboard,246+ gamepad,247+ output,248+ print_deep_link,249+ } => {250+ let (_config, secret, store) = authorization_store_from_config(&config)?;251+ let mut grants = InvitationGrants::VIEW;252+ if pointer_keyboard {253+ grants = grants.union(InvitationGrants::POINTER_KEYBOARD);254+ }255+ if gamepad {256+ grants = grants.union(InvitationGrants::GAMEPAD);257+ }258+ let mut nonce = [0_u8; 32];259+ getrandom::fill(&mut nonce).context("generating invitation nonce")?;260+ let now = unix_timestamp_now()?;261+ let claims = store.issue_claims(peer, grants, expires_in_seconds, now, nonce)?;262+ let expires_at = claims.expires_at_unix;263+ let invitation = SignedInvitation::issue(claims, &secret.to_bytes())?;264+ let token = invitation.encode();265+ write_invitation_file(&output, &token)?;266+ println!("invitation={}", output.display());267+ println!("host_node_id={}", secret.public());268+ println!("peer_node_id={peer}");269+ println!("grants={}", grant_names(grants));270+ println!("expires_at_unix={expires_at}");271+ if print_deep_link {272+ println!("deep_link=goq://invite/{token}");273+ }274+ }275+ }276+ Ok(())277+}278+279+fn enrollment_command(command: EnrollmentCommand) -> Result<()> {280+ match command {281+ EnrollmentCommand::Show { config } => {282+ let (_config, _secret, store) = authorization_store_from_config(&config)?;283+ let snapshot = store.snapshot()?;284+ println!("enrollment_epoch={}", snapshot.epoch);285+ match (snapshot.peer, snapshot.grants) {286+ (Some(peer), Some(grants)) => {287+ println!("enrollment=active");288+ println!("peer_node_id={peer}");289+ println!("grants={}", grant_names(grants));290+ }291+ (None, None) => println!("enrollment=none"),292+ _ => unreachable!("validated authorization state is internally consistent"),293+ }294+ }295+ EnrollmentCommand::Revoke { config } => {296+ let (_config, _secret, store) = authorization_store_from_config(&config)?;297+ let revoked = store.revoke(unix_timestamp_now()?)?;298+ let snapshot = store.snapshot()?;299+ println!("revoked={revoked}");300+ println!("enrollment_epoch={}", snapshot.epoch);301+ }302+ }303+ Ok(())304+}305+306+fn write_invitation_file(path: &Path, token: &str) -> Result<()> {307+ let mut options = OpenOptions::new();308+ options.write(true).create_new(true);309+ #[cfg(unix)]310+ options311+ .mode(0o600)312+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);313+ let mut file = options314+ .open(path)315+ .with_context(|| format!("creating invitation {}", path.display()))?;316+ file.write_all(token.as_bytes())?;317+ file.write_all(b"\n")?;318+ file.sync_all()?;319+ Ok(())320+}321+169322 fn identity_command(command: IdentityCommand) -> Result<()> {170323 match command {171324 IdentityCommand::Init { output } => {
@@ -398,6 +551,14 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
398551 config.validate()?;399552 config.ensure_runtime_directory()?;400553 let secret = identity::load(&config.identity_path)?;554+ let authorization = if args.config.is_some() {555+ AuthorizationPolicy::Required(AuthorizationStore::open(556+ config.state_path.clone(),557+ secret.public(),558+ )?)559+ } else {560+ AuthorizationPolicy::TestPatternProof561+ };401562 let input_backend = InputBackend::initialize(&config)?;402563 403564 let pointer_surface_dimensions = if config.source == VideoSource::TestPattern {
@@ -481,6 +642,7 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
481642 MediaV2Handler {482643 config: config.clone(),483644 sessions: Arc::clone(&sessions),645+ authorization: authorization.clone(),484646 },485647 )486648 .accept(
@@ -488,6 +650,7 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
488650 MediaHandler {489651 config: config.clone(),490652 sessions: Arc::clone(&sessions),653+ authorization,491654 },492655 )493656 .accept(
@@ -598,6 +761,8 @@ fn parse_size(value: &str) -> std::result::Result<(u32, u32), String> {
598761 #[cfg(test)]599762 mod tests {600763 use super::*;764+ #[cfg(unix)]765+ use std::os::unix::fs::PermissionsExt;601766 602767 #[test]603768 fn parses_expected_size() {
@@ -621,6 +786,68 @@ mod tests {
621786 );622787 }623788 789+ #[test]790+ fn invitation_and_enrollment_commands_parse_strictly() {791+ let peer = iroh::SecretKey::from_bytes(&[19; 32]).public().to_string();792+ assert!(793+ Cli::try_parse_from([794+ "sigil",795+ "invitation",796+ "create",797+ "--config",798+ "/tmp/host.toml",799+ "--peer",800+ &peer,801+ "--pointer-keyboard",802+ "--gamepad",803+ "--print-deep-link",804+ "--output",805+ "/tmp/portal.goq-invite",806+ ])807+ .is_ok()808+ );809+ assert!(810+ Cli::try_parse_from([811+ "sigil",812+ "invitation",813+ "create",814+ "--config",815+ "/tmp/host.toml",816+ "--peer",817+ &peer,818+ "--expires-in-seconds",819+ "901",820+ "--output",821+ "/tmp/portal.goq-invite",822+ ])823+ .is_err()824+ );825+ assert!(826+ Cli::try_parse_from([827+ "sigil",828+ "enrollment",829+ "revoke",830+ "--config",831+ "/tmp/host.toml"832+ ])833+ .is_ok()834+ );835+ }836+837+ #[test]838+ fn invitation_file_uses_create_new_semantics() {839+ let directory = tempfile::tempdir().unwrap();840+ let path = directory.path().join("portal.goq-invite");841+ write_invitation_file(&path, "token").unwrap();842+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "token\n");843+ assert!(write_invitation_file(&path, "replacement").is_err());844+ #[cfg(unix)]845+ assert_eq!(846+ std::fs::metadata(path).unwrap().permissions().mode() & 0o777,847+ 0o600848+ );849+ }850+624851 #[test]625852 fn static_media_transport_liveness_is_bounded() {626853 assert_eq!(CONNECTION_IDLE_TIMEOUT, Duration::from_secs(5));
M crates/sigil-host/src/server.rs +229 -31
@@ -10,12 +10,14 @@ use iroh::endpoint::{Connection, SendStream};
1010 use iroh::protocol::ProtocolHandler;1111 use sigil_protocol::{1212 AUDIO_HEADER_LEN, AudioFlags, AudioPacket, AudioPacketHeader, Capability, ClientHello,13- FrameFlags, HostHello, InputAck, MAX_AUDIO_PAYLOAD_LEN, MediaFrame, MediaFrameHeader,14- read_client_hello, read_input_event, write_host_hello, write_input_ack, write_media_frame,13+ FrameFlags, HostHello, InputAck, InvitationGrants, MAX_AUDIO_PAYLOAD_LEN, MediaFrame,14+ MediaFrameHeader, read_client_hello, read_input_event, write_host_hello, write_input_ack,15+ write_media_frame,1516 };1617 use tracing::{debug, error, info, warn};1718 1819 use crate::audio::spawn_pipewire_audio;20+use crate::authorization::{AuthorizationPolicy, unix_timestamp_now};1921 use crate::clock::SessionClock;2022 use crate::config::{HostConfig, VideoSource};2123 use crate::cursor::{PointerPositionTracker, PointerState};
@@ -60,6 +62,7 @@ struct ActiveSession {
6062 session_id: u64,6163 nonce: [u8; 16],6264 session_clock: SessionClock,65+ grants: InvitationGrants,6366 media_active: bool,6467 input_claimed: bool,6568 audio_claimed: bool,
@@ -77,7 +80,12 @@ impl Default for SessionRegistry {
7780 }7881 7982 impl SessionRegistry {80- fn claim(self: &Arc<Self>, remote: EndpointId, nonce: [u8; 16]) -> Result<SessionLease> {83+ fn claim(84+ self: &Arc<Self>,85+ remote: EndpointId,86+ nonce: [u8; 16],87+ grants: InvitationGrants,88+ ) -> Result<SessionLease> {8189 let mut active = self.active.lock().expect("session registry poisoned");8290 if let Some(current) = *active {8391 bail!("host already has active client {}", current.remote);
@@ -89,6 +97,7 @@ impl SessionRegistry {
8997 session_id,9098 nonce,9199 session_clock,100+ grants,92101 media_active: true,93102 input_claimed: false,94103 audio_claimed: false,
@@ -118,6 +127,7 @@ impl SessionRegistry {
118127 registry: Arc::clone(self),119128 remote,120129 session_id: session.session_id,130+ grants: session.grants,121131 })122132 }123133
@@ -139,6 +149,7 @@ impl SessionRegistry {
139149 remote,140150 session_id: session.session_id,141151 session_clock: session.session_clock,152+ grants: session.grants,142153 })143154 }144155
@@ -216,6 +227,7 @@ struct InputLease {
216227 registry: Arc<SessionRegistry>,217228 remote: EndpointId,218229 session_id: u64,230+ grants: InvitationGrants,219231 }220232 221233 #[derive(Debug)]
@@ -224,6 +236,7 @@ struct AudioLease {
224236 remote: EndpointId,225237 session_id: u64,226238 session_clock: SessionClock,239+ grants: InvitationGrants,227240 }228241 229242 #[derive(Debug)]
@@ -690,18 +703,27 @@ impl Drop for AudioLease {
690703 pub struct MediaHandler {691704 pub config: HostConfig,692705 pub sessions: Arc<SessionRegistry>,706+ pub authorization: AuthorizationPolicy,693707 }694708 695709 #[derive(Clone, Debug)]696710 pub struct MediaV2Handler {697711 pub config: HostConfig,698712 pub sessions: Arc<SessionRegistry>,713+ pub authorization: AuthorizationPolicy,699714 }700715 701716 impl ProtocolHandler for MediaHandler {702717 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {703718 let remote = connection.remote_id();704- if let Err(error) = serve_media(connection, self.config.clone(), &self.sessions).await {719+ if let Err(error) = serve_media(720+ connection,721+ self.config.clone(),722+ &self.sessions,723+ &self.authorization,724+ )725+ .await726+ {705727 warn!(%remote, %error, "media connection ended");706728 }707729 Ok(())
@@ -711,7 +733,14 @@ impl ProtocolHandler for MediaHandler {
711733 impl ProtocolHandler for MediaV2Handler {712734 async fn accept(&self, connection: Connection) -> Result<(), iroh::protocol::AcceptError> {713735 let remote = connection.remote_id();714- if let Err(error) = serve_media_v2(connection, self.config.clone(), &self.sessions).await {736+ if let Err(error) = serve_media_v2(737+ connection,738+ self.config.clone(),739+ &self.sessions,740+ &self.authorization,741+ )742+ .await743+ {715744 warn!(%remote, %error, "media v2 connection ended");716745 }717746 Ok(())
@@ -762,6 +791,7 @@ async fn serve_media(
762791 connection: Connection,763792 config: HostConfig,764793 sessions: &Arc<SessionRegistry>,794+ authorization: &AuthorizationPolicy,765795 ) -> Result<()> {766796 let remote = connection.remote_id();767797 let handshake_permit = sessions
@@ -776,7 +806,23 @@ async fn serve_media(
776806 drop(handshake_permit);777807 debug!(%remote, agent = %hello.agent, "media hello received");778808 779- let lease = match sessions.claim(remote, hello.nonce) {809+ let grants = match authorization.authorize_or_redeem(810+ remote,811+ hello.invitation.as_deref(),812+ unix_timestamp_now()?,813+ ) {814+ Ok(grants) => grants,815+ Err(error) => {816+ send_rejection(&mut send, "Portal peer is not authorized").await?;817+ return Err(error.context("authorizing media peer"));818+ }819+ };820+ ensure!(821+ grants.contains(InvitationGrants::VIEW),822+ "authorized media peer lacks view permission"823+ );824+825+ let lease = match sessions.claim(remote, hello.nonce, grants) {780826 Ok(lease) => lease,781827 Err(error) => {782828 send_rejection(&mut send, "host already has an active client").await?;
@@ -921,6 +967,7 @@ async fn serve_media_v2(
921967 connection: Connection,922968 config: HostConfig,923969 sessions: &Arc<SessionRegistry>,970+ authorization: &AuthorizationPolicy,924971 ) -> Result<()> {925972 let remote = connection.remote_id();926973 let handshake_permit = sessions
@@ -935,7 +982,23 @@ async fn serve_media_v2(
935982 drop(handshake_permit);936983 debug!(%remote, agent = %hello.agent, "media v2 hello received");937984 938- let lease = match sessions.claim(remote, hello.nonce) {985+ let grants = match authorization.authorize_or_redeem(986+ remote,987+ hello.invitation.as_deref(),988+ unix_timestamp_now()?,989+ ) {990+ Ok(grants) => grants,991+ Err(error) => {992+ send_rejection(&mut send, "Portal peer is not authorized").await?;993+ return Err(error.context("authorizing media v2 peer"));994+ }995+ };996+ ensure!(997+ grants.contains(InvitationGrants::VIEW),998+ "authorized media peer lacks view permission"999+ );1000+1001+ let lease = match sessions.claim(remote, hello.nonce, grants) {9391002 Ok(lease) => lease,9401003 Err(error) => {9411004 send_rejection(&mut send, "host already has an active client").await?;
@@ -1229,10 +1292,20 @@ async fn serve_input(
12291292 let hello = receive_hello_unconstrained(&mut recv).await?;12301293 drop(handshake_permit);12311294 debug!(%remote, agent = %hello.agent, "input hello received");1232-1295+ ensure!(1296+ hello.invitation.is_none(),1297+ "invitations are accepted only on the first media handshake"1298+ );1299+ let lease = match sessions.claim_input(remote, hello.nonce) {1300+ Ok(lease) => lease,1301+ Err(error) => {1302+ send_rejection(&mut send, error.to_string()).await?;1303+ return Err(error);1304+ }1305+ };12331306 let supported =12341307 supported_input_capabilities(backend.capabilities(), pointer_positions.is_some());1235- let negotiated = negotiated_input_capabilities(&hello, &supported);1308+ let negotiated = negotiated_input_capabilities(&hello, &supported, lease.grants);12361309 12371310 let ack_enabled = negotiated.contains(&Capability::InputAck);12381311 let feedback_enabled = negotiated.contains(&Capability::PointerPositionFeedback);
@@ -1240,13 +1313,6 @@ async fn serve_input(
12401313 let mut pointer_positions = pointer_positions12411314 .filter(|_| feedback_enabled)12421315 .map(PointerPositionTracker::subscribe);1243- let lease = match sessions.claim_input(remote, hello.nonce) {1244- Ok(lease) => lease,1245- Err(error) => {1246- send_rejection(&mut send, error.to_string()).await?;1247- return Err(error);1248- }1249- };12501316 write_host_hello(12511317 &mut send,12521318 &HostHello::accepted(lease.session_id, negotiated.clone()),
@@ -1390,6 +1456,10 @@ async fn serve_audio(
13901456 let hello = receive_hello(&mut recv, Capability::AudioOpus).await?;13911457 drop(handshake_permit);13921458 debug!(%remote, agent = %hello.agent, "audio hello received");1459+ ensure!(1460+ hello.invitation.is_none(),1461+ "invitations are accepted only on the first media handshake"1462+ );13931463 13941464 if config.audio.is_none() {13951465 send_rejection(&mut send, "audio is unavailable").await?;
@@ -1411,6 +1481,10 @@ async fn serve_audio(
14111481 return Err(error);14121482 }14131483 };1484+ ensure!(1485+ lease.grants.contains(InvitationGrants::VIEW),1486+ "active Portal session lacks audio view permission"1487+ );14141488 let (mut audio_receiver, audio_task) =14151489 match spawn_pipewire_audio(config, lease.session_clock).await {14161490 Ok(source) => source,
@@ -1502,14 +1576,38 @@ fn negotiated_capabilities(hello: &ClientHello, supported: &[Capability]) -> Vec
15021576 .collect()15031577 }15041578 1505-fn negotiated_input_capabilities(hello: &ClientHello, supported: &[Capability]) -> Vec<Capability> {1579+fn negotiated_input_capabilities(1580+ hello: &ClientHello,1581+ supported: &[Capability],1582+ grants: InvitationGrants,1583+) -> Vec<Capability> {15061584 let mut negotiated = negotiated_capabilities(hello, supported);1585+ negotiated.retain(|capability| input_capability_authorized(*capability, grants));15071586 if !negotiated.contains(&Capability::PointerPositionFeedback) {15081587 negotiated.retain(|capability| *capability != Capability::PointerVisibilityFeedback);15091588 }15101589 negotiated15111590 }15121591 1592+fn input_capability_authorized(capability: Capability, grants: InvitationGrants) -> bool {1593+ match capability {1594+ Capability::AbsolutePointer1595+ | Capability::RelativePointer1596+ | Capability::Keyboard1597+ | Capability::Text1598+ | Capability::PointerPositionFeedback1599+ | Capability::PointerVisibilityFeedback => {1600+ grants.contains(InvitationGrants::POINTER_KEYBOARD)1601+ }1602+ Capability::Gamepad => grants.contains(InvitationGrants::GAMEPAD),1603+ Capability::InputAck => {1604+ grants.contains(InvitationGrants::POINTER_KEYBOARD)1605+ || grants.contains(InvitationGrants::GAMEPAD)1606+ }1607+ Capability::VideoH264 | Capability::AudioOpus => false,1608+ }1609+}1610+15131611 fn pointer_feedback_fields(15141612 pointer_state: Option<PointerState>,15151613 visibility_feedback_enabled: bool,
@@ -1848,8 +1946,14 @@ mod tests {
18481946 fn only_one_remote_can_hold_session() {18491947 let sessions = Arc::new(SessionRegistry::default());18501948 let nonce = [7; 16];1851- let first = sessions.claim(endpoint(1), nonce).unwrap();1852- assert!(sessions.claim(endpoint(2), nonce).is_err());1949+ let first = sessions1950+ .claim(endpoint(1), nonce, InvitationGrants::ALL)1951+ .unwrap();1952+ assert!(1953+ sessions1954+ .claim(endpoint(2), nonce, InvitationGrants::ALL)1955+ .is_err()1956+ );18531957 assert!(sessions.claim_input(endpoint(1), [8; 16]).is_err());18541958 let input = sessions.claim_input(endpoint(1), nonce).unwrap();18551959 assert_eq!(input.session_id, first.session_id);
@@ -1859,11 +1963,23 @@ mod tests {
18591963 drop(input);18601964 let draining_input = sessions.claim_input(endpoint(1), nonce).unwrap();18611965 drop(first);1862- assert!(sessions.claim(endpoint(2), nonce).is_err());1966+ assert!(1967+ sessions1968+ .claim(endpoint(2), nonce, InvitationGrants::ALL)1969+ .is_err()1970+ );18631971 drop(draining_input);1864- assert!(sessions.claim(endpoint(2), nonce).is_err());1972+ assert!(1973+ sessions1974+ .claim(endpoint(2), nonce, InvitationGrants::ALL)1975+ .is_err()1976+ );18651977 drop(audio);1866- assert!(sessions.claim(endpoint(2), nonce).is_ok());1978+ assert!(1979+ sessions1980+ .claim(endpoint(2), nonce, InvitationGrants::ALL)1981+ .is_ok()1982+ );18671983 }18681984 18691985 #[test]
@@ -1918,6 +2034,67 @@ mod tests {
19182034 );19192035 }19202036 2037+ #[test]2038+ fn enrollment_grants_are_a_strict_input_capability_ceiling() {2039+ let hello = ClientHello::new(2040+ "test",2041+ [0; 16],2042+ vec![2043+ Capability::RelativePointer,2044+ Capability::AbsolutePointer,2045+ Capability::Keyboard,2046+ Capability::Text,2047+ Capability::Gamepad,2048+ Capability::InputAck,2049+ Capability::PointerPositionFeedback,2050+ Capability::PointerVisibilityFeedback,2051+ ],2052+ );2053+ let supported = hello.capabilities.clone();2054+2055+ assert!(2056+ negotiated_input_capabilities(&hello, &supported, InvitationGrants::VIEW).is_empty()2057+ );2058+ assert_eq!(2059+ negotiated_input_capabilities(2060+ &hello,2061+ &supported,2062+ InvitationGrants::VIEW.union(InvitationGrants::POINTER_KEYBOARD),2063+ ),2064+ vec![2065+ Capability::RelativePointer,2066+ Capability::AbsolutePointer,2067+ Capability::Keyboard,2068+ Capability::Text,2069+ Capability::InputAck,2070+ Capability::PointerPositionFeedback,2071+ Capability::PointerVisibilityFeedback,2072+ ]2073+ );2074+ assert_eq!(2075+ negotiated_input_capabilities(2076+ &hello,2077+ &supported,2078+ InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),2079+ ),2080+ vec![Capability::Gamepad, Capability::InputAck]2081+ );2082+ }2083+2084+ #[test]2085+ fn session_substreams_inherit_the_exact_enrollment_grant() {2086+ let sessions = Arc::new(SessionRegistry::default());2087+ let grants = InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD);2088+ let media = sessions.claim(endpoint(1), [3; 16], grants).unwrap();2089+ let input = sessions.claim_input(endpoint(1), [3; 16]).unwrap();2090+ let audio = sessions.claim_audio(endpoint(1), [3; 16]).unwrap();2091+ assert_eq!(input.grants, grants);2092+ assert_eq!(audio.grants, grants);2093+ drop(input);2094+ drop(audio);2095+ drop(media);2096+ }2097+19212098 #[test]19222099 fn pointer_feedback_is_advertised_only_with_tracker_and_relative_input() {19232100 assert_eq!(
@@ -1949,7 +2126,7 @@ mod tests {
19492126 ],19502127 );19512128 let supported = supported_input_capabilities(&[Capability::RelativePointer], true);1952- let negotiated = negotiated_input_capabilities(&hello, &supported);2129+ let negotiated = negotiated_input_capabilities(&hello, &supported, InvitationGrants::ALL);19532130 assert_eq!(19542131 negotiated,19552132 vec![
@@ -2006,7 +2183,10 @@ mod tests {
20062183 vec![Capability::PointerVisibilityFeedback],20072184 );20082185 let supported = supported_input_capabilities(&[Capability::RelativePointer], true);2009- assert!(negotiated_input_capabilities(&visibility_only, &supported).is_empty());2186+ assert!(2187+ negotiated_input_capabilities(&visibility_only, &supported, InvitationGrants::ALL)2188+ .is_empty()2189+ );20102190 20112191 let upgraded = ClientHello::new(20122192 "upgraded-client",
@@ -2018,7 +2198,7 @@ mod tests {
20182198 ],20192199 );20202200 assert_eq!(2021- negotiated_input_capabilities(&upgraded, &supported),2201+ negotiated_input_capabilities(&upgraded, &supported, InvitationGrants::ALL),20222202 supported20232203 );20242204
@@ -2038,20 +2218,32 @@ mod tests {
20382218 #[test]20392219 fn audio_claim_requires_the_active_remote_and_nonce() {20402220 let sessions = Arc::new(SessionRegistry::default());2041- let media = sessions.claim(endpoint(1), [9; 16]).unwrap();2221+ let media = sessions2222+ .claim(endpoint(1), [9; 16], InvitationGrants::ALL)2223+ .unwrap();20422224 assert!(sessions.claim_audio(endpoint(2), [9; 16]).is_err());20432225 assert!(sessions.claim_audio(endpoint(1), [8; 16]).is_err());20442226 let audio = sessions.claim_audio(endpoint(1), [9; 16]).unwrap();20452227 drop(media);2046- assert!(sessions.claim(endpoint(2), [0; 16]).is_err());2228+ assert!(2229+ sessions2230+ .claim(endpoint(2), [0; 16], InvitationGrants::ALL)2231+ .is_err()2232+ );20472233 drop(audio);2048- assert!(sessions.claim(endpoint(2), [0; 16]).is_ok());2234+ assert!(2235+ sessions2236+ .claim(endpoint(2), [0; 16], InvitationGrants::ALL)2237+ .is_ok()2238+ );20492239 }20502240 20512241 #[test]20522242 fn media_and_audio_leases_share_one_session_clock() {20532243 let sessions = Arc::new(SessionRegistry::default());2054- let media = sessions.claim(endpoint(1), [9; 16]).unwrap();2244+ let media = sessions2245+ .claim(endpoint(1), [9; 16], InvitationGrants::ALL)2246+ .unwrap();20552247 let audio = sessions.claim_audio(endpoint(1), [9; 16]).unwrap();20562248 assert_eq!(media.session_clock, audio.session_clock);20572249 }
@@ -2307,7 +2499,9 @@ mod tests {
23072499 let sessions = Arc::new(SessionRegistry::default());23082500 let remote = endpoint(1);23092501 let nonce = [7; 16];2310- let media = sessions.claim(remote, nonce).unwrap();2502+ let media = sessions2503+ .claim(remote, nonce, InvitationGrants::ALL)2504+ .unwrap();23112505 let input = sessions.claim_input(remote, nonce).unwrap();23122506 let session_id = media.session_id;23132507
@@ -2362,6 +2556,10 @@ mod tests {
23622556 .expect("input lease did not observe media shutdown")23632557 .unwrap();23642558 2365- assert!(sessions.claim(endpoint(2), [8; 16]).is_ok());2559+ assert!(2560+ sessions2561+ .claim(endpoint(2), [8; 16], InvitationGrants::ALL)2562+ .is_ok()2563+ );23662564 }23672565 }
M crates/sigil-protocol/Cargo.toml +2 -0
M crates/sigil-protocol/src/error.rs +3 -0
M crates/sigil-protocol/src/handshake.rs +53 -2
@@ -2,7 +2,10 @@ use serde::{Deserialize, Serialize};
22 use tokio::io::{AsyncRead, AsyncWrite};33 44 use crate::framing::{read_json, write_json};5-use crate::{MAX_HANDSHAKE_MESSAGE_LEN, PROTOCOL_VERSION, ProtocolError, Result};5+use crate::{6+ MAX_HANDSHAKE_MESSAGE_LEN, MAX_INVITATION_TOKEN_LEN, PROTOCOL_VERSION, ProtocolError, Result,7+ SignedInvitation,8+};69 710 const MAX_AGENT_LEN: usize = 128;811 const MAX_MESSAGE_LEN: usize = 512;
@@ -36,6 +39,10 @@ pub struct ClientHello {
3639 pub agent: String,3740 pub nonce: [u8; 16],3841 pub capabilities: Vec<Capability>,42+ /// One-time enrollment invitation. It is accepted only on the first media43+ /// connection and is never sent again after the host persists enrollment.44+ #[serde(default, skip_serializing_if = "Option::is_none")]45+ pub invitation: Option<String>,3946 }4047 4148 /// Native coordinate space used by the host compositor for relative-pointer
@@ -75,13 +82,29 @@ impl ClientHello {
7582 agent: agent.into(),7683 nonce,7784 capabilities,85+ invitation: None,7886 }7987 }8088 89+ pub fn with_invitation(mut self, invitation: impl Into<String>) -> Self {90+ self.invitation = Some(invitation.into());91+ self92+ }93+8194 pub fn validate(&self) -> Result<()> {8295 validate_version(self.version)?;8396 validate_agent(&self.agent)?;84- validate_capabilities(&self.capabilities)97+ validate_capabilities(&self.capabilities)?;98+ if let Some(invitation) = &self.invitation {99+ if invitation.len() > MAX_INVITATION_TOKEN_LEN {100+ return Err(ProtocolError::InvalidMessage {101+ message_type: "client hello",102+ reason: "invitation exceeds the bounded token length",103+ });104+ }105+ SignedInvitation::decode(invitation)?;106+ }107+ Ok(())85108 }86109 }87110
@@ -240,6 +263,7 @@ fn validate_capabilities(capabilities: &[Capability]) -> Result<()> {
240263 241264 #[cfg(test)]242265 mod tests {266+ use ed25519_dalek::SigningKey;243267 use tokio::io::{AsyncWriteExt, duplex};244268 245269 use super::*;
@@ -264,6 +288,33 @@ mod tests {
264288 );265289 }266290 291+ #[test]292+ fn client_hello_carries_only_a_bounded_valid_invitation() {293+ let host = SigningKey::from_bytes(&[7; 32]);294+ let peer = SigningKey::from_bytes(&[9; 32]);295+ let claims = crate::InvitationClaims::new(296+ host.verifying_key().to_bytes(),297+ peer.verifying_key().to_bytes(),298+ 100,299+ 200,300+ 1,301+ [1; 32],302+ crate::InvitationGrants::VIEW,303+ )304+ .unwrap();305+ let token = crate::SignedInvitation::issue(claims, &[7; 32])306+ .unwrap()307+ .encode();308+ let hello = ClientHello::new("portal", [0; 16], vec![Capability::VideoH264])309+ .with_invitation(token.clone());310+ hello.validate().unwrap();311+ assert_eq!(hello.invitation.as_deref(), Some(token.as_str()));312+313+ let mut malformed = hello;314+ malformed.invitation = Some("goq-invite-v1.invalid".into());315+ assert!(malformed.validate().is_err());316+ }317+267318 #[test]268319 fn relative_pointer_capability_json_is_a_golden_vector() {269320 assert_eq!(
A crates/sigil-protocol/src/invitation.rs +335 -0
@@ -0,0 +1,335 @@
1+use base64::Engine as _;2+use base64::engine::general_purpose::URL_SAFE_NO_PAD;3+use ed25519_dalek::{Signature, Signer as _, SigningKey, VerifyingKey};4+use serde::{Deserialize, Serialize};5+6+use crate::{PROTOCOL_VERSION, ProtocolError, Result};7+8+const INVITATION_MAGIC: [u8; 4] = *b"SGIV";9+const CLAIMS_LEN: usize = 127;10+const SIGNATURE_LEN: usize = 64;11+const INVITATION_LEN: usize = CLAIMS_LEN + SIGNATURE_LEN;12+const SIGNATURE_DOMAIN: &[u8] = b"goq.sh/sigil-invitation/v1\0";13+14+pub const INVITATION_TOKEN_PREFIX: &str = "goq-invite-v1.";15+pub const MAX_INVITATION_TOKEN_LEN: usize = 384;16+pub const MAX_INVITATION_TTL_SECS: u64 = 15 * 60;17+pub const INVITATION_CLOCK_SKEW_SECS: u64 = 60;18+19+/// Product authorization scopes. These are deliberately separate from wire20+/// capabilities, which also contain operational protocol extensions.21+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]22+#[serde(transparent)]23+pub struct InvitationGrants(u8);24+25+impl InvitationGrants {26+ pub const VIEW: Self = Self(1 << 0);27+ pub const POINTER_KEYBOARD: Self = Self(1 << 1);28+ pub const GAMEPAD: Self = Self(1 << 2);29+ pub const ALL: Self = Self(Self::VIEW.0 | Self::POINTER_KEYBOARD.0 | Self::GAMEPAD.0);30+ const KNOWN: u8 = Self::ALL.0;31+32+ pub fn new(bits: u8) -> Result<Self> {33+ if bits == 0 || bits & !Self::KNOWN != 0 {34+ return Err(ProtocolError::InvalidMessage {35+ message_type: "invitation grants",36+ reason: "grants must contain only known non-zero permission bits",37+ });38+ }39+ Ok(Self(bits))40+ }41+42+ pub const fn bits(self) -> u8 {43+ self.044+ }45+46+ pub const fn contains(self, required: Self) -> bool {47+ self.0 & required.0 == required.048+ }49+50+ pub const fn union(self, other: Self) -> Self {51+ Self(self.0 | other.0)52+ }53+54+ pub fn validate(self) -> Result<()> {55+ Self::new(self.0).map(|_| ())56+ }57+}58+59+#[derive(Clone, Debug, PartialEq, Eq)]60+pub struct InvitationClaims {61+ pub host_node_id: [u8; 32],62+ pub intended_peer_id: [u8; 32],63+ pub issued_at_unix: u64,64+ pub expires_at_unix: u64,65+ pub enrollment_epoch: u64,66+ pub nonce: [u8; 32],67+ pub grants: InvitationGrants,68+}69+70+impl InvitationClaims {71+ #[allow(clippy::too_many_arguments)]72+ pub fn new(73+ host_node_id: [u8; 32],74+ intended_peer_id: [u8; 32],75+ issued_at_unix: u64,76+ expires_at_unix: u64,77+ enrollment_epoch: u64,78+ nonce: [u8; 32],79+ grants: InvitationGrants,80+ ) -> Result<Self> {81+ let claims = Self {82+ host_node_id,83+ intended_peer_id,84+ issued_at_unix,85+ expires_at_unix,86+ enrollment_epoch,87+ nonce,88+ grants,89+ };90+ claims.validate()?;91+ Ok(claims)92+ }93+94+ pub fn validate(&self) -> Result<()> {95+ self.grants.validate()?;96+ if self.issued_at_unix >= self.expires_at_unix97+ || self.expires_at_unix - self.issued_at_unix > MAX_INVITATION_TTL_SECS98+ {99+ return Err(ProtocolError::InvalidMessage {100+ message_type: "invitation claims",101+ reason: "invitation lifetime must be 1..=900 seconds",102+ });103+ }104+ if self.enrollment_epoch == 0 {105+ return Err(ProtocolError::InvalidMessage {106+ message_type: "invitation claims",107+ reason: "enrollment epoch must be non-zero",108+ });109+ }110+ if self.nonce == [0; 32] {111+ return Err(ProtocolError::InvalidMessage {112+ message_type: "invitation claims",113+ reason: "invitation nonce must be non-zero",114+ });115+ }116+ VerifyingKey::from_bytes(&self.host_node_id).map_err(|_| {117+ ProtocolError::InvalidMessage {118+ message_type: "invitation claims",119+ reason: "host node ID is not a valid Ed25519 public key",120+ }121+ })?;122+ VerifyingKey::from_bytes(&self.intended_peer_id).map_err(|_| {123+ ProtocolError::InvalidMessage {124+ message_type: "invitation claims",125+ reason: "intended peer ID is not a valid Ed25519 public key",126+ }127+ })?;128+ Ok(())129+ }130+131+ fn encode(&self) -> [u8; CLAIMS_LEN] {132+ let mut bytes = [0_u8; CLAIMS_LEN];133+ bytes[0..4].copy_from_slice(&INVITATION_MAGIC);134+ bytes[4..6].copy_from_slice(&PROTOCOL_VERSION.to_be_bytes());135+ bytes[6..38].copy_from_slice(&self.host_node_id);136+ bytes[38..70].copy_from_slice(&self.intended_peer_id);137+ bytes[70..78].copy_from_slice(&self.issued_at_unix.to_be_bytes());138+ bytes[78..86].copy_from_slice(&self.expires_at_unix.to_be_bytes());139+ bytes[86..94].copy_from_slice(&self.enrollment_epoch.to_be_bytes());140+ bytes[94..126].copy_from_slice(&self.nonce);141+ bytes[126] = self.grants.bits();142+ bytes143+ }144+145+ fn signing_message(&self) -> Vec<u8> {146+ let mut message = Vec::with_capacity(SIGNATURE_DOMAIN.len() + CLAIMS_LEN);147+ message.extend_from_slice(SIGNATURE_DOMAIN);148+ message.extend_from_slice(&self.encode());149+ message150+ }151+}152+153+#[derive(Clone, Debug, PartialEq, Eq)]154+pub struct SignedInvitation {155+ pub claims: InvitationClaims,156+ signature: [u8; SIGNATURE_LEN],157+}158+159+impl SignedInvitation {160+ pub fn issue(claims: InvitationClaims, host_secret: &[u8; 32]) -> Result<Self> {161+ claims.validate()?;162+ let signing_key = SigningKey::from_bytes(host_secret);163+ if signing_key.verifying_key().to_bytes() != claims.host_node_id {164+ return Err(ProtocolError::InvalidMessage {165+ message_type: "invitation claims",166+ reason: "signing key does not match the claimed host",167+ });168+ }169+ let signature = signing_key.sign(&claims.signing_message()).to_bytes();170+ Ok(Self { claims, signature })171+ }172+173+ pub fn verify(&self) -> Result<()> {174+ self.claims.validate()?;175+ let key = VerifyingKey::from_bytes(&self.claims.host_node_id).map_err(|_| {176+ ProtocolError::InvalidMessage {177+ message_type: "invitation",178+ reason: "invalid host verification key",179+ }180+ })?;181+ let signature = Signature::from_bytes(&self.signature);182+ key.verify_strict(&self.claims.signing_message(), &signature)183+ .map_err(|_| ProtocolError::InvalidMessage {184+ message_type: "invitation",185+ reason: "signature verification failed",186+ })187+ }188+189+ pub fn encode(&self) -> String {190+ let mut bytes = Vec::with_capacity(INVITATION_LEN);191+ bytes.extend_from_slice(&self.claims.encode());192+ bytes.extend_from_slice(&self.signature);193+ format!("{INVITATION_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))194+ }195+196+ pub fn decode(token: &str) -> Result<Self> {197+ if token.len() > MAX_INVITATION_TOKEN_LEN198+ || token.contains('=')199+ || token.bytes().any(|byte| byte.is_ascii_whitespace())200+ {201+ return Err(ProtocolError::InvalidMessage {202+ message_type: "invitation",203+ reason: "token is oversized or non-canonical",204+ });205+ }206+ let encoded =207+ token208+ .strip_prefix(INVITATION_TOKEN_PREFIX)209+ .ok_or(ProtocolError::InvalidMessage {210+ message_type: "invitation",211+ reason: "token prefix is invalid",212+ })?;213+ let bytes = URL_SAFE_NO_PAD214+ .decode(encoded)215+ .map_err(|_| ProtocolError::InvalidMessage {216+ message_type: "invitation",217+ reason: "token is not canonical base64url",218+ })?;219+ if bytes.len() != INVITATION_LEN {220+ return Err(ProtocolError::InvalidMessageLength {221+ actual: bytes.len(),222+ maximum: INVITATION_LEN,223+ });224+ }225+ let magic: [u8; 4] = bytes[0..4].try_into().expect("fixed ticket length");226+ if magic != INVITATION_MAGIC {227+ return Err(ProtocolError::InvalidInvitationMagic(magic));228+ }229+ let version = u16::from_be_bytes(bytes[4..6].try_into().expect("fixed ticket length"));230+ if version != PROTOCOL_VERSION {231+ return Err(ProtocolError::UnsupportedVersion {232+ expected: PROTOCOL_VERSION,233+ actual: version,234+ });235+ }236+ let claims = InvitationClaims::new(237+ bytes[6..38].try_into().expect("fixed ticket length"),238+ bytes[38..70].try_into().expect("fixed ticket length"),239+ u64::from_be_bytes(bytes[70..78].try_into().expect("fixed ticket length")),240+ u64::from_be_bytes(bytes[78..86].try_into().expect("fixed ticket length")),241+ u64::from_be_bytes(bytes[86..94].try_into().expect("fixed ticket length")),242+ bytes[94..126].try_into().expect("fixed ticket length"),243+ InvitationGrants::new(bytes[126])?,244+ )?;245+ let invitation = Self {246+ claims,247+ signature: bytes[CLAIMS_LEN..]248+ .try_into()249+ .expect("fixed signature length"),250+ };251+ invitation.verify()?;252+ if invitation.encode() != token {253+ return Err(ProtocolError::InvalidMessage {254+ message_type: "invitation",255+ reason: "token encoding is not canonical",256+ });257+ }258+ Ok(invitation)259+ }260+}261+262+#[cfg(test)]263+mod tests {264+ use super::*;265+266+ fn invitation() -> SignedInvitation {267+ let host = SigningKey::from_bytes(&[7; 32]);268+ let peer = SigningKey::from_bytes(&[9; 32]);269+ let claims = InvitationClaims::new(270+ host.verifying_key().to_bytes(),271+ peer.verifying_key().to_bytes(),272+ 1_700_000_000,273+ 1_700_000_600,274+ 3,275+ [11; 32],276+ InvitationGrants::VIEW.union(InvitationGrants::GAMEPAD),277+ )278+ .unwrap();279+ SignedInvitation::issue(claims, &[7; 32]).unwrap()280+ }281+282+ #[test]283+ fn signed_invitation_is_canonical_and_round_trips() {284+ let invitation = invitation();285+ let token = invitation.encode();286+ assert_eq!(287+ token,288+ "goq-invite-v1.U0dJVgAB6kpsY-KcUgq-9VB7Ey7F-ZVHdq6-vnuSQh7qaRRG0iz9FyQ4WqDHW2T7eM1gL6HZkf3r92sTxY7XAurINen2GAAAAABlU_EAAAAAAGVT81gAAAAAAAAAAwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLBUuO9fzI2gGLT6L3Lz5XPM500MPzW5eaB2h4FnOWZHM9ENlCqMERXCvwxZTG3hFR4WumyJeqoAlqH_gCeJOsbQs"289+ );290+ assert!(token.starts_with(INVITATION_TOKEN_PREFIX));291+ assert!(token.len() <= MAX_INVITATION_TOKEN_LEN);292+ assert_eq!(SignedInvitation::decode(&token).unwrap(), invitation);293+ }294+295+ #[test]296+ fn tampering_and_noncanonical_tokens_fail_closed() {297+ let token = invitation().encode();298+ let mut bytes = URL_SAFE_NO_PAD299+ .decode(token.strip_prefix(INVITATION_TOKEN_PREFIX).unwrap())300+ .unwrap();301+ bytes[126] ^= InvitationGrants::POINTER_KEYBOARD.bits();302+ let tampered = format!("{INVITATION_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes));303+ assert!(SignedInvitation::decode(&tampered).is_err());304+ assert!(SignedInvitation::decode(&(token + "=")).is_err());305+ }306+307+ #[test]308+ fn claims_reject_unknown_grants_lifetime_epoch_and_nonce() {309+ assert!(InvitationGrants::new(0).is_err());310+ assert!(InvitationGrants::new(0x80).is_err());311+ let host = SigningKey::from_bytes(&[7; 32]);312+ let peer = SigningKey::from_bytes(&[9; 32]);313+ let make = |issued, expires, epoch, nonce| {314+ InvitationClaims::new(315+ host.verifying_key().to_bytes(),316+ peer.verifying_key().to_bytes(),317+ issued,318+ expires,319+ epoch,320+ nonce,321+ InvitationGrants::VIEW,322+ )323+ };324+ assert!(make(10, 10, 1, [1; 32]).is_err());325+ assert!(make(10, 911, 1, [1; 32]).is_err());326+ assert!(make(10, 20, 0, [1; 32]).is_err());327+ assert!(make(10, 20, 1, [0; 32]).is_err());328+ }329+330+ #[test]331+ fn signing_key_must_match_claimed_host() {332+ let invitation = invitation();333+ assert!(SignedInvitation::issue(invitation.claims, &[8; 32]).is_err());334+ }335+}
M crates/sigil-protocol/src/lib.rs +5 -0
M docs/fresh-bazzite-host.md +67 -36
@@ -54,25 +54,44 @@ in a production package.
5454 5555 ### Current security boundary5656 57-In the inherited protocol, the passkey lets the client derive and discover the58-host's Iroh node ID. It does not authenticate the client to the host. Iroh59-authenticates the host endpoint and encrypts the connection, but a custom client60-that already knows the node ID and ALPN can attempt to connect today.61-62-Therefore, `--dev-connect` bypasses host discovery in the official client; it is63-not a host-side authorization bypass and must not be described as one. Keep the64-development node ID private, enforce the single-client limit, and use a fresh65-identity for the dedicated appliance. Capability tickets later add actual66-host-side client authorization. Do not treat Steps 0–2 as a production security67-model.68-69-Build-time CLI containment prevents accidental product exposure; it is not an70-authorization boundary. The `demo-direct-node` feature exists only to measure71-the optimized client during temporary testing. The v1 host limits service to72-one media session and one matching input stream, bounds peer-controlled73-messages, applies handshake and stalled-media-write timeouts, and drops stale74-encoded frames. Treat node IDs and connection metadata as operationally75-sensitive until capability tickets exist.57+Portal derives a stable client Iroh identity from the security key. Sigil signs58+a short-lived invitation bound to its host identity, that exact Portal peer,59+an enrollment epoch, a nonce, expiry, and the requested grants. The first media60+handshake consumes the invitation atomically; later sessions authenticate the61+durably enrolled peer without replaying it. View, pointer/keyboard, and gamepad62+grants are intersected with operational protocol capabilities on both sides.63+64+`--dev-connect` remains only a build-time-contained routing bypass in Portal.65+It does not grant access to a configured host, and ordinary release builds66+reject the option. Only the explicit direct test-pattern proof form of67+`sigil serve` omits enrollment; a configured Gamescope appliance fails closed.68+Replay, expiry, wrong-peer, cross-host, stale-epoch, and capability-escalation69+attempts are rejected before capture starts.70+71+For first enrollment, use Portal's **show portal id** action, then issue the72+short-lived file on the host:73+74+```bash75+sigil invitation create \76+ --config ~/.config/sigil-spark/host.toml \77+ --peer PORTAL_PEER_ID \78+ --pointer-keyboard \79+ --gamepad \80+ --output ~/portal.goq-invite81+```82+83+Open the file with Portal and confirm its bounded summary with the controller.84+After the first accepted media handshake, verify the durable grant with85+`sigil enrollment show --config ~/.config/sigil-spark/host.toml`. Revocation is86+explicit and invalidates all outstanding invitations:87+88+```bash89+sigil enrollment revoke --config ~/.config/sigil-spark/host.toml90+```91+92+Then disconnect Portal and use **client -> reset enrollment** before importing93+the replacement invitation. Portal never silently overwrites a redeemed host94+profile.7695 7796 Do not put an identity seed in an environment variable or command-line7897 argument. Store it in a mode `0600` file and pass only its path.
@@ -466,32 +485,44 @@ The preferred host artifact is the deterministic, allowlisted runtime package.
466485 It contains the generic Linux host/probe binaries, installer and rollback tool,467486 systemd/PipeWire/udev assets, license, complete checksums, and build provenance.468487 It cannot include the worktree, credentials, identity, hardware configuration,469-or evidence. Product packaging exports clean `HEAD`, builds both binaries with470-locked `cargo-zigbuild` in an isolated target directory, and never accepts471-caller-supplied binaries. It fails closed when the worktree is dirty or no472-Minisign secret key is supplied:488+or evidence. Product packaging exports clean tagged `HEAD`, builds both489+binaries with locked `cargo-zigbuild` in an isolated target directory, and490+never accepts caller-supplied binaries. It fails closed when the worktree is491+dirty, the tag does not resolve to `HEAD`, the tag differs from Sigil's Cargo492+version, or the output does not use the stable public asset name. The builder493+never receives the offline Minisign secret:473494 474495 ```bash475496 cd /Users/dami/Developer/sigil-spark476497 source ~/.cargo/env477498 scripts/package-bazzite-release.sh \478- --output /tmp/sigil-spark-host.tar.gz \479- --minisign-key /absolute/path/to/release.key480-481-# Verify with a public key obtained through a separate trusted channel.482-minisign -Vm /tmp/sigil-spark-host.tar.gz \483- -x /tmp/sigil-spark-host.tar.gz.minisig \484- -P '<trusted minisign public key>'485-shasum -a 256 -c /tmp/sigil-spark-host.tar.gz.sha256486-scp /tmp/sigil-spark-host.tar.gz tank@umpc:/tmp/499+ --release-tag v0.1.0 \500+ --output /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz501+502+# Verify the candidate before it crosses the offline signing boundary.503+scripts/verify-sigil-release.sh \504+ --tag v0.1.0 \505+ --archive /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz \506+ --source-commit "$(git rev-parse HEAD)" \507+ --candidate508+509+# After the offline signer returns the detached signature, verify with the510+# reviewed public key before extraction. See docs/public-release-delivery.md.511+scripts/verify-sigil-release.sh \512+ --tag v0.1.0 \513+ --archive /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz \514+ --source-commit "$(git rev-parse HEAD)" \515+ --public-key-file /absolute/path/to/sigil-minisign.pub516+shasum -a 256 -c /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz.sha256517+scp /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz tank@umpc:/tmp/487518 488519 ssh tank@umpc '489520 set -eu490521 incoming="$HOME/.local/share/sigil-spark/incoming"491522 install -d -m 0700 "$incoming"492523 run_dir="$(mktemp -d "$incoming/package.XXXXXX")"493- tar -tzf /tmp/sigil-spark-host.tar.gz494- tar -xzf /tmp/sigil-spark-host.tar.gz -C "$run_dir"524+ tar -tzf /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz525+ tar -xzf /tmp/sigil-v0.1.0-bazzite-x86_64.tar.gz -C "$run_dir"495526 cd "$run_dir/payload"496527 sha256sum -c PACKAGE-SHA256SUMS497528 bash -n stage-this-release.sh
@@ -512,8 +543,8 @@ them. It can adopt an operator-owned regular file only when it is byte-identical
512543 to the new release asset; any local modification or unsafe target rejects the513544 whole asset migration without changing an earlier destination.514545 515-For a temporary dirty and unsigned development package only, replace the516-Minisign option with `--allow-dirty --allow-unsigned`. The manifest records that546+For a temporary dirty and unsigned development package only, omit the release547+tag and use both `--allow-dirty --allow-unsigned`. The manifest records that517548 state and the package prints `publisher_signature=absent-development`; never518549 publish that artifact. Externally built host/probe binaries are accepted only519550 as an all-or-none pair with both development flags, and their manifest records
A docs/portal-release.md +159 -0
@@ -0,0 +1,159 @@
1+# Portal public release runbook (issue #5)2+3+This runbook completes the repository-controlled portion of4+[issue #5](https://github.com/FelineStateMachine/goq/issues/5). The first5+supported Portal download is **macOS arm64 only**. x86_64, universal, Linux,6+and Windows builds remain unavailable until they receive equivalent signing7+and release gates.8+9+Portal is always a compiled DMG download. There is no shell installer and no10+fallback to an ad-hoc, demo, or development build.11+12+## One-time GitHub and Apple setup13+14+Protect the GitHub environment named `main` with required reviewers and limit15+deployment branches/tags according to the repository's release policy. Add16+these environment secrets:17+18+| Secret | Purpose |19+| --- | --- |20+| `PORTAL_APPLE_CERTIFICATE_BASE64` | Base64-encoded Developer ID Application PKCS#12 certificate |21+| `PORTAL_APPLE_CERTIFICATE_PASSWORD` | PKCS#12 import password |22+| `PORTAL_APPLE_SIGNING_IDENTITY` | Exact `Developer ID Application: ...` keychain identity |23+| `PORTAL_APPLE_ID` | Apple account used by the notarization service |24+| `PORTAL_APPLE_PASSWORD` | App-specific password for notarization |25+| `PORTAL_APPLE_TEAM_ID` | Apple Developer team identifier |26+27+Do not put certificate bytes, passwords, API keys, or secret-derived output in28+the repository, workflow artifacts, release notes, or website manifest. The29+workflow imports the certificate into an ephemeral keychain and removes it in30+an always-running cleanup step.31+32+The Apple team identifier itself is public release policy, not a credential.33+Before the first release tag, replace `unconfigured` in34+`release/portal-apple-team-id.txt` with the exact ten-character TeamIdentifier35+for the protected Developer ID certificate. The protected36+`PORTAL_APPLE_TEAM_ID` secret must match that committed pin. Packaging,37+promotion, and hardware UAT all fail closed if the pin is missing, malformed,38+or different from the application signature.39+40+## Prepare an exact tag41+42+Choose a SemVer tag such as `v0.3.0-alpha.1`. Before tagging, update every43+workspace package version and `src-tauri/tauri.conf.json` to the tag's version.44+The shared verifier rejects a dirty worktree, a tag not resolving exactly to45+`HEAD`, any version mismatch, a non-arm64 target, or a Portal manifest that46+enables `demo-direct-node` by default.47+48+Run the non-secret policy checks before pushing the tag:49+50+```bash51+python3 scripts/verify-portal-release.py website \52+ --manifest website/portal-release.json53+./scripts/tests/portal-release.sh54+./scripts/verify-website.sh55+```56+57+Create and push the tag only from the reviewed clean release commit. Dispatch58+the workflows from that tag ref, not from `main`; the Portal build refuses to59+attest an input tag checked out by a different workflow ref. First run60+the **Sigil Release** workflow with `build-candidate`; it creates the shared61+draft release and attaches the two verified Sigil candidate assets. Then62+manually run **Portal release** for the same tag. Both workflows share a63+concurrency group, so their release mutations cannot overlap.64+65+## Publication transaction66+67+The workflow performs this fail-closed sequence:68+69+1. Re-verifies clean tag, exact `HEAD`, workspace versions, Tauri version, and70+ demo-feature policy on Linux.71+2. Uses a native `macos-26` arm64 runner and the protected `main` environment.72+3. Builds without feature flags and requires Developer ID Application signing,73+ hardened runtime, notarization, app and DMG stapling, strict code-signature74+ verification, and Gatekeeper acceptance.75+4. Confirms the executable contains only arm64, the Apple TeamIdentifier equals76+ the committed signer pin, and the DMG, digest, and JSON manifest agree with77+ the source tag and commit.78+5. Creates GitHub/Sigstore build-provenance attestations for the exact three79+ Portal assets from the protected tag-ref workflow.80+6. Requires the existing draft to contain exactly the two Sigil candidate81+ assets, then uploads exactly:82+83+ - `Portal-VERSION-arm64.dmg`84+ - `Portal-VERSION-arm64.dmg.sha256`85+ - `Portal-VERSION-arm64.json`86+87+7. Reads GitHub's remote asset list and requires the exact five-file combined88+ pre-signature set. It deliberately leaves the release as a draft.89+90+The operator next attaches the offline Sigil `.minisig`. The **Sigil Release**91+`promote-signed-draft` operation requires exactly all six Portal and Sigil92+assets, re-verifies the Sigil signature/archive/provenance and Portal93+digest/manifest against the same tag, verifies every Portal asset's attestation94+against the exact Portal workflow, source tag, and source commit, mounts the95+downloaded DMG on a native arm64 macOS runner, repeats the pinned Developer ID,96+Gatekeeper, and stapling checks, and only then publishes the prerelease with97+final notes.98+99+If signing, upload, or remote verification fails, the shared release remains a100+draft for diagnosis. Do not replace assets on an existing tag; fix the source,101+delete an unpublished failed draft after review, and create a new version/tag102+when provenance changed.103+104+For a local Apple-credentialed rehearsal on an arm64 Mac, use an empty directory105+outside the repository:106+107+```bash108+scripts/package-macos-client.sh \109+ --release-tag v0.3.0-alpha.1 \110+ --output-dir /absolute/empty/release-directory111+```112+113+## Enable the reviewed website download114+115+The checked-in `website/portal-release.json` starts with `available: false` and116+contains no URL. After the prerelease is promoted:117+118+1. Download all three GitHub assets on a separate machine.119+2. Verify the digest file against the DMG and inspect the JSON manifest.120+3. Verify Gatekeeper again on the downloaded DMG.121+4. Submit a normal reviewed change that replaces only the `macos-arm64` entry122+ with `available: true` and these exact fields:123+124+```json125+{126+ "architecture": "arm64",127+ "asset": "Portal-0.3.0-alpha.1-arm64.dmg",128+ "available": true,129+ "checksum_asset": "Portal-0.3.0-alpha.1-arm64.dmg.sha256",130+ "checksum_url": "https://github.com/FelineStateMachine/goq/releases/download/v0.3.0-alpha.1/Portal-0.3.0-alpha.1-arm64.dmg.sha256",131+ "download_url": "https://github.com/FelineStateMachine/goq/releases/download/v0.3.0-alpha.1/Portal-0.3.0-alpha.1-arm64.dmg",132+ "manifest_asset": "Portal-0.3.0-alpha.1-arm64.json",133+ "manifest_url": "https://github.com/FelineStateMachine/goq/releases/download/v0.3.0-alpha.1/Portal-0.3.0-alpha.1-arm64.json",134+ "platform": "macos",135+ "release_tag": "v0.3.0-alpha.1",136+ "release_url": "https://github.com/FelineStateMachine/goq/releases/tag/v0.3.0-alpha.1",137+ "sha256": "REPLACE_WITH_VERIFIED_LOWERCASE_SHA256",138+ "verification": "developer-id+hardened-runtime+notarized+stapled+gatekeeper",139+ "version": "0.3.0-alpha.1"140+}141+```142+143+`scripts/verify-website.sh` validates the manifest and JavaScript gate before144+the existing protected `main` website deployment. A missing or invalid145+manifest, fetch failure, or unavailable entry leaves the anchor without an146+`href` and explains that no signed download is offered.147+148+To withdraw a download, submit a reviewed manifest change back to:149+150+```json151+{152+ "available": false,153+ "reason": "Signed Portal download temporarily unavailable."154+}155+```156+157+This disables the website link without substituting another artifact. GitHub158+release deletion or withdrawal and website rollback are separate reviewed159+operator actions.
A docs/public-alpha-uat.md +330 -0
@@ -0,0 +1,330 @@
1+# Public-alpha release-candidate UAT2+3+Issue #6 is a hardware acceptance gate, not a unit-test checkbox. This guide4+turns operator observations and diagnostics into a bounded evidence bundle for5+one published Sigil asset set, one published Portal asset set, and one immutable6+`vVERSION` tag. The harness validates supplied evidence; it does not run the7+hardware exercise, infer observations, or claim that the gate passed.8+9+Use the actual release candidates from a clean checkout of the exact tag. `HEAD`,10+a branch name, a locally rebuilt binary, a different DMG, or a moving ref is not11+accepted even if its source is equivalent. Initialization and verification must12+run on macOS because the Portal DMG is assessed with the platform security tools.13+They also require an authenticated GitHub CLI with network access to the public14+`FelineStateMachine/goq` repository; downloaded or mirrored files cannot be used15+as an offline substitute for the published release identity checks.16+17+## 1. Initialize the bundle18+19+Choose an absolute path on an encrypted operator-controlled volume. Do not put20+the evidence bundle in the repository or on the shared host.21+22+```bash23+uat_dir="$PWD-private/goq-uat-rc1"24+./scripts/public-alpha-uat.sh init \25+ --evidence-dir "$uat_dir" \26+ --release-tag v0.1.0-rc.1 \27+ --sigil-archive /absolute/path/sigil-v0.1.0-rc.1-bazzite-x86_64.tar.gz \28+ --sigil-bin /absolute/path/to/sigil \29+ --portal-assets /absolute/path/to/portal-v0.1.0-rc.1-assets30+```31+32+`init` creates a mode `0700` directory and an atomic, mode `0600`, tab-separated33+`manifest.tsv`. It records the resolved 40-character tagged commit, release tag,34+the hashes of the signed Sigil archive/checksum/signature/public key, the exact35+Sigil payload hash, all three Portal asset hashes, creation time, and the36+evidence freshness window. The default window is seven days;37+`--max-age-seconds` can set 3600 through 2592000 seconds.38+39+Before creating the bundle the harness performs all of these fail-closed checks:40+41+- `refs/tags/vVERSION` resolves exactly to the clean checkout's `HEAD`.42+- GitHub's `FelineStateMachine/goq` tag ref resolves to that same 40-character43+ commit, including through an annotated-tag chain, and the release carrying44+ that exact tag is published, non-draft, and marked as a prerelease.45+- The published release contains exactly the expected six Sigil and Portal46+ filenames. Every supplied file's size and SHA-256 must equal GitHub's uploaded47+ asset metadata; a locally valid or identically named substitution is rejected.48+- `scripts/verify-sigil-release.sh` validates the archive, checksum, detached49+ Minisign signature, source commit, and product contents against the reviewed50+ repository key at `release/sigil-minisign.pub`.51+- The supplied installed/tested `sigil` executable is byte-for-byte identical52+ to `payload/release/sigil` inside that verified archive.53+- The Portal directory contains exactly `Portal-VERSION-arm64.dmg`, its54+ `.dmg.sha256`, and `Portal-VERSION-arm64.json`.55+- `scripts/verify-portal-release.py assets` validates those three assets against56+ the same clean exact tag.57+- `gh attestation verify` validates all three Portal files against the exact58+ `FelineStateMachine/goq/.github/workflows/portal-release.yml` signer workflow,59+ `refs/tags/vVERSION`, tagged source commit, and GitHub-hosted runner boundary.60+- `scripts/verify-macos-portal-signature.sh` validates the DMG, Developer ID61+ signature, hardened runtime, notarization ticket, staple, Gatekeeper result,62+ bundle identity/version, arm64-only executable, containment boundary, and the63+ exact Apple TeamIdentifier pinned in `release/portal-apple-team-id.txt`.64+65+If the reviewed Sigil key is still the `unconfigured` sentinel, Minisign or66+macOS verification tools are unavailable, the Apple TeamIdentifier pin is67+invalid, the GitHub API or attestations cannot be verified, the macOS verifier68+is absent, or any verification cannot run, initialization fails. There is no69+candidate, offline, or test bypass in the UAT command.70+71+The bundle format is intentionally append-only at the command boundary. To72+replace a mistaken record, initialize a new bundle. Do not edit the manifest or73+normalized records.74+75+## 2. Evidence envelope76+77+Every input is a mode `0600` regular file at an absolute path. It begins with78+these fields, copied from `manifest.tsv` where applicable:79+80+```text81+uat_schema=goq-public-alpha-evidence-v282+evidence_kind=controller83+observed_at_unix=176000000084+git_commit=0123456789abcdef0123456789abcdef0123456785+release_tag=v0.1.0-rc.186+sigil_sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef87+portal_sha256=abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678988+```89+90+Use a current `date +%s` taken when that exercise finishes. An input is rejected91+when a required field is missing, duplicated, stale, future-dated, or bound to92+anything other than the initialized release candidates.93+94+Do not include node IDs, host/peer IDs, invitations, secrets, private keys, or95+identity seeds. The harness rejects recognizable secret-bearing fields before96+normalizing the allowlisted keys, caps each source at 1 MiB, and rejects97+symlinks and group/other-writable inputs.98+99+Record an observation only after its exercise has genuinely completed:100+101+```bash102+chmod 600 /absolute/path/to/controller.evidence103+./scripts/public-alpha-uat.sh record \104+ --evidence-dir "$uat_dir" \105+ --kind controller \106+ --file /absolute/path/to/controller.evidence107+```108+109+Supported kinds are `cold-boot`, `controller`, `mouse`, `soak`,110+`network-direct`, `network-relay`, `reconnect`, `second-client`, and the111+optional `loopback-preflight`.112+113+## 3. Required hardware exercises114+115+Append the listed kind-specific fields after the common envelope. `pass` means116+the operator actually observed that result with the initialized release117+candidates.118+119+### Physically headless cold boot120+121+Disconnect every display, remove any interactive SSH session, cold boot the122+Bazzite host, and wait before making the first SSH connection. Run the existing123+read-only inventory check with `--cold-boot`. Its output may be used directly124+after the common envelope; unrecognized inventory fields are omitted during125+normalization.126+127+```bash128+ssh tank@umpc 'cd /path/to/goq && ./scripts/bazzite-inventory.sh --cold-boot' \129+ >> /absolute/path/to/cold-boot.evidence130+```131+132+The evidence must contain:133+134+```text135+cold_boot_result=pass136+cold_boot_failure_count=0137+cold_boot_insufficient_count=0138+headless_connector_state=ok139+gaming_autologin_session=ok140+sigil_host_enabled=enabled141+sigil_host_active=active142+gamescope_pipewire_node=ok143+gamescope_before_first_ssh=ok144+sigil_unit_before_first_ssh=ok145+sigil_ready_before_first_ssh=ok146+```147+148+This proves Gamescope and Sigil became ready before first SSH, not merely that149+they were running after an operator repaired the boot.150+151+### Physical controller and neutral release152+153+Attach a physical controller to the Portal client and control an actual game154+for at least five minutes. Exercise every named control class. Disconnect or155+end the session while controls are active and observe the host returning both156+buttons and axes to neutral.157+158+```text159+physical_controller_attached_to_portal=pass160+actual_game_controlled=pass161+controller_coverage=abxy,dpad,sticks,triggers,shoulders,start-back162+neutral_release_on_disconnect=pass163+neutral_buttons=pass164+neutral_axes=pass165+session_seconds=300166+```167+168+### Mouse buttons in Gamescope/an actual game169+170+Pointer motion or hover changes are insufficient. In a bounded target slug,171+observe at least five attempts and confirm both buttons are consumed by the172+Gamescope target application or actual game:173+174+```text175+target_application=actual-game176+left_click_consumed=pass177+right_click_consumed=pass178+consumption_observed_in_target=pass179+click_attempts=10180+```181+182+### Sustained A/V and resource soak183+184+Run an uninterrupted session for at least 3600 seconds and collect at least 60185+samples. Report measured percentiles, not estimates:186+187+```text188+duration_seconds=3600189+samples=60190+capture_fps_p50=59.8191+presentation_fps_p50=59.2192+frame_interval_p95_ms=20193+hitch_p99_ms=40194+video_queue_p95_frames=1195+decode_queue_p95_frames=1196+audio_queue_p95_ms=50197+av_skew_p95_ms=30198+max_queue_age_p95_ms=45199+cpu_p95_percent=50200+gpu_p95_percent=70201+rss_p95_mib=512202+transport_drops=2203+frontend_drops=3204+audio_drops=0205+latency_first_window_p95_ms=30206+latency_last_window_p95_ms=33207+disconnects=0208+```209+210+The fail-closed bounds are: capture and presentation p50 at least 55 fps;211+frame interval p95 at most 25 ms; hitch p99 at most 50 ms; video and decoder212+queues p95 at most two frames; audio queue and maximum queue age p95 at most213+100 ms; A/V skew p95 at most 50 ms; CPU p95 at most 90%; GPU p95 at most 95%;214+RSS p95 at most 2048 MiB; and zero disconnects. Last-window latency p95 must be215+no more than 120% of the first window and may grow by no more than 5 ms. Drop216+counts remain visible evidence rather than being silently discarded.217+218+### Direct and difficult-NAT relay paths219+220+These are separate records and separate sessions of at least ten minutes. Do221+not relabel an ordinary direct connection as a relay exercise.222+223+`network-direct`:224+225+```text226+path_mode=direct227+nat_scenario=ordinary228+session_seconds=600229+rtt_p50_ms=10230+rtt_p95_ms=20231+input_ack_p95_ms=30232+presentation_latency_p95_ms=60233+packet_loss_percent=0.1234+```235+236+`network-relay` uses the same timing fields with:237+238+```text239+path_mode=relay240+nat_scenario=difficult241+```242+243+Both records reject packet loss over 5%. The relay run must be forced or244+observed under the documented difficult-NAT setup and confirmed as relayed by245+Portal diagnostics.246+247+### Reconnect and second-client admission248+249+Perform at least ten reconnects with the exact candidates. Each must restore250+the intended session state and resume from a keyframe:251+252+```text253+reconnect_cycles=10254+reconnect_successes=10255+reconnect_failures=0256+state_preserved=pass257+keyframe_recovery_p95_ms=900258+```259+260+Keyframe recovery p95 must not exceed 2000 ms.261+262+While the authorized primary session remains playable, attempt a second client263+at least three times:264+265+```text266+second_client_attempts=3267+second_client_rejections=3268+authorized_primary_uninterrupted=pass269+rejection_reason=active-client270+```271+272+## 4. Optional loopback preflight273+274+The existing loopback proof is useful preflight evidence, but it does not275+replace any physical gate. Run it from the exact committed tree using release276+mode. Its output includes an operationally sensitive `node_id`, so remove that277+line before ingestion and add the common envelope:278+279+```bash280+./scripts/loopback-proof.sh --profile release \281+ | sed '/^node_id=/d' \282+ >> /absolute/path/to/loopback-preflight.evidence283+chmod 600 /absolute/path/to/loopback-preflight.evidence284+```285+286+The normalized record requires:287+288+```text289+loopback_proof=ok290+profile=release291+host_sha256=<same value as sigil_sha256>292+active_client_rejection=ok293+reconnect_cycles=3294+cleanup=ok295+```296+297+The harness ingests the output by value and never mutates the Bazzite host.298+299+## 5. Verify and retain300+301+Verification requires the exact same signed asset sets and tested Sigil302+executable again. It reruns every release, Minisign, and macOS platform check;303+it also reruns the live GitHub release/tag/digest and Portal provenance checks.304+Stored hashes alone are not sufficient:305+306+```bash307+./scripts/public-alpha-uat.sh verify \308+ --evidence-dir "$uat_dir" \309+ --sigil-archive /absolute/path/sigil-v0.1.0-rc.1-bazzite-x86_64.tar.gz \310+ --sigil-bin /absolute/path/to/sigil \311+ --portal-assets /absolute/path/to/portal-v0.1.0-rc.1-assets312+```313+314+Success prints `public_alpha_uat=pass` plus the release tag, commit, artifact hashes, and315+required gate count. Verification rejects missing records, expired timestamps,316+changed tags, wrong artifacts, modified normalized evidence, unsafe modes,317+symlinks, unpublished or non-prerelease releases, mismatched remote asset318+digests, invalid Portal attestations, and unexpected bundle files.319+320+That output means the supplied bundle satisfies the machine-checkable contract.321+It is not evidence that an exercise occurred unless the inputs came from the322+real operator run. Preserve the verified directory read-only as release323+evidence, separately from publish credentials and product artifacts.324+325+Run the fixture suite before changing the contract:326+327+```bash328+shellcheck scripts/public-alpha-uat.sh scripts/tests/public-alpha-uat.sh329+./scripts/tests/public-alpha-uat.sh330+```
M docs/public-release-delivery.md +91 -3
@@ -53,19 +53,107 @@ either provision a pinned verifier with its own hard-coded digest or make the
5353 verifier prerequisite explicit; it must never silently downgrade to SHA-only5454 verification.5555 56+## Sigil candidate and offline signing ceremony57+58+The repository stores only the reviewed public key in59+`release/sigil-minisign.pub`. Its committed `unconfigured` sentinel keeps every60+promotion fail-closed until the offline publisher key exists. Never replace the61+sentinel with a temporary or CI-generated key.62+63+For an immutable tag that exactly matches `crates/sigil-host/Cargo.toml`, run64+the **Sigil Release** workflow with `build-candidate`. The protected `main`65+environment builds clean tagged source, runs the complete repository gate,66+creates exactly these unsigned draft assets, and retains the same bytes as a67+short-lived Actions artifact:68+69+- `sigil-$tag-bazzite-x86_64.tar.gz`70+- `sigil-$tag-bazzite-x86_64.tar.gz.sha256`71+72+Move those bytes to the offline publisher machine. After independently73+confirming the tag commit and reviewed public key, sign without exporting the74+secret key or its passphrase:75+76+```bash77+scripts/sign-bazzite-release.sh \78+ --tag "$tag" \79+ --archive "/absolute/offline/path/sigil-$tag-bazzite-x86_64.tar.gz" \80+ --source-commit "$tag_commit" \81+ --minisign-key /absolute/offline/path/sigil-release.key \82+ --public-key-file /absolute/offline/path/sigil-minisign.pub83+```84+85+Before promotion, run the **Portal release** workflow for the same tag. It86+requires the untouched two-asset Sigil draft, attaches exactly the signed and87+notarized Portal DMG, checksum, and manifest, verifies the five-asset combined88+draft, and leaves it unpublished. Transfer only the resulting Sigil `.minisig`89+back to the online release operator and attach it to that draft. Do not use90+`--clobber` for any asset.91+92+Run **Sigil Release** again with `promote-signed-draft`. It requires the exact93+six-asset public-alpha set, verifies the detached Sigil signature with the94+committed public key, checks the outer digest, inspects the archive without95+extraction, binds both products to the same tag and commit, re-verifies the96+Portal digest and release manifest, then mounts the downloaded DMG on a native97+arm64 macOS runner and repeats Developer ID, hardened-runtime, Gatekeeper,98+stapling, architecture, identifier, and payload checks. Only then does it99+replace the incomplete draft notes and publish the prerelease. The workflow has100+no input, environment, or secret capable of101+receiving the offline secret key.102+103+`scripts/package-bazzite-release.sh` intentionally cannot sign. Its product104+mode requires a clean worktree, an existing tag resolving exactly to `HEAD`, a105+tag equal to Sigil's Cargo version, and the stable public asset name. The two106+development flags are an inseparable escape hatch and cannot claim a release107+tag. `scripts/verify-sigil-release.sh` is the common candidate, offline, and108+promotion verifier.109+110+## Public Bazzite acceptance record111+112+Repository tests exercise a complete package fixture through fresh install,113+same-release rerun, upgrade, rollback, identity/config preservation, hostile114+payload rejection, and proof that no `systemctl start`, `restart`, or `enable`115+was attempted. That is not a substitute for the public-command gate.116+117+Closing issue #4 still requires operator evidence from clean Bazzite using two118+distinct signed candidates. Record, without node IDs or credentials:119+120+1. The Bazzite image/version, x86_64 architecture, public bootstrap SHA-256,121+ release asset SHA-256, and release tag/commit.122+2. A clean `curl -fsSL https://goq.sh/install-sigil | bash` installation with123+ service active/enabled state captured before and after.124+3. An idempotent rerun with unchanged `current`, `previous`, identity, and host125+ configuration.126+4. Promotion to the second signed candidate followed by the same public command,127+ proving new `current`, old `previous`, and no implicit service interruption.128+5. `sigil-spark-host-rollback` without `--restart`, proving the links reverse,129+ all release checksums revalidate, and service state remains unchanged.130+56131 ## Portal signing boundary57132 58133 The existing macOS package gate requires a Developer ID Application identity,59134 hardened runtime, notarization, stapling, strict Gatekeeper assessment, and a60-release build without `demo-direct-node`. Store the certificate and App Store61-Connect credentials only in a separately protected GitHub `release`62-environment with required reviewers.135+release build without `demo-direct-node`. Store the certificate and136+notarization credentials only in the protected GitHub `main` environment with137+required reviewers. The release workflow uses those secrets only in its macOS138+arm64 publication job.139+140+Pin the public Apple TeamIdentifier in `release/portal-apple-team-id.txt` before141+the first tag. The protected secret, produced app signature, promotion verifier,142+and UAT verifier must all equal that committed value. The Portal tag-ref build143+also emits GitHub artifact attestations; promotion requires each of the three144+Portal assets to verify against the exact workflow, tag ref, and source commit.63145 64146 The first release automation must verify that the git tag, every Cargo package65147 version, Tauri version, artifact filenames, and manifests agree before it66148 publishes the GitHub Release. Upload every required asset to a draft release,67149 verify the complete set, and only then mark it as a prerelease.68150 151+goq.sh reads `website/portal-release.json`, which is deliberately committed as152+unavailable until an operator verifies the promoted prerelease and submits a153+reviewed manifest change. Invalid, missing, or unavailable manifest data never154+falls back to a development build. See the155+[Portal release runbook](portal-release.md) for the issue #5 procedure.156+69157 ## Tracked gates70158 71159 - [Capability tickets and Portal onboarding](https://github.com/FelineStateMachine/goq/issues/3)
A portal/enrollment-ui.test.mjs +37 -0
@@ -0,0 +1,37 @@
1+import assert from 'node:assert/strict';2+import { readFile } from 'node:fs/promises';3+import test from 'node:test';4+5+const html = await readFile(new URL('./index.html', import.meta.url), 'utf8');6+const main = await readFile(new URL('./main.js', import.meta.url), 'utf8');7+8+test('onboarding and confirmation actions are controller reachable', () => {9+ for (const id of ['enrollment-pin', 'derive-portal-id', 'choose-invitation', 'confirm-invitation', 'reset-enrollment', 'reset-enrollment-intro', 'confirm-reset-enrollment']) {10+ assert.match(html, new RegExp(`id="${id}"[^>]*data-controller-focus`));11+ }12+ assert.match(main, /const invitation = document\.getElementById\('invitation-overlay'\)/);13+ assert.match(main, /const enrollment = document\.getElementById\('enrollment-overlay'\)/);14+ assert.match(main, /const resetEnrollment = document\.getElementById\('reset-enrollment-overlay'\)/);15+});16+17+test('disconnected intro keeps enrollment reset inside the active controller scope', () => {18+ const introStart = html.indexOf('<div class="overlay-screen hidden" id="intro">');19+ const topbarStart = html.indexOf('<div class="topbar">');20+ assert.notEqual(introStart, -1);21+ assert.ok(topbarStart > introStart);22+ const intro = html.slice(introStart, topbarStart);23+ assert.match(intro, /id="reset-enrollment-intro"[^>]*data-controller-focus/);24+ assert.match(main, /getElementById\('reset-enrollment-intro'\)\.classList\.toggle\('hidden', !enrollmentReady\)/);25+});26+27+test('enrollment reset is explicit and native', () => {28+ assert.match(html, /Only continue after revoking enrollment on Sigil/);29+ assert.match(main, /portal_reset_enrollment/);30+ assert.match(main, /expectedHostNodeId: hostNodeId/);31+});32+33+test('raw invitation bytes stay in native commands instead of the DOM', () => {34+ assert.doesNotMatch(html, /invitation-token|goq-invite-v1/);35+ assert.match(main, /portal_import_invitation_file/);36+ assert.doesNotMatch(main, /readAsText|FileReader/);37+});
A portal/enrollment.mjs +62 -0
@@ -0,0 +1,62 @@
1+const NODE_ID_PATTERN = /^[0-9a-f]{64}$/;2+const GRANTS = Object.freeze(['view', 'pointer_keyboard', 'gamepad']);3+4+export function shortPeerFingerprint(nodeId) {5+ if (typeof nodeId !== 'string' || !NODE_ID_PATTERN.test(nodeId)) {6+ throw new TypeError('node ID must be 64 lowercase hexadecimal characters');7+ }8+ return `${nodeId.slice(0, 8)}…${nodeId.slice(-8)}`;9+}10+11+export function normalizeInvitationSummary(value, nowUnix = Math.floor(Date.now() / 1000)) {12+ if (!value || typeof value !== 'object' || Array.isArray(value)) {13+ throw new TypeError('invitation summary must be an object');14+ }15+ const allowed = new Set(['host_node_id', 'peer_node_id', 'expires_at_unix', 'grants']);16+ for (const key of Object.keys(value)) {17+ if (!allowed.has(key)) throw new TypeError(`unknown invitation summary field: ${key}`);18+ }19+ if (!NODE_ID_PATTERN.test(value.host_node_id || '')) throw new TypeError('invalid host node ID');20+ if (!NODE_ID_PATTERN.test(value.peer_node_id || '')) throw new TypeError('invalid peer node ID');21+ if (!Number.isSafeInteger(value.expires_at_unix) || value.expires_at_unix <= nowUnix) {22+ throw new TypeError('invitation is expired or has an invalid expiry');23+ }24+ if (!Array.isArray(value.grants) || value.grants.length === 0 || value.grants.length > GRANTS.length) {25+ throw new TypeError('invitation grants must be a non-empty bounded list');26+ }27+ const grants = [];28+ for (const grant of value.grants) {29+ if (!GRANTS.includes(grant) || grants.includes(grant)) {30+ throw new TypeError('invitation contains an unknown or duplicate grant');31+ }32+ grants.push(grant);33+ }34+ return Object.freeze({35+ hostNodeId: value.host_node_id,36+ peerNodeId: value.peer_node_id,37+ hostFingerprint: shortPeerFingerprint(value.host_node_id),38+ peerFingerprint: shortPeerFingerprint(value.peer_node_id),39+ expiresAtUnix: value.expires_at_unix,40+ grants: Object.freeze(grants),41+ });42+}43+44+export function grantLabel(grant) {45+ switch (grant) {46+ case 'view': return 'view stream';47+ case 'pointer_keyboard': return 'keyboard + mouse';48+ case 'gamepad': return 'gamepad';49+ default: throw new TypeError(`unknown invitation grant: ${grant}`);50+ }51+}52+53+export function formatInvitationExpiry(expiresAtUnix, nowUnix = Math.floor(Date.now() / 1000)) {54+ if (!Number.isSafeInteger(expiresAtUnix) || !Number.isSafeInteger(nowUnix)) {55+ throw new TypeError('expiry values must be integer Unix seconds');56+ }57+ const remaining = expiresAtUnix - nowUnix;58+ if (remaining <= 0) return 'expired';59+ if (remaining < 120) return 'expires in under 2 minutes';60+ const minutes = Math.ceil(remaining / 60);61+ return `expires in ${minutes} minutes`;62+}
A portal/enrollment.test.mjs +42 -0
@@ -0,0 +1,42 @@
1+import assert from 'node:assert/strict';2+import test from 'node:test';3+4+import {5+ formatInvitationExpiry,6+ grantLabel,7+ normalizeInvitationSummary,8+ shortPeerFingerprint,9+} from './enrollment.mjs';10+11+const HOST = '12'.repeat(32);12+const PEER = 'ab'.repeat(32);13+14+test('normalizes only a bounded public invitation summary', () => {15+ const summary = normalizeInvitationSummary({16+ host_node_id: HOST,17+ peer_node_id: PEER,18+ expires_at_unix: 1_100,19+ grants: ['view', 'gamepad'],20+ }, 1_000);21+ assert.equal(summary.hostFingerprint, '12121212…12121212');22+ assert.equal(summary.peerFingerprint, 'abababab…abababab');23+ assert.deepEqual(summary.grants, ['view', 'gamepad']);24+ assert.equal('token' in summary, false);25+});26+27+test('rejects expired, malformed, duplicate, unknown, and raw-token-bearing summaries', () => {28+ const base = { host_node_id: HOST, peer_node_id: PEER, expires_at_unix: 1_100, grants: ['view'] };29+ assert.throws(() => normalizeInvitationSummary({ ...base, expires_at_unix: 1_000 }, 1_000));30+ assert.throws(() => normalizeInvitationSummary({ ...base, host_node_id: 'nope' }, 1_000));31+ assert.throws(() => normalizeInvitationSummary({ ...base, grants: ['view', 'view'] }, 1_000));32+ assert.throws(() => normalizeInvitationSummary({ ...base, grants: ['admin'] }, 1_000));33+ assert.throws(() => normalizeInvitationSummary({ ...base, token: 'secret' }, 1_000));34+});35+36+test('formats fingerprints, grants, and bounded expiry copy', () => {37+ assert.equal(shortPeerFingerprint(PEER), 'abababab…abababab');38+ assert.equal(grantLabel('pointer_keyboard'), 'keyboard + mouse');39+ assert.equal(formatInvitationExpiry(1_030, 1_000), 'expires in under 2 minutes');40+ assert.equal(formatInvitationExpiry(1_121, 1_000), 'expires in 3 minutes');41+ assert.equal(formatInvitationExpiry(999, 1_000), 'expired');42+});
M portal/index.html +60 -1
@@ -7,6 +7,63 @@
77 <link rel="stylesheet" href="./style.css" />88 </head>99 <body>10+ <div class="overlay-screen hidden" id="enrollment-overlay" role="dialog" aria-modal="true" aria-labelledby="enrollment-title">11+ <div class="overlay-content enrollment-content">12+ <h1 id="enrollment-title">mark this portal</h1>13+ <p id="enrollment-desc">Use the security key once to reveal this Portal's peer ID, then create a one-time invitation on the Sigil host.</p>14+ <div class="overlay-form" id="enrollment-pin-form">15+ <input class="overlay-input" id="enrollment-pin" data-controller-focus data-pin-target="enrollment-pin" type="password" inputmode="numeric" maxlength="64" placeholder="PIN" autocomplete="off" />16+ <button class="overlay-btn controller-pin-btn" id="controller-pin-enrollment" data-controller-focus onclick="openPinPad('enrollment-pin', this)">D-pad PIN</button>17+ </div>18+ <div class="overlay-actions">19+ <button class="overlay-btn primary" id="derive-portal-id" data-controller-focus onclick="derivePortalIdentity()">show portal id</button>20+ </div>21+ <div class="enrollment-peer hidden" id="enrollment-peer-wrap">22+ <span>portal peer</span>23+ <output id="enrollment-peer-id"></output>24+ </div>25+ <div class="overlay-actions">26+ <button class="overlay-btn" id="choose-invitation" data-controller-focus onclick="chooseInvitationFile()">open invitation</button>27+ </div>28+ <div class="overlay-status" id="enrollment-status"></div>29+ <p class="controller-hint">D-pad / left stick to move · A select · B close PIN pad</p>30+ </div>31+ </div>32+33+ <div class="overlay-screen hidden" id="invitation-overlay" role="dialog" aria-modal="true" aria-labelledby="invitation-title">34+ <div class="overlay-content invitation-content">35+ <h1 id="invitation-title">open this rift?</h1>36+ <p>Confirm the exact host, Portal peer, and controls before enrolling.</p>37+ <dl class="invitation-summary">38+ <div><dt>sigil</dt><dd id="invitation-host"></dd></div>39+ <div><dt>portal</dt><dd id="invitation-peer"></dd></div>40+ <div><dt>expires</dt><dd id="invitation-expiry"></dd></div>41+ <div><dt>grants</dt><dd id="invitation-grants"></dd></div>42+ </dl>43+ <div class="overlay-actions">44+ <button class="overlay-btn primary" id="confirm-invitation" data-controller-focus onclick="confirmInvitation()">enroll</button>45+ <button class="overlay-btn" data-controller-focus onclick="cancelInvitation()">cancel</button>46+ </div>47+ <div class="overlay-status" id="invitation-status"></div>48+ </div>49+ </div>50+51+ <div class="overlay-screen hidden" id="reset-enrollment-overlay" role="alertdialog" aria-modal="true" aria-labelledby="reset-enrollment-title">52+ <div class="overlay-content invitation-content">53+ <h1 id="reset-enrollment-title">erase this portal mark?</h1>54+ <p>Only continue after revoking enrollment on Sigil. Portal will forget this host and require a new invitation.</p>55+ <div class="enrollment-peer">56+ <span>sigil</span>57+ <output id="reset-enrollment-host"></output>58+ </div>59+ <div class="overlay-actions">60+ <button class="overlay-btn danger" id="confirm-reset-enrollment" data-controller-focus onclick="confirmEnrollmentReset()">erase enrollment</button>61+ <button class="overlay-btn" data-controller-focus onclick="cancelEnrollmentReset()">keep enrollment</button>62+ </div>63+ <div class="overlay-status" id="reset-enrollment-status"></div>64+ </div>65+ </div>66+1067 <div class="overlay-screen hidden" id="tap-overlay" style="z-index: 200;">1168 <div class="overlay-content">1269 <div class="flow-steps" aria-hidden="true">
@@ -58,7 +115,7 @@
58115 <span class="flow-step">03 play</span>59116 </div>60117 <h1>portal</h1>61- <p id="intro-desc">enter the host security-key pin</p>118+ <p id="intro-desc">enter the Portal security-key pin</p>62119 <div class="dev-warning hidden" id="dev-connect-warning" role="alert">63120 DEVELOPMENT DIRECT NODE<br />PASSKEY LOOKUP BYPASSED · NOT CLIENT AUTHORIZATION64121 </div>
@@ -68,6 +125,7 @@
68125 </div>69126 <div class="overlay-actions">70127 <button class="overlay-btn primary" id="intro-connect" data-controller-focus onclick="introConnect()">connect</button>128+ <button class="overlay-btn danger hidden" id="reset-enrollment-intro" data-controller-focus onclick="openEnrollmentReset()">reset enrollment</button>71129 </div>72130 <div class="overlay-status" id="intro-status"></div>73131 <p class="controller-hint" id="intro-controller-hint">Controller optional · keyboard and mouse remain available</p>
@@ -134,6 +192,7 @@
134192 <div class="info-row"><span class="info-label">extensions</span><span class="info-value" id="fido-extensions">—</span></div>135193 <div class="info-row"><span class="info-label">pin retries</span><span class="info-value" id="fido-pin">—</span></div>136194 <button class="link-btn panel-action" data-controller-focus onclick="scanFido()">rescan</button>195+ <button class="link-btn panel-action hidden" id="reset-enrollment" data-controller-focus onclick="openEnrollmentReset()">reset enrollment</button>137196 </div>138197 <div class="panel-section" id="stream-section">139198 <h3><span class="hash" aria-hidden="true">#</span> stream</h3>
M portal/main.js +186 -3
@@ -88,11 +88,20 @@ import {
8888 fitStreamSurface,8989 } from './window-geometry.mjs';9090 import { buildVideoDecoderConfig } from './video-decoder-config.mjs';91+import {92+ formatInvitationExpiry,93+ grantLabel,94+ normalizeInvitationSummary,95+ shortPeerFingerprint,96+} from './enrollment.mjs';9197 9298 let controlMode = false;9399 let connected = false;94100 let connecting = false;95101 let developmentConnectionMode = false;102+let enrollmentReady = false;103+let pendingInvitationSummary = null;104+let currentEnrollmentStatus = null;96105 let disconnecting = false;97106 let inputCapabilities = {98107 relativePointer: false,
@@ -250,6 +259,152 @@ function showTap() {
250259 }251260 function hideTap() { document.getElementById('tap-overlay').classList.add('hidden'); }252261 262+function hideEnrollment() { document.getElementById('enrollment-overlay').classList.add('hidden'); }263+function showEnrollment() {264+ if (!document.getElementById('invitation-overlay').classList.contains('hidden')) return;265+ hideIntro();266+ document.getElementById('enrollment-overlay').classList.remove('hidden');267+ setTimeout(() => document.getElementById('enrollment-pin')?.focus(), 50);268+}269+270+function showInvitationSummary(value) {271+ const summary = normalizeInvitationSummary(value);272+ pendingInvitationSummary = summary;273+ hideEnrollment();274+ document.getElementById('invitation-host').textContent = summary.hostFingerprint;275+ document.getElementById('invitation-peer').textContent = summary.peerFingerprint;276+ document.getElementById('invitation-expiry').textContent = formatInvitationExpiry(summary.expiresAtUnix);277+ document.getElementById('invitation-grants').textContent = summary.grants.map(grantLabel).join(' · ');278+ document.getElementById('invitation-status').textContent = '';279+ document.getElementById('invitation-overlay').classList.remove('hidden');280+ setTimeout(() => setControllerFocus(document.getElementById('confirm-invitation')), 0);281+}282+283+async function refreshEnrollment() {284+ if (developmentConnectionMode) {285+ enrollmentReady = true;286+ hideEnrollment();287+ showIntro();288+ return true;289+ }290+ const status = await invoke('portal_enrollment_status');291+ currentEnrollmentStatus = status;292+ enrollmentReady = status.enrolled === true;293+ document.getElementById('reset-enrollment').classList.toggle('hidden', !enrollmentReady);294+ document.getElementById('reset-enrollment-intro').classList.toggle('hidden', !enrollmentReady);295+ if (status.pending) {296+ showInvitationSummary(status.pending);297+ return enrollmentReady;298+ }299+ if (enrollmentReady) {300+ hideEnrollment();301+ showIntro();302+ } else {303+ showEnrollment();304+ }305+ return enrollmentReady;306+}307+308+function openEnrollmentReset() {309+ if (!currentEnrollmentStatus?.enrolled || !currentEnrollmentStatus.host_node_id) return;310+ togglePanel(false);311+ document.getElementById('reset-enrollment-host').textContent =312+ shortPeerFingerprint(currentEnrollmentStatus.host_node_id);313+ document.getElementById('reset-enrollment-status').textContent = '';314+ document.getElementById('reset-enrollment-overlay').classList.remove('hidden');315+ setTimeout(() => setControllerFocus(document.getElementById('confirm-reset-enrollment')), 0);316+}317+318+function cancelEnrollmentReset() {319+ document.getElementById('reset-enrollment-overlay').classList.add('hidden');320+}321+322+async function confirmEnrollmentReset() {323+ const hostNodeId = currentEnrollmentStatus?.host_node_id;324+ if (!hostNodeId) return;325+ const status = document.getElementById('reset-enrollment-status');326+ status.className = 'overlay-status pending';327+ status.textContent = 'erasing enrollment';328+ try {329+ await invoke('portal_reset_enrollment', { expectedHostNodeId: hostNodeId });330+ enrollmentReady = false;331+ currentEnrollmentStatus = null;332+ document.getElementById('reset-enrollment').classList.add('hidden');333+ document.getElementById('reset-enrollment-intro').classList.add('hidden');334+ document.getElementById('reset-enrollment-overlay').classList.add('hidden');335+ showEnrollment();336+ } catch (error) {337+ status.className = 'overlay-status err';338+ status.textContent = String(error);339+ }340+}341+342+async function derivePortalIdentity() {343+ const pin = document.getElementById('enrollment-pin').value.trim();344+ const status = document.getElementById('enrollment-status');345+ if (!pin) {346+ status.className = 'overlay-status err';347+ status.textContent = 'enter pin';348+ return;349+ }350+ status.className = 'overlay-status pending';351+ status.textContent = 'tap the security key';352+ showTap();353+ try {354+ const result = await invoke('key_derive_identity', { pin });355+ if (result.error || !result.node_id) throw new Error(result.error || 'identity derivation failed');356+ document.getElementById('enrollment-peer-id').textContent = result.node_id;357+ document.getElementById('enrollment-peer-wrap').classList.remove('hidden');358+ status.className = 'overlay-status ok';359+ status.textContent = `portal ${shortPeerFingerprint(result.node_id)} ready for an invitation`;360+ } catch (error) {361+ status.className = 'overlay-status err';362+ status.textContent = String(error);363+ } finally {364+ hideTap();365+ }366+}367+368+async function chooseInvitationFile() {369+ const status = document.getElementById('enrollment-status');370+ try {371+ const path = await window.__TAURI__.dialog.open({372+ multiple: false,373+ directory: false,374+ filters: [{ name: 'goq invitation', extensions: ['goq-invite'] }],375+ });376+ if (!path) return;377+ const summary = await invoke('portal_import_invitation_file', { path });378+ showInvitationSummary(summary);379+ } catch (error) {380+ status.className = 'overlay-status err';381+ status.textContent = String(error);382+ }383+}384+385+async function confirmInvitation() {386+ if (!pendingInvitationSummary) return;387+ const status = document.getElementById('invitation-status');388+ status.className = 'overlay-status pending';389+ status.textContent = 'saving enrollment';390+ try {391+ await invoke('portal_confirm_invitation');392+ pendingInvitationSummary = null;393+ document.getElementById('invitation-overlay').classList.add('hidden');394+ await refreshEnrollment();395+ } catch (error) {396+ status.className = 'overlay-status err';397+ status.textContent = String(error);398+ }399+}400+401+async function cancelInvitation() {402+ try { await invoke('portal_cancel_invitation'); } catch (error) { console.warn(error); }403+ pendingInvitationSummary = null;404+ document.getElementById('invitation-overlay').classList.add('hidden');405+ showEnrollment();406+}407+253408 async function checkDevelopmentConnectionMode() {254409 const mode = await invoke('development_connection_mode');255410 developmentConnectionMode = mode.enabled;
@@ -275,6 +430,10 @@ async function checkDevelopmentConnectionMode() {
275430 // ─── Intro actions ────────────────────────────────────────────────────────────276431 async function introConnect() {277432 if (connecting || connected) return;433+ if (!developmentConnectionMode && !enrollmentReady) {434+ showEnrollment();435+ return;436+ }278437 const pin = document.getElementById('intro-pin').value.trim();279438 const status = document.getElementById('intro-status');280439 if (!developmentConnectionMode && !pin) {
@@ -2296,11 +2455,12 @@ function pinPadClear() {
22962455 renderPinPad();22972456 }22982457 2299-for (const id of ['intro-pin', 'pin-input']) {2458+for (const id of ['enrollment-pin', 'intro-pin', 'pin-input']) {23002459 document.getElementById(id).addEventListener('keydown', (event) => {23012460 if (event.key !== 'Enter') return;23022461 event.preventDefault();2303- if (id === 'intro-pin') void introConnect();2462+ if (id === 'enrollment-pin') void derivePortalIdentity();2463+ else if (id === 'intro-pin') void introConnect();23042464 else void connectHost();23052465 });23062466 }
@@ -2334,6 +2494,12 @@ function controllerScope() {
23342494 if (!tap.classList.contains('hidden')) return tap;23352495 const pinPad = document.getElementById('pin-pad-overlay');23362496 if (!pinPad.classList.contains('hidden')) return pinPad;2497+ const resetEnrollment = document.getElementById('reset-enrollment-overlay');2498+ if (!resetEnrollment.classList.contains('hidden')) return resetEnrollment;2499+ const invitation = document.getElementById('invitation-overlay');2500+ if (!invitation.classList.contains('hidden')) return invitation;2501+ const enrollment = document.getElementById('enrollment-overlay');2502+ if (!enrollment.classList.contains('hidden')) return enrollment;23372503 const intro = document.getElementById('intro');23382504 if (!intro.classList.contains('hidden')) return intro;23392505 const panel = document.getElementById('panel');
@@ -2385,6 +2551,8 @@ function activateControllerFocus() {
23852551 function controllerBack() {23862552 if (!document.getElementById('pin-pad-overlay').classList.contains('hidden')) {23872553 closePinPad();2554+ } else if (!document.getElementById('reset-enrollment-overlay').classList.contains('hidden')) {2555+ cancelEnrollmentReset();23882556 } else if (document.getElementById('panel').classList.contains('visible')) {23892557 togglePanel(false);23902558 }
@@ -2495,11 +2663,18 @@ listen('dev-connect-routing', (event) => {
24952663 console.warn('[development direct-node routing]', event.payload.warning, event.payload.host_node_id);24962664 });24972665 2666+listen('invitation-pending', (event) => {2667+ try { showInvitationSummary(event.payload); }2668+ catch (error) { console.error('invalid native invitation summary:', error); }2669+});2670+24982671 // ─── Expose to HTML onclick handlers ─────────────────────────────────────────24992672 Object.assign(window, {25002673 introConnect, connectHost, disconnect,25012674 scanFido, togglePanel, toggleControl, toggleAudioMute,25022675 openPinPad, closePinPad, pinPadDigit, pinPadBackspace, pinPadClear,2676+ openEnrollmentReset, cancelEnrollmentReset, confirmEnrollmentReset,2677+ derivePortalIdentity, chooseInvitationFile, confirmInvitation, cancelInvitation,25032678 sigilController: Object.freeze({25042679 getLatestState: () => controllerPublisher.latest,25052680 setObserver: (observer) => {
@@ -2518,7 +2693,15 @@ async function initialize() {
25182693 }25192694 updatePanelVisibility();25202695 updateControlUI();2521- showIntro();2696+ try {2697+ await refreshEnrollment();2698+ } catch (error) {2699+ console.error('enrollment status check failed:', error);2700+ showEnrollment();2701+ const status = document.getElementById('enrollment-status');2702+ status.className = 'overlay-status err';2703+ status.textContent = 'could not load enrollment';2704+ }25222705 if (!developmentConnectionMode) scanFido();25232706 requestAnimationFrame(pollControllers);25242707 }
M portal/style.css +48 -0
@@ -475,6 +475,50 @@ body.native-pointer-control * { cursor: none !important; }
475475 }476476 #intro .flow-step.active { color: var(--terracotta); }477477 #tap-overlay .flow-step.active { color: var(--ochre); }478+.enrollment-content,479+.invitation-content { width: 460px; }480+.enrollment-peer {481+ margin: 20px 0 4px;482+ padding: 12px;483+ border: 1px solid var(--border-strong);484+ background: var(--background);485+ font-family: var(--mono);486+ text-align: left;487+}488+.enrollment-peer.hidden { display: none; }489+.enrollment-peer span {490+ display: block;491+ margin-bottom: 7px;492+ color: var(--muted-fg);493+ font-size: 10px;494+ text-transform: uppercase;495+ letter-spacing: 0.08em;496+}497+.enrollment-peer output {498+ display: block;499+ overflow-wrap: anywhere;500+ color: var(--sand);501+ font-size: 11px;502+ user-select: text;503+ -webkit-user-select: text;504+}505+.invitation-summary {506+ margin: 18px 0;507+ border-block: 1px solid var(--border);508+ font-family: var(--mono);509+ font-size: 12px;510+ text-align: left;511+}512+.invitation-summary > div {513+ display: grid;514+ grid-template-columns: 86px minmax(0, 1fr);515+ gap: 12px;516+ padding: 9px 4px;517+ border-bottom: 1px solid var(--border);518+}519+.invitation-summary > div:last-child { border-bottom: 0; }520+.invitation-summary dt { color: var(--muted-fg); }521+.invitation-summary dd { color: var(--foreground); overflow-wrap: anywhere; }478522 .overlay-actions {479523 display: flex;480524 gap: 16px;
@@ -482,6 +526,10 @@ body.native-pointer-control * { cursor: none !important; }
482526 align-items: center;483527 margin-top: 14px;484528 }529+.overlay-btn.danger {530+ color: var(--destructive);531+ border-color: color-mix(in srgb, var(--destructive) 55%, var(--border));532+}485533 .shortcut-key {486534 color: var(--foreground);487535 }
A release/portal-apple-team-id.txt +1 -0
@@ -0,0 +1 @@
1+unconfigured
A release/sigil-minisign.pub +1 -0
@@ -0,0 +1 @@
1+unconfigured
M scripts/install-bazzite-package.sh +71 -2
@@ -45,7 +45,7 @@ done
4545 [[ "$(uname -s)" == Linux ]] || die "this package must be installed on Linux"4646 [[ "$(uname -m)" == x86_64 ]] || die "expected x86_64, got $(uname -m)"4747 [[ "$(id -u)" -ne 0 ]] || die "run as the dedicated gaming user, not root"48-for command in cmp diff flock ldd sha256sum systemctl systemd-analyze timeout; do48+for command in cmp diff flock ldd python3 sha256sum systemctl systemd-analyze timeout; do4949 command -v "$command" >/dev/null 2>&1 || die "$command is required"5050 done5151
@@ -191,7 +191,26 @@ activate_release_links() {
191191 }192192 # END release activation transaction helpers193193 194-install -d -m 0755 "$HOME/.local" "$HOME/.local/libexec" "$install_root" "$releases_root"194+ensure_owned_directory() {195+ local directory="$1"196+ local mode197+198+ if [[ -e "$directory" || -L "$directory" ]]; then199+ [[ -d "$directory" && ! -L "$directory" ]] || die "unsafe install directory: $directory"200+ [[ "$(stat -Lc '%u' "$directory")" -eq "$(id -u)" ]] \201+ || die "install directory is not owned by the current user: $directory"202+ mode="$(stat -Lc '%a' "$directory")"203+ (( (8#$mode & 8#022) == 0 )) \204+ || die "install directory is group/world writable: $directory"205+ else206+ install -d -m 0755 "$directory"207+ fi208+}209+210+[[ "$HOME" == /* && -d "$HOME" ]] || die "HOME must be an absolute directory"211+for directory in "$HOME/.local" "$HOME/.local/libexec" "$install_root" "$releases_root"; do212+ ensure_owned_directory "$directory"213+done195214 exec 9>"$lock_path"196215 flock -x 9197216
@@ -225,6 +244,56 @@ validate_release_tree() {
225244 cd "$path"226245 sha256sum -c SHA256SUMS227246 )247+ python3 - "$path/release-manifest.json" <<'PY'248+import json249+import pathlib250+import re251+import sys252+253+path = pathlib.Path(sys.argv[1])254+try:255+ manifest = json.loads(path.read_text())256+except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:257+ raise SystemExit(f"package install failed: release manifest is invalid: {error}")258+259+expected = {260+ "format": 2,261+ "product": "sigil-host",262+ "primary_executable": "sigil",263+ "compatibility_executable": "sigil-host",264+ "target": "x86_64-unknown-linux-gnu.2.17",265+ "profile": "release",266+ "features": ["default"],267+ "demo_direct_node": False,268+}269+for key, value in expected.items():270+ if manifest.get(key) != value:271+ raise SystemExit(f"package install failed: release manifest field {key} is invalid")272+273+version = manifest.get("version")274+commit = manifest.get("git_commit")275+release_tag = manifest.get("release_tag")276+asset_name = manifest.get("asset_name")277+release_kind = manifest.get("release_kind")278+if not isinstance(version, str) or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z][0-9A-Za-z.-]*)?", version) is None:279+ raise SystemExit("package install failed: release manifest version is invalid")280+if not isinstance(commit, str) or re.fullmatch(r"[0-9a-f]{40}", commit) is None:281+ raise SystemExit("package install failed: release manifest commit is invalid")282+if not isinstance(asset_name, str) or re.fullmatch(r"[A-Za-z0-9._-]+[.]tar[.]gz", asset_name) is None:283+ raise SystemExit("package install failed: release manifest asset name is invalid")284+if release_kind == "product-candidate":285+ if release_tag != f"v{version}" or asset_name != f"sigil-{release_tag}-bazzite-x86_64.tar.gz":286+ raise SystemExit("package install failed: product release identity is inconsistent")287+ if manifest.get("git_dirty") is not False:288+ raise SystemExit("package install failed: product release claims dirty source")289+ if manifest.get("binary_provenance") != "self-built-clean-head" or manifest.get("binary_provenance_verified") is not True:290+ raise SystemExit("package install failed: product binary provenance is invalid")291+elif release_kind == "development":292+ if release_tag != "development":293+ raise SystemExit("package install failed: development release claims a product tag")294+else:295+ raise SystemExit("package install failed: release kind is invalid")296+PY228297 for binary in sigil sigil-host sigil-probe; do229298 [[ -f "$path/$binary" && ! -L "$path/$binary" && -x "$path/$binary" ]] \230299 || die "$binary is not a regular executable"
M scripts/package-bazzite-release.sh +55 -22
@@ -9,7 +9,7 @@ probe_binary=""
99 output_path=""1010 allow_dirty=false1111 allow_unsigned=false12-minisign_secret_key=""12+release_tag=""1313 linux_target="x86_64-unknown-linux-gnu.2.17"1414 linux_output_target="x86_64-unknown-linux-gnu"1515
@@ -28,14 +28,15 @@ Options:
2828 --output PATH New .tar.gz bundle path (required; must not exist)2929 --host-binary PATH Development-only prebuilt generic Linux sigil3030 --probe-binary PATH Development-only prebuilt generic Linux sigil-probe31- --minisign-key PATH Create detached PACKAGE.minisig with this secret key31+ --release-tag TAG Immutable vVERSION tag (required in product mode)3232 --allow-dirty Permit development packaging from a dirty worktree3333 --allow-unsigned Permit a package without a detached publisher signature3434 --help Show this help3535 3636 The two prebuilt-binary options are an all-or-none development escape hatch and37-require both --allow-dirty and --allow-unsigned. Product packages never accept38-caller-supplied binaries.37+require both --allow-dirty and --allow-unsigned. Product mode emits a clean,38+unsigned release candidate. Sign it on the offline publisher machine with39+scripts/sign-bazzite-release.sh; this builder never receives the secret key.3940 EOF4041 }4142
@@ -70,9 +71,9 @@ while [[ $# -gt 0 ]]; do
7071 probe_binary="$2"7172 shift 27273 ;;73- --minisign-key)74- [[ $# -ge 2 ]] || die "--minisign-key requires a path"75- minisign_secret_key="$2"74+ --release-tag)75+ [[ $# -ge 2 ]] || die "--release-tag requires a value"76+ release_tag="$2"7677 shift 27778 ;;7879 --allow-dirty)
@@ -110,6 +111,21 @@ if [[ -n "$host_binary" || -n "$probe_binary" ]]; then
110111 caller_supplied_binaries=true111112 fi112113 114+development_mode=false115+if $allow_dirty || $allow_unsigned || $caller_supplied_binaries; then116+ development_mode=true117+ if ! $allow_dirty || ! $allow_unsigned; then118+ die "development packaging requires both --allow-dirty and --allow-unsigned"119+ fi120+ [[ -z "$release_tag" ]] || die "development packages must not claim a release tag"121+else122+ [[ "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z][0-9A-Za-z.-]*)?$ ]] \123+ || die "product mode requires --release-tag vVERSION"124+ expected_asset="sigil-$release_tag-bazzite-x86_64.tar.gz"125+ [[ "$(basename -- "$output_path")" == "$expected_asset" ]] \126+ || die "product output must be named $expected_asset"127+fi128+113129 [[ -x "$script_dir/install-bazzite-package.sh" ]] || die "package installer is missing"114130 [[ -x "$script_dir/rollback-bazzite-release.sh" ]] || die "rollback helper is missing"115131 [[ -f "$script_dir/sigil-host.service" ]] || die "systemd user unit is missing"
@@ -126,12 +142,18 @@ if [[ -n "$(git -C "$repo_dir" status --porcelain=v1)" ]]; then
126142 git_dirty=true127143 $allow_dirty || die "worktree is dirty; commit the release or pass --allow-dirty for a development package"128144 fi129-if [[ -z "$minisign_secret_key" ]]; then130- $allow_unsigned || die "publisher signature required; pass --minisign-key or explicit --allow-unsigned for development"131-else132- [[ "$minisign_secret_key" == /* && -f "$minisign_secret_key" && ! -L "$minisign_secret_key" ]] \133- || die "minisign secret key must be an absolute regular file"134- command -v minisign >/dev/null 2>&1 || die "minisign is required for --minisign-key"145+if ! $development_mode; then146+ git -C "$repo_dir" show-ref --verify --quiet "refs/tags/$release_tag" \147+ || die "release tag does not exist: $release_tag"148+ tag_commit="$(git -C "$repo_dir" rev-parse "refs/tags/$release_tag^{commit}" 2>/dev/null || true)"149+ [[ "$tag_commit" == "$git_commit" ]] \150+ || die "release tag $release_tag must resolve exactly to clean HEAD"151+fi152+product_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$repo_dir/crates/sigil-host/Cargo.toml" | head -n 1)"153+[[ -n "$product_version" ]] || die "could not read the Sigil product version"154+if ! $development_mode; then155+ [[ "$release_tag" == "v$product_version" ]] \156+ || die "release tag $release_tag does not match Sigil version v$product_version"135157 fi136158 137159 output_parent="$(dirname -- "$output_path")"
@@ -200,15 +222,23 @@ install -m 0644 "$script_dir/70-sigil-remote-input.rules" \
200222 install -m 0644 "$repo_dir/LICENSE" "$release_tree/LICENSE"201223 install -m 0755 "$script_dir/install-bazzite-package.sh" "$payload/install-bazzite-package.sh"202224 203-product_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$repo_dir/src-tauri/Cargo.toml" | head -n 1)"204-[[ -n "$product_version" ]] || die "could not read the product version"225+if ! $development_mode; then226+ manifest_release_tag="$release_tag"227+ manifest_asset_name="$(basename -- "$output_path")"228+ release_kind="product-candidate"229+else230+ manifest_release_tag="development"231+ manifest_asset_name="$(basename -- "$output_path")"232+ release_kind="development"233+fi205234 lock_sha256="$(sha256_file "$repo_dir/Cargo.lock")"206235 toolchain_sha256="$(sha256_file "$repo_dir/rust-toolchain.toml")"207236 zigbuild_version="$(cargo-zigbuild --version 2>/dev/null | head -n 1 || true)"208237 [[ -n "$zigbuild_version" ]] || zigbuild_version="unavailable"209238 python3 - "$release_tree/release-manifest.json" "$product_version" "$git_commit" \210239 "$git_dirty" "$lock_sha256" "$toolchain_sha256" "$zigbuild_version" \211- "$binary_provenance" "$binary_provenance_verified" "$linux_target" <<'PY'240+ "$binary_provenance" "$binary_provenance_verified" "$linux_target" \241+ "$manifest_release_tag" "$manifest_asset_name" "$release_kind" <<'PY'212242 import json213243 import pathlib214244 import sys
@@ -224,6 +254,9 @@ import sys
224254 binary_provenance,225255 binary_provenance_verified,226256 target,257+ release_tag,258+ asset_name,259+ release_kind,227260 ) = sys.argv[1:]228261 manifest = {229262 "format": 2,
@@ -242,6 +275,9 @@ manifest = {
242275 "cargo_zigbuild": zigbuild,243276 "binary_provenance": binary_provenance,244277 "binary_provenance_verified": binary_provenance_verified == "true",278+ "release_tag": release_tag,279+ "asset_name": asset_name,280+ "release_kind": release_kind,245281 }246282 pathlib.Path(path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")247283 PY
@@ -316,13 +352,10 @@ mv -- "$archive_tmp" "$output_path"
316352 317353 package_sha256="$(sha256_file "$output_path")"318354 printf '%s %s\n' "$package_sha256" "$(basename -- "$output_path")" >"$output_path.sha256"319-if [[ -n "$minisign_secret_key" ]]; then320- minisign -S -s "$minisign_secret_key" -m "$output_path" -x "$output_path.minisig" \321- -t "Sigil host $product_version" \322- -c "release $release_id"323- signature_status="detached-minisign"324-else355+if $development_mode; then325356 signature_status="absent-development"357+else358+ signature_status="pending-offline"326359 fi327360 328361 printf 'package=%s\n' "$output_path"
M scripts/package-macos-client.sh +67 -29
@@ -5,12 +5,14 @@ umask 077
55 66 usage() {77 cat <<'EOF'8-Usage: scripts/package-macos-client.sh --output-dir /absolute/path9- [--expected-arch arm64|x86_64]8+Usage: scripts/package-macos-client.sh --release-tag vVERSION9+ --output-dir /absolute/path1010 1111 Build and verify a distributable macOS DMG. This gate requires:1212 1313 - a clean worktree;14+ - a release tag resolving exactly to HEAD and matching every package version;15+ - an Apple Silicon runner and the aarch64-apple-darwin Rust target;1416 - APPLE_SIGNING_IDENTITY naming a Developer ID Application identity;1517 - Tauri notarization credentials (App Store Connect API or Apple ID);1618 - strict code-signing, hardened runtime, Gatekeeper, and stapling checks.
@@ -28,7 +30,7 @@ die() {
2830 script_dir="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)"2931 repo_dir="$(CDPATH='' cd -- "$script_dir/.." && pwd -P)"3032 output_dir=""31-expected_arch="$(uname -m)"33+release_tag=""3234 3335 while [[ $# -gt 0 ]]; do3436 case "$1" in
@@ -37,9 +39,9 @@ while [[ $# -gt 0 ]]; do
3739 output_dir="$2"3840 shift 23941 ;;40- --expected-arch)41- [[ $# -ge 2 ]] || die "--expected-arch requires an architecture"42- expected_arch="$2"42+ --release-tag)43+ [[ $# -ge 2 ]] || die "--release-tag requires a tag"44+ release_tag="$2"4345 shift 24446 ;;4547 --help|-h)
@@ -51,20 +53,41 @@ while [[ $# -gt 0 ]]; do
5153 done5254 5355 [[ "$(uname -s)" == Darwin ]] || die "this package must be built on macOS"56+[[ "$(uname -m)" == arm64 ]] || die "the first public Portal release is macOS arm64 only"5457 [[ -n "$output_dir" && "$output_dir" == /* ]] || die "--output-dir must be absolute"55-[[ "$expected_arch" == arm64 || "$expected_arch" == x86_64 ]] || die "unsupported expected architecture"58+[[ -n "$release_tag" ]] || die "--release-tag is required"5659 [[ -d "$output_dir" ]] || die "output directory does not exist"57-[[ -z "$(git -C "$repo_dir" status --porcelain=v1)" ]] || die "worktree must be clean"60+output_dir="$(CDPATH='' cd -- "$output_dir" && pwd -P)"61+case "$output_dir/" in62+ "$repo_dir/"*) die "--output-dir must be outside the source repository" ;;63+esac64+if find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit | grep -q .; then65+ die "output directory must be empty"66+fi5867 59-for command in cargo codesign hdiutil lipo plutil python3 security shasum spctl xcrun; do68+for command in cargo codesign find git grep hdiutil lipo plutil python3 rustup security sed shasum spctl xcrun; do6069 command -v "$command" >/dev/null 2>&1 || die "$command is required"6170 done6271 72+verifier="$script_dir/verify-portal-release.py"73+[[ -f "$verifier" ]] || die "Portal release verifier is missing"74+python3 "$verifier" source --repo-dir "$repo_dir" --release-tag "$release_tag" >/dev/null75+rustup target list --installed | grep -Fxq aarch64-apple-darwin \76+ || die "the aarch64-apple-darwin Rust target is required"77+6378 signing_identity="${APPLE_SIGNING_IDENTITY:-}"6479 [[ "$signing_identity" == Developer\ ID\ Application:* ]] \6580 || die "APPLE_SIGNING_IDENTITY must name a Developer ID Application identity"6681 security find-identity -v -p codesigning | grep -Fq "\"$signing_identity\"" \6782 || die "APPLE_SIGNING_IDENTITY is not available in the keychain"83+expected_team_id_file="$repo_dir/release/portal-apple-team-id.txt"84+[[ -f "$expected_team_id_file" && ! -L "$expected_team_id_file" ]] \85+ || die "committed Portal Apple team identifier is missing or unsafe"86+expected_team_id="$(<"$expected_team_id_file")"87+[[ "$expected_team_id" =~ ^[A-Z0-9]{10}$ ]] \88+ || die "release/portal-apple-team-id.txt must contain one Apple TeamIdentifier"89+[[ "${APPLE_TEAM_ID:-}" == "$expected_team_id" ]] \90+ || die "APPLE_TEAM_ID does not match the committed Portal signer"6891 6992 api_credentials=false7093 apple_id_credentials=false
@@ -83,10 +106,10 @@ $api_credentials || $apple_id_credentials \
83106 source "$HOME/.cargo/env"84107 (85108 cd "$repo_dir"86- cargo tauri build --locked --bundles app,dmg109+ cargo tauri build --locked --ci --target aarch64-apple-darwin --bundles app,dmg87110 )88111 89-bundle_root="$repo_dir/target/release/bundle"112+bundle_root="$repo_dir/target/aarch64-apple-darwin/release/bundle"90113 app_path="$bundle_root/macos/Portal.app"91114 [[ -d "$app_path" ]] || die "Tauri app bundle is missing"92115 dmg_count="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print | wc -l | tr -d ' ')"
@@ -96,10 +119,11 @@ dmg_path="$(find "$bundle_root/dmg" -maxdepth 1 -type f -name '*.dmg' -print)"
96119 hdiutil verify "$dmg_path"97120 codesign --verify --deep --strict --verbose=2 "$app_path"98121 signature_details="$(codesign -d --verbose=4 "$app_path" 2>&1)"99-grep -Fq 'Authority=Developer ID Application:' <<<"$signature_details" \100- || die "application is not signed by Developer ID Application"101-grep -Eq '^TeamIdentifier=[A-Z0-9]+$' <<<"$signature_details" \102- || die "application signature has no TeamIdentifier"122+grep -Fq "Authority=$signing_identity" <<<"$signature_details" \123+ || die "application is not signed by the configured Developer ID identity"124+team_identifier="$(sed -n 's/^TeamIdentifier=//p' <<<"$signature_details")"125+[[ "$team_identifier" == "$expected_team_id" ]] \126+ || die "application TeamIdentifier does not match the committed Portal signer"103127 grep -Eq '^flags=.*runtime' <<<"$signature_details" \104128 || die "application signature does not enable hardened runtime"105129 spctl --assess --type execute --verbose=4 "$app_path"
@@ -110,8 +134,7 @@ spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg
110134 executable="$app_path/Contents/MacOS/portal"111135 [[ -x "$executable" ]] || die "client executable is missing"112136 architectures="$(lipo -archs "$executable")"113-grep -Eq "(^| )$expected_arch( |$)" <<<"$architectures" \114- || die "client executable does not contain $expected_arch"137+[[ "$architectures" == arm64 ]] || die "client executable must contain only arm64"115138 identifier="$(plutil -extract CFBundleIdentifier raw "$app_path/Contents/Info.plist")"116139 [[ "$identifier" == sh.goq.portal ]] || die "unexpected bundle identifier: $identifier"117140 if find "$app_path" -type f \( -name sigil -o -name sigil-host -o -name sigil-probe -o -name host.toml -o -name '*.key' \) \
@@ -121,39 +144,54 @@ then
121144 fi122145 123146 version="$(plutil -extract CFBundleShortVersionString raw "$app_path/Contents/Info.plist")"124-artifact_name="Portal-${version}-${expected_arch}.dmg"147+[[ "$release_tag" == "v$version" ]] || die "app version does not match release tag"148+git_commit="$(git -C "$repo_dir" rev-parse --verify HEAD)"149+artifact_name="Portal-${version}-arm64.dmg"125150 artifact_path="$output_dir/$artifact_name"126-manifest_path="$output_dir/Portal-${version}-${expected_arch}.json"151+manifest_path="$output_dir/Portal-${version}-arm64.json"127152 [[ ! -e "$artifact_path" && ! -e "$manifest_path" && ! -e "$artifact_path.sha256" ]] \128153 || die "release output already exists"129154 install -m 0644 "$dmg_path" "$artifact_path"130155 artifact_sha256="$(shasum -a 256 "$artifact_path" | awk '{print $1}')"131156 printf '%s %s\n' "$artifact_sha256" "$artifact_name" >"$artifact_path.sha256"132-python3 - "$manifest_path" "$version" "$expected_arch" "$architectures" "$artifact_sha256" <<'PY'157+python3 - "$manifest_path" "$release_tag" "$version" "$git_commit" "$artifact_name" "$artifact_sha256" <<'PY'133158 import json134159 import pathlib135160 import sys136161 137-path, version, expected_arch, architectures, digest = sys.argv[1:]162+path, release_tag, version, git_commit, asset, digest = sys.argv[1:]138163 manifest = {139- "format": 1,140- "product": "portal-client",141- "version": version,164+ "architecture": "arm64",165+ "architectures": ["arm64"],166+ "asset": asset,142167 "bundle_identifier": "sh.goq.portal",143- "expected_arch": expected_arch,144- "architectures": architectures.split(),145- "sha256": digest,168+ "checksum_asset": f"{asset}.sha256",169+ "demo_direct_node": False,146170 "developer_id_signed": True,171+ "format": 1,172+ "gatekeeper_verified": True,173+ "git_commit": git_commit,147174 "hardened_runtime": True,148- "notarized_and_stapled": True,149- "demo_direct_node": False,175+ "notarized": True,176+ "platform": "macos",177+ "product": "portal-client",178+ "release_tag": release_tag,179+ "sha256": digest,180+ "stapled": True,181+ "version": version,150182 }151183 pathlib.Path(path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")152184 PY153185 chmod 0644 "$manifest_path"186+python3 "$verifier" assets \187+ --repo-dir "$repo_dir" \188+ --release-tag "$release_tag" \189+ --asset-dir "$output_dir" >/dev/null154190 155191 printf 'client_package=%s\n' "$artifact_path"156192 printf 'client_package_sha256=%s\n' "$artifact_sha256"157193 printf 'client_architectures=%s\n' "$architectures"194+printf 'client_release_tag=%s\n' "$release_tag"195+printf 'client_git_commit=%s\n' "$git_commit"158196 printf 'client_signing=developer-id-hardened-runtime\n'159197 printf 'client_notarization=stapled-and-validated\n'
A scripts/public-alpha-uat.sh +912 -0
@@ -0,0 +1,912 @@
1+#!/usr/bin/env bash2+3+set -euo pipefail4+5+FORMAT='goq-public-alpha-uat-v2'6+EVIDENCE_SCHEMA='goq-public-alpha-evidence-v2'7+MAX_SOURCE_BYTES=$((1024 * 1024))8+DEFAULT_MAX_AGE_SECONDS=$((7 * 24 * 60 * 60))9+FUTURE_SKEW_SECONDS=30010+GITHUB_REPOSITORY='FelineStateMachine/goq'11+required_kinds=(cold-boot controller mouse soak network-direct network-relay reconnect second-client)12+all_kinds=("${required_kinds[@]}" loopback-preflight)13+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"14+repo_dir="$(CDPATH='' cd -- "$script_dir/.." && pwd -P)"15+sigil_release_verifier="$script_dir/verify-sigil-release.sh"16+portal_release_verifier="$script_dir/verify-portal-release.py"17+portal_signature_verifier="$script_dir/verify-macos-portal-signature.sh"18+sigil_public_key="$repo_dir/release/sigil-minisign.pub"19+portal_apple_team_id_file="$repo_dir/release/portal-apple-team-id.txt"20+21+usage() {22+ cat <<'EOF'23+Usage:24+ public-alpha-uat.sh init --evidence-dir DIR --release-tag vVERSION --sigil-archive FILE --sigil-bin FILE --portal-assets DIR [--max-age-seconds N]25+ public-alpha-uat.sh record --evidence-dir DIR --kind KIND --file FILE26+ public-alpha-uat.sh verify --evidence-dir DIR --sigil-archive FILE --sigil-bin FILE --portal-assets DIR27+28+The harness never runs hardware tests or invents attestations. `record` ingests29+strict key=value evidence and `verify` succeeds only when every required gate is30+present, current, bound to an exact signed release tag and verified release31+assets, and internally valid. Hardware UAT initialization and verification must32+run on macOS so Portal signature, notarization, and Gatekeeper checks can run.33+EOF34+}35+36+die() {37+ printf 'public-alpha UAT: %s\n' "$*" >&238+ exit 139+}40+41+sha256_file() {42+ local path="$1"43+ if command -v sha256sum >/dev/null 2>&1; then44+ sha256sum "$path" | awk '{print $1}'45+ else46+ shasum -a 256 "$path" | awk '{print $1}'47+ fi48+}49+50+file_mode() {51+ local path="$1"52+ if stat -f '%Lp' "$path" >/dev/null 2>&1; then53+ stat -f '%Lp' "$path"54+ else55+ stat -c '%a' "$path"56+ fi57+}58+59+file_size() {60+ local path="$1"61+ if stat -f '%z' "$path" >/dev/null 2>&1; then62+ stat -f '%z' "$path"63+ else64+ stat -c '%s' "$path"65+ fi66+}67+68+require_absolute_path() {69+ [[ "$1" == /* ]] || die "path must be absolute: $1"70+}71+72+require_secure_directory() {73+ local path="$1"74+ local mode75+ [[ -d "$path" && ! -L "$path" ]] || die "directory is missing or a symlink: $path"76+ mode="$(file_mode "$path")"77+ [[ "$mode" == 700 ]] || die "directory must have mode 0700: $path (mode $mode)"78+}79+80+require_safe_regular_file() {81+ local path="$1"82+ local mode83+ local permissions84+ [[ -f "$path" && ! -L "$path" ]] || die "file is missing, non-regular, or a symlink: $path"85+ mode="$(file_mode "$path")"86+ [[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "could not determine safe permissions for $path"87+ permissions=$((8#$mode))88+ (( (permissions & 8#022) == 0 )) || die "file is writable by group or others: $path"89+}90+91+require_hash() {92+ [[ "$1" =~ ^[0-9a-f]{64}$ ]] || die "invalid SHA256 value for $2"93+}94+95+require_commit() {96+ [[ "$1" =~ ^[0-9a-f]{40}$ ]] || die "invalid git commit in $2"97+}98+99+require_release_tag() {100+ [[ "$1" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] \101+ || die "release tag must be an exact vVERSION tag"102+}103+104+require_safe_directory() {105+ local path="$1"106+ local mode107+ local permissions108+ [[ -d "$path" && ! -L "$path" ]] || die "directory is missing or a symlink: $path"109+ mode="$(file_mode "$path")"110+ [[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "could not determine safe permissions for $path"111+ permissions=$((8#$mode))112+ (( (permissions & 8#022) == 0 )) || die "directory is writable by group or others: $path"113+}114+115+verified_commit=''116+verified_portal_dmg=''117+verified_portal_checksum=''118+verified_portal_manifest=''119+120+github_api() {121+ local endpoint="$1"122+ command -v gh > /dev/null 2>&1 || die "GitHub CLI (gh) is required to verify published release assets"123+ GH_PROMPT_DISABLED=1 gh api "$endpoint" \124+ || die "GitHub API request failed for $endpoint"125+}126+127+git_object_from_json() {128+ PYTHONDONTWRITEBYTECODE=1 python3 -c '129+import json130+import re131+import sys132+133+try:134+ obj = json.load(sys.stdin)["object"]135+ kind = obj["type"]136+ sha = obj["sha"]137+except (KeyError, TypeError, ValueError, json.JSONDecodeError):138+ raise SystemExit(1)139+if kind not in {"commit", "tag"} or not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}", sha):140+ raise SystemExit(1)141+print(f"{kind}\t{sha}")142+'143+}144+145+resolve_published_tag_commit() {146+ local release_tag="$1"147+ local response148+ local parsed149+ local object_type150+ local object_sha151+ local depth=0152+153+ response="$(github_api "/repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag")"154+ parsed="$(printf '%s' "$response" | git_object_from_json)" \155+ || die "GitHub tag ref is malformed for $release_tag"156+ IFS=$'\t' read -r object_type object_sha <<< "$parsed"157+ while [[ "$object_type" == tag ]]; do158+ ((depth += 1))159+ (( depth <= 8 )) || die "GitHub annotated tag chain is too deep for $release_tag"160+ response="$(github_api "/repos/$GITHUB_REPOSITORY/git/tags/$object_sha")"161+ parsed="$(printf '%s' "$response" | git_object_from_json)" \162+ || die "GitHub annotated tag object is malformed for $release_tag"163+ IFS=$'\t' read -r object_type object_sha <<< "$parsed"164+ done165+ [[ "$object_type" == commit ]] || die "GitHub tag does not resolve to a commit: $release_tag"166+ printf '%s\n' "$object_sha"167+}168+169+verify_published_release() {170+ local release_tag="$1"171+ local expected_commit="$2"172+ local archive="$3"173+ local portal_dmg="$4"174+ local portal_checksum="$5"175+ local portal_manifest="$6"176+ local remote_commit177+ local release_json178+179+ remote_commit="$(resolve_published_tag_commit "$release_tag")"180+ require_equal "$remote_commit" "$expected_commit" "published GitHub tag commit"181+ release_json="$(github_api "/repos/$GITHUB_REPOSITORY/releases/tags/$release_tag")"182+ PYTHONDONTWRITEBYTECODE=1 python3 - \183+ "$GITHUB_REPOSITORY" "$release_tag" \184+ "$archive" "$archive.sha256" "$archive.minisig" \185+ "$portal_dmg" "$portal_checksum" "$portal_manifest" \186+ 3<<< "$release_json" <<'PY' \187+ || die "supplied assets do not match the published GitHub prerelease"188+import hashlib189+import json190+import os191+import pathlib192+import re193+import sys194+195+repository = sys.argv[1]196+release_tag = sys.argv[2]197+paths = [pathlib.Path(value) for value in sys.argv[3:]]198+try:199+ with os.fdopen(3) as release_stream:200+ release = json.load(release_stream)201+except (TypeError, ValueError, json.JSONDecodeError):202+ raise SystemExit(1)203+204+if release.get("tag_name") != release_tag:205+ raise SystemExit(1)206+if release.get("draft") is not False or release.get("prerelease") is not True:207+ raise SystemExit(1)208+if not isinstance(release.get("id"), int) or release["id"] <= 0:209+ raise SystemExit(1)210+if not isinstance(release.get("published_at"), str) or not release["published_at"]:211+ raise SystemExit(1)212+if release.get("html_url") != f"https://github.com/{repository}/releases/tag/{release_tag}":213+ raise SystemExit(1)214+215+assets = release.get("assets")216+if not isinstance(assets, list) or len(assets) != len(paths):217+ raise SystemExit(1)218+by_name = {}219+for asset in assets:220+ if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):221+ raise SystemExit(1)222+ if asset["name"] in by_name:223+ raise SystemExit(1)224+ by_name[asset["name"]] = asset225+226+expected_names = {path.name for path in paths}227+if len(expected_names) != len(paths) or set(by_name) != expected_names:228+ raise SystemExit(1)229+for path in paths:230+ asset = by_name[path.name]231+ digest = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()232+ if asset.get("state") != "uploaded" or asset.get("digest") != digest:233+ raise SystemExit(1)234+ if asset.get("size") != path.stat().st_size:235+ raise SystemExit(1)236+ if not isinstance(asset.get("id"), int) or asset["id"] <= 0:237+ raise SystemExit(1)238+ api_url = asset.get("url")239+ if not isinstance(api_url, str) or not re.fullmatch(240+ rf"https://api\.github\.com/repos/{re.escape(repository)}/releases/assets/[1-9][0-9]*",241+ api_url,242+ ):243+ raise SystemExit(1)244+PY245+}246+247+verify_portal_attestations() {248+ local release_tag="$1"249+ local expected_commit="$2"250+ shift 2251+ local asset252+253+ for asset in "$@"; do254+ GH_PROMPT_DISABLED=1 gh attestation verify "$asset" \255+ --repo "$GITHUB_REPOSITORY" \256+ --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/portal-release.yml" \257+ --source-ref "refs/tags/$release_tag" \258+ --source-digest "$expected_commit" \259+ --deny-self-hosted-runners > /dev/null \260+ || die "Portal GitHub build-provenance attestation verification failed: $(basename -- "$asset")"261+ done262+}263+264+verify_release_inputs() {265+ local release_tag="$1"266+ local archive="$2"267+ local sigil="$3"268+ local portal_assets="$4"269+ local version270+ local head271+ local worktree_status272+ local expected_names273+ local portal_apple_team_id274+275+ require_release_tag "$release_tag"276+ require_absolute_path "$archive"277+ require_absolute_path "$sigil"278+ require_absolute_path "$portal_assets"279+ require_safe_regular_file "$archive"280+ require_safe_regular_file "$archive.sha256"281+ require_safe_regular_file "$archive.minisig"282+ require_safe_regular_file "$sigil"283+ require_safe_directory "$portal_assets"284+ require_safe_regular_file "$sigil_release_verifier"285+ require_safe_regular_file "$portal_release_verifier"286+ require_safe_regular_file "$portal_signature_verifier"287+ require_safe_regular_file "$sigil_public_key"288+ require_safe_regular_file "$portal_apple_team_id_file"289+290+ verified_commit="$(git -C "$repo_dir" rev-parse --verify "refs/tags/$release_tag^{commit}" 2>/dev/null)" \291+ || die "release tag does not resolve through refs/tags/$release_tag"292+ require_commit "$verified_commit" release293+ head="$(git -C "$repo_dir" rev-parse --verify HEAD 2>/dev/null)" || die "repository HEAD is unavailable"294+ require_equal "$head" "$verified_commit" "release tag commit"295+ worktree_status="$(git -C "$repo_dir" status --porcelain=v1 --untracked-files=all)" \296+ || die "release worktree status is unavailable"297+ [[ -z "$worktree_status" ]] || die "hardware UAT must run from the clean exact release tag"298+299+ version="${release_tag#v}"300+ verified_portal_dmg="$portal_assets/Portal-$version-arm64.dmg"301+ verified_portal_checksum="$verified_portal_dmg.sha256"302+ verified_portal_manifest="$portal_assets/Portal-$version-arm64.json"303+ expected_names="$(304+ find "$portal_assets" -mindepth 1 -maxdepth 1 -print \305+ | sed 's#.*/##' \306+ | LC_ALL=C sort307+ )"308+ require_equal "$expected_names" "$(printf '%s\n' \309+ "Portal-$version-arm64.dmg" \310+ "Portal-$version-arm64.dmg.sha256" \311+ "Portal-$version-arm64.json" | LC_ALL=C sort)" "Portal asset set"312+ require_safe_regular_file "$verified_portal_dmg"313+ require_safe_regular_file "$verified_portal_checksum"314+ require_safe_regular_file "$verified_portal_manifest"315+316+ "$sigil_release_verifier" \317+ --tag "$release_tag" \318+ --archive "$archive" \319+ --source-commit "$verified_commit" \320+ --public-key-file "$sigil_public_key" > /dev/null \321+ || die "signed Sigil release verification failed"322+323+ python3 - "$archive" "$sigil" <<'PY' || die "supplied Sigil executable does not equal payload/release/sigil"324+import pathlib325+import sys326+import tarfile327+328+archive_path = pathlib.Path(sys.argv[1])329+sigil_path = pathlib.Path(sys.argv[2])330+try:331+ with tarfile.open(archive_path, "r:gz") as archive:332+ member = archive.getmember("payload/release/sigil")333+ if not member.isreg():334+ raise ValueError("Sigil payload is not regular")335+ stream = archive.extractfile(member)336+ if stream is None or stream.read() != sigil_path.read_bytes():337+ raise ValueError("Sigil payload differs")338+except (KeyError, OSError, tarfile.TarError, ValueError):339+ raise SystemExit(1)340+PY341+342+ PYTHONDONTWRITEBYTECODE=1 python3 "$portal_release_verifier" assets \343+ --repo-dir "$repo_dir" \344+ --release-tag "$release_tag" \345+ --asset-dir "$portal_assets" > /dev/null \346+ || die "Portal release asset verification failed"347+348+ portal_apple_team_id="$(awk 'NF { count += 1; value = $0 } END { if (count != 1) exit 1; print value }' \349+ "$portal_apple_team_id_file")" \350+ || die "committed Portal Apple TeamIdentifier pin must contain exactly one non-empty line"351+ [[ "$portal_apple_team_id" =~ ^[A-Z0-9]{10}$ ]] \352+ || die "committed Portal Apple TeamIdentifier pin is invalid"353+354+ [[ "$(uname -s)" == Darwin ]] || die "hardware UAT release verification must run on macOS"355+ bash "$portal_signature_verifier" \356+ --dmg "$verified_portal_dmg" \357+ --expected-version "$version" \358+ --expected-team-id "$portal_apple_team_id" > /dev/null \359+ || die "Portal macOS signature verification failed"360+361+ verify_published_release \362+ "$release_tag" \363+ "$verified_commit" \364+ "$archive" \365+ "$verified_portal_dmg" \366+ "$verified_portal_checksum" \367+ "$verified_portal_manifest"368+ verify_portal_attestations \369+ "$release_tag" \370+ "$verified_commit" \371+ "$verified_portal_dmg" \372+ "$verified_portal_checksum" \373+ "$verified_portal_manifest"374+}375+376+manifest_get() {377+ local manifest="$1"378+ local key="$2"379+ awk -F '\t' -v wanted="$key" '380+ $1 == wanted { count += 1; value = $2 }381+ END { if (count != 1) exit 1; print value }382+ ' "$manifest" || die "manifest key is missing or duplicated: $key"383+}384+385+source_get() {386+ local source="$1"387+ local key="$2"388+ awk -v wanted="$key" '389+ index($0, wanted "=") == 1 {390+ count += 1391+ value = substr($0, length(wanted) + 2)392+ }393+ END { if (count != 1 || value == "") exit 1; print value }394+ ' "$source" || die "evidence key is missing, empty, or duplicated: $key"395+}396+397+kind_allowed() {398+ local wanted="$1"399+ local candidate400+ for candidate in "${all_kinds[@]}"; do401+ [[ "$candidate" == "$wanted" ]] && return 0402+ done403+ return 1404+}405+406+common_keys() {407+ printf '%s\n' uat_schema evidence_kind observed_at_unix git_commit release_tag sigil_sha256 portal_sha256408+}409+410+kind_keys() {411+ case "$1" in412+ cold-boot)413+ printf '%s\n' cold_boot_result cold_boot_failure_count cold_boot_insufficient_count \414+ headless_connector_state gaming_autologin_session sigil_host_enabled sigil_host_active \415+ gamescope_pipewire_node gamescope_before_first_ssh sigil_unit_before_first_ssh \416+ sigil_ready_before_first_ssh417+ ;;418+ controller)419+ printf '%s\n' physical_controller_attached_to_portal actual_game_controlled \420+ controller_coverage neutral_release_on_disconnect neutral_buttons neutral_axes session_seconds421+ ;;422+ mouse)423+ printf '%s\n' target_application left_click_consumed right_click_consumed \424+ consumption_observed_in_target click_attempts425+ ;;426+ soak)427+ printf '%s\n' duration_seconds samples capture_fps_p50 presentation_fps_p50 \428+ frame_interval_p95_ms hitch_p99_ms video_queue_p95_frames decode_queue_p95_frames \429+ audio_queue_p95_ms av_skew_p95_ms max_queue_age_p95_ms cpu_p95_percent \430+ gpu_p95_percent rss_p95_mib transport_drops frontend_drops audio_drops \431+ latency_first_window_p95_ms latency_last_window_p95_ms disconnects432+ ;;433+ network-direct|network-relay)434+ printf '%s\n' path_mode nat_scenario session_seconds rtt_p50_ms rtt_p95_ms \435+ input_ack_p95_ms presentation_latency_p95_ms packet_loss_percent436+ ;;437+ reconnect)438+ printf '%s\n' reconnect_cycles reconnect_successes reconnect_failures state_preserved \439+ keyframe_recovery_p95_ms440+ ;;441+ second-client)442+ printf '%s\n' second_client_attempts second_client_rejections \443+ authorized_primary_uninterrupted rejection_reason444+ ;;445+ loopback-preflight)446+ printf '%s\n' loopback_proof profile host_sha256 active_client_rejection reconnect_cycles cleanup447+ ;;448+ *) return 1 ;;449+ esac450+}451+452+require_number() {453+ [[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]] || die "$2 must be a non-negative decimal number"454+}455+456+number_ge() {457+ awk -v value="$1" -v minimum="$2" 'BEGIN { exit !(value >= minimum) }'458+}459+460+number_le() {461+ awk -v value="$1" -v maximum="$2" 'BEGIN { exit !(value <= maximum) }'462+}463+464+require_equal() {465+ [[ "$1" == "$2" ]] || die "$3 must be $2 (got $1)"466+}467+468+validate_common_evidence() {469+ local source="$1"470+ local expected_kind="$2"471+ local manifest="$3"472+ local observed473+ local now474+ local max_age475+ local value476+477+ require_equal "$(source_get "$source" uat_schema)" "$EVIDENCE_SCHEMA" uat_schema478+ require_equal "$(source_get "$source" evidence_kind)" "$expected_kind" evidence_kind479+ value="$(source_get "$source" git_commit)"480+ require_commit "$value" evidence481+ require_equal "$value" "$(manifest_get "$manifest" git_commit)" git_commit482+ value="$(source_get "$source" release_tag)"483+ require_release_tag "$value"484+ require_equal "$value" "$(manifest_get "$manifest" release_tag)" release_tag485+ value="$(source_get "$source" sigil_sha256)"486+ require_hash "$value" sigil_sha256487+ require_equal "$value" "$(manifest_get "$manifest" sigil_sha256)" sigil_sha256488+ value="$(source_get "$source" portal_sha256)"489+ require_hash "$value" portal_sha256490+ require_equal "$value" "$(manifest_get "$manifest" portal_sha256)" portal_sha256491+492+ observed="$(source_get "$source" observed_at_unix)"493+ [[ "$observed" =~ ^[0-9]{10}$ ]] || die "observed_at_unix must be a ten-digit Unix timestamp"494+ now="$(date +%s)"495+ max_age="$(manifest_get "$manifest" max_age_seconds)"496+ (( observed <= now + FUTURE_SKEW_SECONDS )) || die "evidence timestamp is in the future"497+ (( now - observed <= max_age )) || die "evidence is stale"498+}499+500+validate_cold_boot() {501+ local source="$1"502+ require_equal "$(source_get "$source" cold_boot_result)" pass cold_boot_result503+ require_equal "$(source_get "$source" cold_boot_failure_count)" 0 cold_boot_failure_count504+ require_equal "$(source_get "$source" cold_boot_insufficient_count)" 0 cold_boot_insufficient_count505+ require_equal "$(source_get "$source" headless_connector_state)" ok headless_connector_state506+ require_equal "$(source_get "$source" gaming_autologin_session)" ok gaming_autologin_session507+ require_equal "$(source_get "$source" sigil_host_enabled)" enabled sigil_host_enabled508+ require_equal "$(source_get "$source" sigil_host_active)" active sigil_host_active509+ require_equal "$(source_get "$source" gamescope_pipewire_node)" ok gamescope_pipewire_node510+ require_equal "$(source_get "$source" gamescope_before_first_ssh)" ok gamescope_before_first_ssh511+ require_equal "$(source_get "$source" sigil_unit_before_first_ssh)" ok sigil_unit_before_first_ssh512+ require_equal "$(source_get "$source" sigil_ready_before_first_ssh)" ok sigil_ready_before_first_ssh513+}514+515+validate_controller() {516+ local source="$1"517+ local seconds518+ require_equal "$(source_get "$source" physical_controller_attached_to_portal)" pass physical_controller_attached_to_portal519+ require_equal "$(source_get "$source" actual_game_controlled)" pass actual_game_controlled520+ require_equal "$(source_get "$source" controller_coverage)" abxy,dpad,sticks,triggers,shoulders,start-back controller_coverage521+ require_equal "$(source_get "$source" neutral_release_on_disconnect)" pass neutral_release_on_disconnect522+ require_equal "$(source_get "$source" neutral_buttons)" pass neutral_buttons523+ require_equal "$(source_get "$source" neutral_axes)" pass neutral_axes524+ seconds="$(source_get "$source" session_seconds)"; require_number "$seconds" session_seconds525+ number_ge "$seconds" 300 || die "controller session must last at least 300 seconds"526+}527+528+validate_mouse() {529+ local source="$1"530+ local target531+ local attempts532+ target="$(source_get "$source" target_application)"533+ [[ "$target" =~ ^[A-Za-z0-9._+-]{1,64}$ ]] || die "target_application must be a bounded slug"534+ require_equal "$(source_get "$source" left_click_consumed)" pass left_click_consumed535+ require_equal "$(source_get "$source" right_click_consumed)" pass right_click_consumed536+ require_equal "$(source_get "$source" consumption_observed_in_target)" pass consumption_observed_in_target537+ attempts="$(source_get "$source" click_attempts)"; require_number "$attempts" click_attempts538+ number_ge "$attempts" 5 || die "mouse evidence requires at least five click attempts"539+}540+541+validate_soak() {542+ local source="$1"543+ local key544+ local value545+ for key in $(kind_keys soak); do546+ value="$(source_get "$source" "$key")"547+ require_number "$value" "$key"548+ done549+ number_ge "$(source_get "$source" duration_seconds)" 3600 || die "soak must last at least 3600 seconds"550+ number_ge "$(source_get "$source" samples)" 60 || die "soak requires at least 60 samples"551+ number_ge "$(source_get "$source" capture_fps_p50)" 55 || die "capture_fps_p50 is below 55"552+ number_ge "$(source_get "$source" presentation_fps_p50)" 55 || die "presentation_fps_p50 is below 55"553+ number_le "$(source_get "$source" frame_interval_p95_ms)" 25 || die "frame_interval_p95_ms exceeds 25"554+ number_le "$(source_get "$source" hitch_p99_ms)" 50 || die "hitch_p99_ms exceeds 50"555+ number_le "$(source_get "$source" video_queue_p95_frames)" 2 || die "video queue exceeds two frames"556+ number_le "$(source_get "$source" decode_queue_p95_frames)" 2 || die "decode queue exceeds two frames"557+ number_le "$(source_get "$source" audio_queue_p95_ms)" 100 || die "audio queue exceeds 100 ms"558+ number_le "$(source_get "$source" av_skew_p95_ms)" 50 || die "A/V skew exceeds 50 ms"559+ number_le "$(source_get "$source" max_queue_age_p95_ms)" 100 || die "queue age exceeds 100 ms"560+ number_le "$(source_get "$source" cpu_p95_percent)" 90 || die "CPU p95 exceeds 90 percent"561+ number_le "$(source_get "$source" gpu_p95_percent)" 95 || die "GPU p95 exceeds 95 percent"562+ number_le "$(source_get "$source" rss_p95_mib)" 2048 || die "RSS p95 exceeds 2048 MiB"563+ require_equal "$(source_get "$source" disconnects)" 0 disconnects564+ awk -v first="$(source_get "$source" latency_first_window_p95_ms)" \565+ -v last="$(source_get "$source" latency_last_window_p95_ms)" \566+ 'BEGIN { growth = last - first; exit !(last <= first * 1.20 && growth <= 5.0) }' || \567+ die "latency grew by more than both bounded thresholds"568+}569+570+validate_network() {571+ local source="$1"572+ local kind="$2"573+ local expected_mode=direct574+ local expected_nat=ordinary575+ local key576+ if [[ "$kind" == network-relay ]]; then577+ expected_mode=relay578+ expected_nat=difficult579+ fi580+ require_equal "$(source_get "$source" path_mode)" "$expected_mode" path_mode581+ require_equal "$(source_get "$source" nat_scenario)" "$expected_nat" nat_scenario582+ for key in session_seconds rtt_p50_ms rtt_p95_ms input_ack_p95_ms presentation_latency_p95_ms packet_loss_percent; do583+ require_number "$(source_get "$source" "$key")" "$key"584+ done585+ number_ge "$(source_get "$source" session_seconds)" 600 || die "$kind session must last at least 600 seconds"586+ number_le "$(source_get "$source" packet_loss_percent)" 5 || die "$kind packet loss exceeds 5 percent"587+}588+589+validate_reconnect() {590+ local source="$1"591+ local cycles successes failures recovery592+ cycles="$(source_get "$source" reconnect_cycles)"; require_number "$cycles" reconnect_cycles593+ successes="$(source_get "$source" reconnect_successes)"; require_number "$successes" reconnect_successes594+ failures="$(source_get "$source" reconnect_failures)"; require_number "$failures" reconnect_failures595+ recovery="$(source_get "$source" keyframe_recovery_p95_ms)"; require_number "$recovery" keyframe_recovery_p95_ms596+ number_ge "$cycles" 10 || die "reconnect evidence requires at least ten cycles"597+ require_equal "$successes" "$cycles" reconnect_successes598+ require_equal "$failures" 0 reconnect_failures599+ require_equal "$(source_get "$source" state_preserved)" pass state_preserved600+ number_le "$recovery" 2000 || die "keyframe recovery p95 exceeds 2000 ms"601+}602+603+validate_second_client() {604+ local source="$1"605+ local attempts rejections606+ attempts="$(source_get "$source" second_client_attempts)"; require_number "$attempts" second_client_attempts607+ rejections="$(source_get "$source" second_client_rejections)"; require_number "$rejections" second_client_rejections608+ number_ge "$attempts" 3 || die "second-client evidence requires at least three attempts"609+ require_equal "$rejections" "$attempts" second_client_rejections610+ require_equal "$(source_get "$source" authorized_primary_uninterrupted)" pass authorized_primary_uninterrupted611+ require_equal "$(source_get "$source" rejection_reason)" active-client rejection_reason612+}613+614+validate_loopback() {615+ local source="$1"616+ local cycles617+ require_equal "$(source_get "$source" loopback_proof)" ok loopback_proof618+ require_equal "$(source_get "$source" profile)" release profile619+ require_equal "$(source_get "$source" host_sha256)" "$(source_get "$source" sigil_sha256)" host_sha256620+ require_equal "$(source_get "$source" active_client_rejection)" ok active_client_rejection621+ require_equal "$(source_get "$source" cleanup)" ok cleanup622+ cycles="$(source_get "$source" reconnect_cycles)"; require_number "$cycles" reconnect_cycles623+ number_ge "$cycles" 3 || die "loopback preflight requires at least three reconnect cycles"624+}625+626+validate_kind() {627+ case "$2" in628+ cold-boot) validate_cold_boot "$1" ;;629+ controller) validate_controller "$1" ;;630+ mouse) validate_mouse "$1" ;;631+ soak) validate_soak "$1" ;;632+ network-direct|network-relay) validate_network "$1" "$2" ;;633+ reconnect) validate_reconnect "$1" ;;634+ second-client) validate_second_client "$1" ;;635+ loopback-preflight) validate_loopback "$1" ;;636+ *) die "unsupported evidence kind: $2" ;;637+ esac638+}639+640+reject_sensitive_source() {641+ local source="$1"642+ if LC_ALL=C grep -Eiq '(^|_)(node_id|host_node_id|peer_node_id|secret|secret_key|identity_seed|private_key)=|goq-invite-v1[.]|BEGIN [A-Z ]*PRIVATE KEY' "$source"; then643+ die "evidence contains a node ID, invitation, or secret-bearing field"644+ fi645+}646+647+validate_manifest() {648+ local directory="$1"649+ local manifest="$directory/manifest.tsv"650+ local key651+ local record_kind652+ local record_field653+ require_secure_directory "$directory"654+ require_secure_directory "$directory/records"655+ require_safe_regular_file "$manifest"656+ while IFS=$'\t' read -r key _; do657+ case "$key" in658+ format|created_at_unix|release_tag|git_commit|sigil_archive_name|sigil_archive_sha256|sigil_checksum_sha256|sigil_signature_sha256|sigil_public_key_sha256|sigil_sha256|portal_dmg_name|portal_sha256|portal_checksum_sha256|portal_manifest_sha256|max_age_seconds) ;;659+ *)660+ if [[ "$key" =~ ^record\.([a-z-]+)\.(file|sha256|observed_at_unix)$ ]]; then661+ record_kind="${BASH_REMATCH[1]}"662+ record_field="${BASH_REMATCH[2]}"663+ kind_allowed "$record_kind" || die "manifest contains an unknown evidence kind: $record_kind"664+ [[ -n "$record_field" ]] || die "invalid manifest record field: $key"665+ else666+ die "unexpected manifest key: $key"667+ fi668+ ;;669+ esac670+ done < "$manifest"671+ require_equal "$(manifest_get "$manifest" format)" "$FORMAT" format672+ require_commit "$(manifest_get "$manifest" git_commit)" manifest673+ require_release_tag "$(manifest_get "$manifest" release_tag)"674+ [[ "$(manifest_get "$manifest" sigil_archive_name)" =~ ^sigil-v[0-9A-Za-z.-]+-bazzite-x86_64\.tar\.gz$ ]] \675+ || die "invalid manifest Sigil archive name"676+ [[ "$(manifest_get "$manifest" portal_dmg_name)" =~ ^Portal-[0-9A-Za-z.-]+-arm64\.dmg$ ]] \677+ || die "invalid manifest Portal DMG name"678+ require_hash "$(manifest_get "$manifest" sigil_archive_sha256)" sigil_archive_sha256679+ require_hash "$(manifest_get "$manifest" sigil_checksum_sha256)" sigil_checksum_sha256680+ require_hash "$(manifest_get "$manifest" sigil_signature_sha256)" sigil_signature_sha256681+ require_hash "$(manifest_get "$manifest" sigil_public_key_sha256)" sigil_public_key_sha256682+ require_hash "$(manifest_get "$manifest" sigil_sha256)" sigil_sha256683+ require_hash "$(manifest_get "$manifest" portal_sha256)" portal_sha256684+ require_hash "$(manifest_get "$manifest" portal_checksum_sha256)" portal_checksum_sha256685+ require_hash "$(manifest_get "$manifest" portal_manifest_sha256)" portal_manifest_sha256686+ [[ "$(manifest_get "$manifest" created_at_unix)" =~ ^[0-9]{10}$ ]] || die "invalid manifest created_at_unix"687+ local max_age688+ max_age="$(manifest_get "$manifest" max_age_seconds)"689+ if [[ ! "$max_age" =~ ^[0-9]+$ ]] || (( max_age < 3600 || max_age > 2592000 )); then690+ die "invalid manifest max_age_seconds"691+ fi692+}693+694+atomic_manifest_record() {695+ local directory="$1"696+ local kind="$2"697+ local record_sha="$3"698+ local observed="$4"699+ local manifest="$directory/manifest.tsv"700+ local temporary701+ temporary="$(mktemp "$directory/.manifest.XXXXXX")"702+ chmod 600 "$temporary"703+ awk -F '\t' -v prefix="record.$kind." 'index($1, prefix) != 1 { print }' "$manifest" > "$temporary"704+ {705+ printf 'record.%s.file\trecords/%s.evidence\n' "$kind" "$kind"706+ printf 'record.%s.sha256\t%s\n' "$kind" "$record_sha"707+ printf 'record.%s.observed_at_unix\t%s\n' "$kind" "$observed"708+ } >> "$temporary"709+ mv -f "$temporary" "$manifest"710+}711+712+command_init() {713+ local directory=''714+ local release_tag=''715+ local archive=''716+ local sigil=''717+ local portal_assets=''718+ local max_age="$DEFAULT_MAX_AGE_SECONDS"719+ local parent720+ while (($#)); do721+ case "$1" in722+ --evidence-dir) [[ $# -ge 2 ]] || die "$1 requires a value"; directory="$2"; shift 2 ;;723+ --release-tag) [[ $# -ge 2 ]] || die "$1 requires a value"; release_tag="$2"; shift 2 ;;724+ --sigil-archive) [[ $# -ge 2 ]] || die "$1 requires a value"; archive="$2"; shift 2 ;;725+ --sigil-bin) [[ $# -ge 2 ]] || die "$1 requires a value"; sigil="$2"; shift 2 ;;726+ --portal-assets) [[ $# -ge 2 ]] || die "$1 requires a value"; portal_assets="$2"; shift 2 ;;727+ --max-age-seconds) [[ $# -ge 2 ]] || die "$1 requires a value"; max_age="$2"; shift 2 ;;728+ *) die "unknown init argument: $1" ;;729+ esac730+ done731+ [[ -n "$directory" && -n "$release_tag" && -n "$archive" && -n "$sigil" && -n "$portal_assets" ]] \732+ || die "init requires evidence dir, release tag, Sigil archive/executable, and Portal asset directory"733+ require_absolute_path "$directory"734+ if [[ ! "$max_age" =~ ^[0-9]+$ ]] || (( max_age < 3600 || max_age > 2592000 )); then735+ die "max age must be 3600..2592000 seconds"736+ fi737+ [[ ! -e "$directory" && ! -L "$directory" ]] || die "evidence directory already exists"738+ parent="$(dirname "$directory")"; [[ -d "$parent" && ! -L "$parent" ]] || die "evidence parent is missing or a symlink"739+ verify_release_inputs "$release_tag" "$archive" "$sigil" "$portal_assets"740+ mkdir -m 700 "$directory"741+ mkdir -m 700 "$directory/records"742+ umask 077743+ {744+ printf 'format\t%s\n' "$FORMAT"745+ printf 'created_at_unix\t%s\n' "$(date +%s)"746+ printf 'release_tag\t%s\n' "$release_tag"747+ printf 'git_commit\t%s\n' "$verified_commit"748+ printf 'sigil_archive_name\t%s\n' "$(basename -- "$archive")"749+ printf 'sigil_archive_sha256\t%s\n' "$(sha256_file "$archive")"750+ printf 'sigil_checksum_sha256\t%s\n' "$(sha256_file "$archive.sha256")"751+ printf 'sigil_signature_sha256\t%s\n' "$(sha256_file "$archive.minisig")"752+ printf 'sigil_public_key_sha256\t%s\n' "$(sha256_file "$sigil_public_key")"753+ printf 'sigil_sha256\t%s\n' "$(sha256_file "$sigil")"754+ printf 'portal_dmg_name\t%s\n' "$(basename -- "$verified_portal_dmg")"755+ printf 'portal_sha256\t%s\n' "$(sha256_file "$verified_portal_dmg")"756+ printf 'portal_checksum_sha256\t%s\n' "$(sha256_file "$verified_portal_checksum")"757+ printf 'portal_manifest_sha256\t%s\n' "$(sha256_file "$verified_portal_manifest")"758+ printf 'max_age_seconds\t%s\n' "$max_age"759+ } > "$directory/manifest.tsv"760+ chmod 600 "$directory/manifest.tsv"761+ printf 'public_alpha_uat=initialized\nevidence_dir=%s\nrelease_tag=%s\ngit_commit=%s\n' \762+ "$directory" "$release_tag" "$verified_commit"763+ printf 'sigil_archive_sha256=%s\nsigil_sha256=%s\nportal_sha256=%s\n' \764+ "$(sha256_file "$archive")" "$(sha256_file "$sigil")" "$(sha256_file "$verified_portal_dmg")"765+}766+767+command_record() {768+ local directory=''769+ local kind=''770+ local source=''771+ local manifest772+ local record773+ local temporary774+ local key775+ local observed776+ local size777+ while (($#)); do778+ case "$1" in779+ --evidence-dir) [[ $# -ge 2 ]] || die "$1 requires a value"; directory="$2"; shift 2 ;;780+ --kind) [[ $# -ge 2 ]] || die "$1 requires a value"; kind="$2"; shift 2 ;;781+ --file) [[ $# -ge 2 ]] || die "$1 requires a value"; source="$2"; shift 2 ;;782+ *) die "unknown record argument: $1" ;;783+ esac784+ done785+ [[ -n "$directory" && -n "$kind" && -n "$source" ]] || die "record requires evidence dir, kind, and file"786+ require_absolute_path "$directory"; require_absolute_path "$source"787+ kind_allowed "$kind" || die "unknown evidence kind: $kind"788+ validate_manifest "$directory"789+ manifest="$directory/manifest.tsv"790+ require_safe_regular_file "$source"791+ size="$(file_size "$source")"; [[ "$size" =~ ^[0-9]+$ ]] || die "could not determine evidence size"792+ (( size > 0 && size <= MAX_SOURCE_BYTES )) || die "evidence source is empty or exceeds $MAX_SOURCE_BYTES bytes"793+ reject_sensitive_source "$source"794+ validate_common_evidence "$source" "$kind" "$manifest"795+ validate_kind "$source" "$kind"796+ [[ ! -e "$directory/records/$kind.evidence" ]] || die "evidence kind is already recorded: $kind"797+798+ temporary="$(mktemp "$directory/records/.$kind.XXXXXX")"799+ chmod 600 "$temporary"800+ while IFS= read -r key; do801+ printf '%s=%s\n' "$key" "$(source_get "$source" "$key")" >> "$temporary"802+ done < <({ common_keys; kind_keys "$kind"; } | awk '!seen[$0]++')803+ record="$directory/records/$kind.evidence"804+ mv "$temporary" "$record"805+ observed="$(source_get "$record" observed_at_unix)"806+ atomic_manifest_record "$directory" "$kind" "$(sha256_file "$record")" "$observed"807+ printf 'public_alpha_uat=recorded\nevidence_kind=%s\nrecord_sha256=%s\n' "$kind" "$(sha256_file "$record")"808+}809+810+command_verify() {811+ local directory=''812+ local archive=''813+ local sigil=''814+ local portal_assets=''815+ local manifest816+ local kind817+ local record818+ local expected819+ local actual820+ local file821+ local filename822+ local found_kind823+ while (($#)); do824+ case "$1" in825+ --evidence-dir) [[ $# -ge 2 ]] || die "$1 requires a value"; directory="$2"; shift 2 ;;826+ --sigil-archive) [[ $# -ge 2 ]] || die "$1 requires a value"; archive="$2"; shift 2 ;;827+ --sigil-bin) [[ $# -ge 2 ]] || die "$1 requires a value"; sigil="$2"; shift 2 ;;828+ --portal-assets) [[ $# -ge 2 ]] || die "$1 requires a value"; portal_assets="$2"; shift 2 ;;829+ *) die "unknown verify argument: $1" ;;830+ esac831+ done832+ [[ -n "$directory" && -n "$archive" && -n "$sigil" && -n "$portal_assets" ]] \833+ || die "verify requires evidence dir, Sigil archive/executable, and Portal asset directory"834+ require_absolute_path "$directory"835+ validate_manifest "$directory"; manifest="$directory/manifest.tsv"836+ verify_release_inputs "$(manifest_get "$manifest" release_tag)" "$archive" "$sigil" "$portal_assets"837+ require_equal "$verified_commit" "$(manifest_get "$manifest" git_commit)" "release tag commit"838+ require_equal "$(basename -- "$archive")" "$(manifest_get "$manifest" sigil_archive_name)" "Sigil archive name"839+ require_equal "$(sha256_file "$archive")" "$(manifest_get "$manifest" sigil_archive_sha256)" "Sigil archive hash"840+ require_equal "$(sha256_file "$archive.sha256")" "$(manifest_get "$manifest" sigil_checksum_sha256)" "Sigil checksum asset hash"841+ require_equal "$(sha256_file "$archive.minisig")" "$(manifest_get "$manifest" sigil_signature_sha256)" "Sigil signature asset hash"842+ require_equal "$(sha256_file "$sigil_public_key")" "$(manifest_get "$manifest" sigil_public_key_sha256)" "Sigil public key hash"843+ require_equal "$(sha256_file "$sigil")" "$(manifest_get "$manifest" sigil_sha256)" "Sigil release-candidate hash"844+ require_equal "$(basename -- "$verified_portal_dmg")" "$(manifest_get "$manifest" portal_dmg_name)" "Portal DMG name"845+ require_equal "$(sha256_file "$verified_portal_dmg")" "$(manifest_get "$manifest" portal_sha256)" "Portal release-candidate hash"846+ require_equal "$(sha256_file "$verified_portal_checksum")" "$(manifest_get "$manifest" portal_checksum_sha256)" "Portal checksum asset hash"847+ require_equal "$(sha256_file "$verified_portal_manifest")" "$(manifest_get "$manifest" portal_manifest_sha256)" "Portal manifest asset hash"848+849+ while IFS= read -r file; do850+ case "$file" in851+ "$directory/manifest.tsv") [[ -f "$file" ]] || die "manifest is not a regular file" ;;852+ "$directory/records") [[ -d "$file" ]] || die "records path is not a directory" ;;853+ "$directory/records/"*.evidence)854+ [[ -f "$file" ]] || die "evidence record is not a regular file: $file"855+ filename="${file##*/}"856+ found_kind="${filename%.evidence}"857+ kind_allowed "$found_kind" || die "unexpected evidence kind in bundle: $found_kind"858+ ;;859+ *) die "unexpected file or directory in evidence bundle: $file" ;;860+ esac861+ [[ ! -L "$file" ]] || die "evidence bundle contains a symlink: $file"862+ done < <(find "$directory" -mindepth 1 -maxdepth 2 -print)863+864+ for kind in "${required_kinds[@]}"; do865+ record="$directory/records/$kind.evidence"866+ require_safe_regular_file "$record"867+ expected="$(manifest_get "$manifest" "record.$kind.sha256")"868+ actual="$(sha256_file "$record")"869+ require_equal "$actual" "$expected" "$kind record hash"870+ require_equal "$(manifest_get "$manifest" "record.$kind.file")" "records/$kind.evidence" "$kind record path"871+ require_equal "$(manifest_get "$manifest" "record.$kind.observed_at_unix")" \872+ "$(source_get "$record" observed_at_unix)" "$kind record timestamp"873+ validate_common_evidence "$record" "$kind" "$manifest"874+ validate_kind "$record" "$kind"875+ done876+ if grep -q '^record\.loopback-preflight\.' "$manifest" && \877+ [[ ! -e "$directory/records/loopback-preflight.evidence" ]]; then878+ die "loopback preflight manifest metadata exists without its record"879+ fi880+ if [[ -e "$directory/records/loopback-preflight.evidence" ]]; then881+ record="$directory/records/loopback-preflight.evidence"882+ require_safe_regular_file "$record"883+ require_equal "$(sha256_file "$record")" \884+ "$(manifest_get "$manifest" record.loopback-preflight.sha256)" "loopback preflight record hash"885+ require_equal "$(manifest_get "$manifest" record.loopback-preflight.file)" \886+ "records/loopback-preflight.evidence" "loopback preflight record path"887+ require_equal "$(manifest_get "$manifest" record.loopback-preflight.observed_at_unix)" \888+ "$(source_get "$record" observed_at_unix)" "loopback preflight record timestamp"889+ validate_common_evidence "$record" loopback-preflight "$manifest"890+ validate_loopback "$record"891+ fi892+893+ printf 'public_alpha_uat=pass\ngit_commit=%s\nrelease_tag=%s\n' \894+ "$(manifest_get "$manifest" git_commit)" "$(manifest_get "$manifest" release_tag)"895+ printf 'sigil_sha256=%s\nportal_sha256=%s\nrequired_gates=%s\n' \896+ "$(manifest_get "$manifest" sigil_sha256)" "$(manifest_get "$manifest" portal_sha256)" "${#required_kinds[@]}"897+}898+899+main() {900+ local command="${1:-}"901+ [[ -n "$command" ]] || { usage; exit 2; }902+ shift903+ case "$command" in904+ init) command_init "$@" ;;905+ record) command_record "$@" ;;906+ verify) command_verify "$@" ;;907+ --help|-h|help) usage ;;908+ *) usage >&2; die "unknown command: $command" ;;909+ esac910+}911+912+main "$@"
A scripts/sign-bazzite-release.sh +107 -0
@@ -0,0 +1,107 @@
1+#!/usr/bin/env bash2+3+set -euo pipefail4+umask 0775+6+usage() {7+ cat <<'EOF'8+Usage: scripts/sign-bazzite-release.sh \9+ --tag vVERSION \10+ --archive /absolute/path/sigil-vVERSION-bazzite-x86_64.tar.gz \11+ --minisign-key /absolute/offline/path/to/release.key \12+ --public-key-file /absolute/path/to/sigil-minisign.pub \13+ [--source-commit HEX]14+15+Verify and sign one clean Sigil release candidate on the offline publisher16+machine. The secret key and passphrase are consumed only by Minisign and are17+never copied into the package or printed by this script.18+EOF19+}20+21+die() {22+ printf 'offline signing failed: %s\n' "$*" >&223+ exit 124+}25+26+script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"27+release_tag=""28+archive_path=""29+secret_key=""30+public_key_file=""31+source_commit=""32+33+while [[ $# -gt 0 ]]; do34+ case "$1" in35+ --tag)36+ [[ $# -ge 2 ]] || die "--tag requires a value"37+ release_tag="$2"38+ shift 239+ ;;40+ --archive)41+ [[ $# -ge 2 ]] || die "--archive requires a path"42+ archive_path="$2"43+ shift 244+ ;;45+ --minisign-key)46+ [[ $# -ge 2 ]] || die "--minisign-key requires a path"47+ secret_key="$2"48+ shift 249+ ;;50+ --public-key-file)51+ [[ $# -ge 2 ]] || die "--public-key-file requires a path"52+ public_key_file="$2"53+ shift 254+ ;;55+ --source-commit)56+ [[ $# -ge 2 ]] || die "--source-commit requires a value"57+ source_commit="$2"58+ shift 259+ ;;60+ --help|-h)61+ usage62+ exit 063+ ;;64+ *) die "unknown argument: $1" ;;65+ esac66+done67+68+[[ -n "$release_tag" && -n "$archive_path" && -n "$secret_key" \69+ && -n "$public_key_file" ]] || die "tag, archive, secret key, and public key are required"70+[[ "$secret_key" == /* && -f "$secret_key" && ! -L "$secret_key" ]] \71+ || die "Minisign secret key must be an absolute regular file"72+[[ "$(stat -c '%a' "$secret_key" 2>/dev/null || stat -f '%Lp' "$secret_key")" =~ ^(400|600)$ ]] \73+ || die "Minisign secret key permissions must be 0400 or 0600"74+command -v minisign >/dev/null 2>&1 || die "minisign is required on the offline signer"75+76+verify_args=(--tag "$release_tag" --archive "$archive_path" --candidate)77+if [[ -n "$source_commit" ]]; then78+ verify_args+=(--source-commit "$source_commit")79+fi80+"$script_dir/verify-sigil-release.sh" "${verify_args[@]}"81+82+signature_path="$archive_path.minisig"83+[[ ! -e "$signature_path" ]] || die "detached signature already exists: $signature_path"84+signature_created=false85+cleanup_signature() {86+ local status=$?87+ trap - EXIT INT TERM HUP88+ if [[ "$status" -ne 0 && "$signature_created" == true \89+ && -f "$signature_path" && ! -L "$signature_path" ]]; then90+ rm -f -- "$signature_path"91+ fi92+ exit "$status"93+}94+trap cleanup_signature EXIT INT TERM HUP95+minisign -S -s "$secret_key" -m "$archive_path" -x "$signature_path" \96+ -t "Sigil host $release_tag" -c "offline release signature"97+signature_created=true98+99+verify_args=(--tag "$release_tag" --archive "$archive_path" \100+ --public-key-file "$public_key_file")101+if [[ -n "$source_commit" ]]; then102+ verify_args+=(--source-commit "$source_commit")103+fi104+"$script_dir/verify-sigil-release.sh" "${verify_args[@]}"105+trap - EXIT INT TERM HUP106+printf 'detached_signature=%s\n' "$signature_path"107+printf 'offline_signing=ok\n'
A scripts/tests/bazzite-package-install-e2e.sh +212 -0

Not shown: this page already shows 6000 lines of diff.

A scripts/tests/package-bazzite-product-contract.sh +128 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/tests/package-bazzite-provenance.sh +18 -0

Not shown: this page already shows 6000 lines of diff.

A scripts/tests/portal-release.sh +9 -0

Not shown: this page already shows 6000 lines of diff.

A scripts/tests/public-alpha-uat.sh +503 -0

Not shown: this page already shows 6000 lines of diff.

A scripts/tests/sigil-release-verifier.sh +190 -0

Not shown: this page already shows 6000 lines of diff.

A scripts/tests/test_portal_release.py +248 -0

Not shown: this page already shows 6000 lines of diff.

A scripts/verify-macos-portal-signature.sh +85 -0

Not shown: this page already shows 6000 lines of diff.

A scripts/verify-portal-release.py +331 -0

Not shown: this page already shows 6000 lines of diff.

A scripts/verify-sigil-release.sh +284 -0

Not shown: this page already shows 6000 lines of diff.

M scripts/verify-website.sh +7 -3

Not shown: this page already shows 6000 lines of diff.

M src-tauri/Cargo.toml +9 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/capabilities/default.json +2 -1

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/commands/auth.rs +33 -2

Not shown: this page already shows 6000 lines of diff.

A src-tauri/src/commands/enrollment.rs +644 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/commands/mod.rs +1 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/commands/network.rs +113 -18

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/commands/state.rs +2 -0

Not shown: this page already shows 6000 lines of diff.

M src-tauri/src/main.rs +54 -3

Not shown: this page already shows 6000 lines of diff.

M src-tauri/tauri.conf.json +16 -0

Not shown: this page already shows 6000 lines of diff.

M website/_headers +5 -1

Not shown: this page already shows 6000 lines of diff.

M website/dither.js +69 -6

Not shown: this page already shows 6000 lines of diff.

A website/portal-release.json +10 -0

Not shown: this page already shows 6000 lines of diff.