diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index ae200d8..083a9cb 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -29,11 +29,15 @@ jobs: run: npm ci --prefix web - name: Run simulator unit tests run: | - python3 web/tests/test-resolve-build-target.py - python3 web/tests/test-trigger-pr.py + for test in web/tests/test-*.py; do + if [[ "$test" == *test-browser-manifest.py ]]; then continue; fi + python3 "$test" + done + - name: Run build-independent JavaScript tests + run: | node web/tests/test-demo-data.mjs - python3 web/tests/test-inspector.py node web/tests/test-build-provenance.mjs + node web/tests/test-preview-route.mjs - name: Install browser build dependencies run: | sudo apt-get update @@ -45,6 +49,10 @@ jobs: path: specter-diy submodules: recursive persist-credentials: false + - name: Write and verify browser freeze manifest + run: | + python3 web/browser/write-browser-manifest.py "$GITHUB_WORKSPACE/specter-diy" + python3 web/tests/test-browser-manifest.py "$GITHUB_WORKSPACE/specter-diy" - name: Install Emscripten 3.1.74 run: | git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$RUNNER_TEMP/emsdk" diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..4abba4d --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,620 @@ +name: Specter PR preview + +run-name: >- + Specter PR ${{ inputs.pr_number }} · ${{ inputs.head_sha }} · ${{ inputs.request_id }} + +on: + workflow_dispatch: + inputs: + request_id: + description: Unique request ID from the paired Specter DIY workflow + type: string + required: true + action: + description: Build or remove the PR preview + type: choice + options: [build, delete] + required: true + base_repository: + description: Paired base repository (owner/specter-diy) + type: string + required: true + base_sha: + description: Exact PR base commit used for the trusted JavaScript runtime + type: string + required: true + base_ref: + description: PR base branch name + type: string + required: true + pr_number: + description: Pull request number + type: string + required: true + head_repository: + description: Exact PR source repository (owner/specter-diy) + type: string + required: false + head_sha: + description: Exact PR source commit SHA + type: string + required: true + head_ref: + description: PR source branch name + type: string + required: false + source_updated_at: + description: PR updated_at timestamp from the source event + type: string + required: true + +permissions: + contents: read + +jobs: + validate: + name: Validate paired Specter PR request + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + request_id: ${{ steps.validate.outputs.request_id }} + action: ${{ steps.validate.outputs.action }} + base_repository: ${{ steps.validate.outputs.base_repository }} + base_sha: ${{ steps.validate.outputs.base_sha }} + base_ref: ${{ steps.validate.outputs.base_ref }} + pr_number: ${{ steps.validate.outputs.pr_number }} + head_repository: ${{ steps.validate.outputs.head_repository }} + head_sha: ${{ steps.validate.outputs.head_sha }} + head_ref: ${{ steps.validate.outputs.head_ref }} + source_updated_at: ${{ steps.validate.outputs.source_updated_at }} + steps: + - name: Check out trusted Web Simulator workflow code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 pinned commit + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Validate event metadata against the live PR + id: validate + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PREVIEW_INPUTS_JSON: ${{ toJSON(inputs) }} + GH_TOKEN: ${{ github.token }} + run: python3 web/tools/validate_preview_request.py + + build: + name: Build PR firmware and browser payload + needs: validate + if: needs.validate.outputs.action == 'build' + runs-on: ubuntu-latest + timeout-minutes: 180 + concurrency: + group: preview-build-${{ github.repository }}-${{ needs.validate.outputs.pr_number }} + cancel-in-progress: true + permissions: + contents: read + outputs: + firmware_url: ${{ steps.firmware.outputs.artifact-url }} + firmware_name: ${{ steps.record_firmware.outputs.firmware_name }} + env: + BASE_REPOSITORY: ${{ needs.validate.outputs.base_repository }} + BASE_SHA: ${{ needs.validate.outputs.base_sha }} + BASE_REF: ${{ needs.validate.outputs.base_ref }} + HEAD_REPOSITORY: ${{ needs.validate.outputs.head_repository }} + HEAD_SHA: ${{ needs.validate.outputs.head_sha }} + PR_NUMBER: ${{ needs.validate.outputs.pr_number }} + REQUEST_ID: ${{ needs.validate.outputs.request_id }} + SOURCE_UPDATED_AT: ${{ needs.validate.outputs.source_updated_at }} + SIMULATOR_REPOSITORY: ${{ github.repository }} + SIMULATOR_SHA: ${{ github.sha }} + SPECTER_SOURCE_DIR: ${{ github.workspace }}/specter-diy + SIMULATOR_DIR: ${{ github.workspace }}/simulator + SIMULATOR_WEB_DIR: ${{ github.workspace }}/simulator/web + FIRMWARE_ARTIFACT_DIR: ${{ github.workspace }}/firmware-artifact + BROWSER_ARCHIVE: ${{ github.workspace }}/browser-preview-payload.tar.gz + steps: + - name: Check out pinned Web Simulator tooling + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 pinned commit + with: + repository: ${{ github.repository }} + ref: ${{ github.sha }} + path: simulator + persist-credentials: false + - name: Check out exact Specter PR source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 pinned commit + with: + repository: ${{ needs.validate.outputs.head_repository }} + ref: ${{ needs.validate.outputs.head_sha }} + path: specter-diy + submodules: recursive + fetch-depth: 0 + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + - name: Write trusted browser manifest for the source check + run: python3 simulator/web/browser/write-browser-manifest.py "$SPECTER_SOURCE_DIR" + - name: Check the PR browser manifest in the untrusted build job + run: python3 simulator/web/tests/test-browser-manifest.py "$SPECTER_SOURCE_DIR" + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + - name: Install firmware build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libffi-dev \ + libgmp-dev \ + libreadline-dev \ + libsdl2-dev \ + pkg-config \ + python3 + python3 -m pip install --upgrade pip + python3 -m pip install -r specter-diy/requirements.txt -r specter-diy/test/integration/requirements.txt + - name: Run Specter native firmware tests + working-directory: specter-diy + run: | + python3 test/run_native_tests.py + python3 -m compileall src test + make test + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@da36cb69b1c3247ad7a1f931ebfd954a1105ef14 # v14 + - name: Build Specter firmware from the verified source SHA + working-directory: specter-diy + run: nix develop -c make disco + - name: Record and stage firmware provenance + id: record_firmware + run: python3 simulator/web/tools/record_firmware.py + - name: Install Emscripten 3.1.74 + run: | + mkdir -p simulator/.browser-work + git init simulator/.browser-work/emsdk + git -C simulator/.browser-work/emsdk remote add origin https://github.com/emscripten-core/emsdk.git + git -C simulator/.browser-work/emsdk fetch --depth 1 origin refs/tags/3.1.74 + git -C simulator/.browser-work/emsdk checkout --detach FETCH_HEAD + test "$(git -C simulator/.browser-work/emsdk rev-parse HEAD)" = "$(cat simulator/.preview-config/emsdk-commit)" + simulator/.browser-work/emsdk/emsdk install 3.1.74 + simulator/.browser-work/emsdk/emsdk activate 3.1.74 + - name: Build browser simulator from the same exact Specter SHA + env: + SPECTER_SRC: ${{ github.workspace }}/specter-diy + SPECTER_SOURCE_REPOSITORY: ${{ needs.validate.outputs.head_repository }} + SIMULATOR_REPOSITORY: ${{ github.repository }} + SIMULATOR_COMMIT: ${{ github.sha }} + run: bash simulator/web/browser/build-browser.sh + - name: Package allowlisted browser output + run: python3 simulator/web/tools/package_browser.py + - name: Upload browser build payload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: browser-preview-payload + path: browser-preview-payload.tar.gz + if-no-files-found: error + retention-days: 30 + - name: Upload firmware artifact + id: firmware + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ steps.record_firmware.outputs.firmware_name }} + path: firmware-artifact/ + if-no-files-found: error + retention-days: 90 + + trusted_runtime: + name: Build trusted JavaScript runtime from the PR base + needs: validate + if: needs.validate.outputs.action == 'build' + runs-on: ubuntu-latest + timeout-minutes: 180 + concurrency: + group: preview-runtime-${{ github.repository }}-${{ needs.validate.outputs.pr_number }} + cancel-in-progress: true + permissions: + contents: read + env: + BASE_REPOSITORY: ${{ needs.validate.outputs.base_repository }} + BASE_SHA: ${{ needs.validate.outputs.base_sha }} + BASE_REF: ${{ needs.validate.outputs.base_ref }} + SIMULATOR_REPOSITORY: ${{ github.repository }} + SIMULATOR_SHA: ${{ github.sha }} + SIMULATOR_COMMIT: ${{ github.sha }} + SPECTER_SRC: ${{ github.workspace }}/specter-diy + SIMULATOR_WEB_DIR: ${{ github.workspace }}/simulator/web + TRUSTED_RUNTIME_DIR: ${{ github.workspace }}/trusted-runtime-artifact + steps: + - name: Check out pinned trusted Web Simulator tooling + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 pinned commit + with: + repository: ${{ github.repository }} + ref: ${{ github.sha }} + path: simulator + persist-credentials: false + - name: Check out exact validated PR base source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 pinned commit + with: + repository: ${{ needs.validate.outputs.base_repository }} + ref: ${{ needs.validate.outputs.base_sha }} + path: specter-diy + submodules: recursive + fetch-depth: 0 + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + - name: Set up Node.js for Emscripten + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + - name: Install trusted browser build dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libffi-dev \ + libgmp-dev \ + libreadline-dev \ + libsdl2-dev \ + pkg-config \ + python3 + - name: Install pinned Emscripten 3.1.74 + run: | + mkdir -p simulator/.browser-work + git init simulator/.browser-work/emsdk + git -C simulator/.browser-work/emsdk remote add origin https://github.com/emscripten-core/emsdk.git + git -C simulator/.browser-work/emsdk fetch --depth 1 origin refs/tags/3.1.74 + git -C simulator/.browser-work/emsdk checkout --detach FETCH_HEAD + test "$(git -C simulator/.browser-work/emsdk rev-parse HEAD)" = "$(cat simulator/.preview-config/emsdk-commit)" + simulator/.browser-work/emsdk/emsdk install 3.1.74 + simulator/.browser-work/emsdk/emsdk activate 3.1.74 + # The untrusted firmware build's `make disco` freezes src/git_info.py + # with HEAD_SHA. Recreate that generated module using only the base's + # trusted generator so Emscripten's JS and the PR WASM share one layout. + - name: Recreate trusted frozen source metadata for the validated PR head + working-directory: specter-diy + env: + SPECTER_GIT_COMMIT: ${{ needs.validate.outputs.head_sha }} + run: python3 tools/embed_git_info.py + - name: Build trusted JavaScript from the exact validated base SHA + env: + SPECTER_SOURCE_REPOSITORY: ${{ needs.validate.outputs.base_repository }} + run: bash simulator/web/browser/build-browser.sh + - name: Package the trusted JavaScript runtime with provenance + run: python3 simulator/web/tools/package_trusted_runtime.py + - name: Upload trusted JavaScript runtime + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: trusted-micropython-runtime + path: trusted-runtime-artifact/ + if-no-files-found: error + retention-days: 30 + + verify: + name: Verify the published preview on a clean runner + needs: [validate, build, trusted_runtime] + if: needs.validate.outputs.action == 'build' + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + actions: read + contents: read + pull-requests: read + env: + BASE_REPOSITORY: ${{ needs.validate.outputs.base_repository }} + BASE_SHA: ${{ needs.validate.outputs.base_sha }} + BASE_REF: ${{ needs.validate.outputs.base_ref }} + HEAD_REPOSITORY: ${{ needs.validate.outputs.head_repository }} + HEAD_SHA: ${{ needs.validate.outputs.head_sha }} + HEAD_REF: ${{ needs.validate.outputs.head_ref }} + PR_NUMBER: ${{ needs.validate.outputs.pr_number }} + ACTION: ${{ needs.validate.outputs.action }} + REQUEST_ID: ${{ needs.validate.outputs.request_id }} + SOURCE_UPDATED_AT: ${{ needs.validate.outputs.source_updated_at }} + SIMULATOR_REPOSITORY: ${{ github.repository }} + SIMULATOR_SHA: ${{ github.sha }} + PAGES_DIR: ${{ github.workspace }}/verification-pages + TRUSTED_WEB_DIR: ${{ github.workspace }}/simulator/web + BROWSER_ARCHIVE: ${{ github.workspace }}/browser-artifact/browser-preview-payload.tar.gz + FIRMWARE_DIR: ${{ github.workspace }}/firmware-artifact + TRUSTED_RUNTIME_DIR: ${{ github.workspace }}/trusted-runtime-artifact + BUILD_RESULT: success + FIRMWARE_URL: ${{ needs.build.outputs.firmware_url }} + TEST_BASE_URL: http://127.0.0.1:8765/ + steps: + - name: Check out trusted Web Simulator test and publisher code + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 pinned commit + with: + repository: ${{ github.repository }} + ref: ${{ github.sha }} + path: simulator + persist-credentials: false + - name: Check out pinned Virtual Host compatibility target + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.0 + with: + repository: cryptoadvance/specter-virtual-host + ref: 3cf3ecd58a97da0f2cc4b7586ca33abf02f68372 + path: virtual-host + persist-credentials: false + - name: Download browser build payload + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: browser-preview-payload + path: browser-artifact + - name: Download firmware artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ needs.build.outputs.firmware_name }} + path: firmware-artifact + - name: Download trusted JavaScript runtime + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: trusted-micropython-runtime + path: trusted-runtime-artifact + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 pinned commit + with: + go-version: '1.22' + - name: Install trusted browser test dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + libffi-dev \ + libgmp-dev \ + libreadline-dev \ + libsdl2-dev \ + pkg-config \ + python3 + npm ci --prefix simulator/web + npx --prefix simulator/web playwright install --with-deps chromium firefox webkit + - name: Compose and validate the exact preview using the trusted publisher + env: + GH_TOKEN: ${{ github.token }} + run: python3 simulator/web/tools/publish_preview.py + - name: Verify the composed browser artifact hashes + env: + EXPECTED_SHA: ${{ needs.validate.outputs.head_sha }} + SPECTER_SOURCE_REPOSITORY: ${{ needs.validate.outputs.head_repository }} + SIMULATOR_COMMIT: ${{ github.sha }} + SIMULATOR_REPOSITORY: ${{ github.repository }} + run: | + python3 -c 'from pathlib import Path; import os; from simulator.web.browser.verify_build import verify; verify(Path(os.environ["PAGES_DIR"]) / "pr" / os.environ["PR_NUMBER"] / os.environ["EXPECTED_SHA"], os.environ["EXPECTED_SHA"], os.environ["SPECTER_SOURCE_REPOSITORY"], os.environ["SIMULATOR_COMMIT"], os.environ["SIMULATOR_REPOSITORY"])' + - name: Run trusted Web Simulator Python and provenance tests + env: + EXPECTED_SHA: ${{ needs.validate.outputs.head_sha }} + SPECTER_SOURCE_REPOSITORY: ${{ needs.validate.outputs.head_repository }} + SIMULATOR_COMMIT: ${{ github.sha }} + SIMULATOR_REPOSITORY: ${{ github.repository }} + run: | + for test in simulator/web/tests/test-*.py; do + if [[ "$test" == *test-browser-manifest.py ]]; then + continue + fi + python3 "$test" + done + - name: Serve the composed preview for browser and transport tests + run: | + python3 -m http.server 8765 --directory "$PAGES_DIR/pr/$PR_NUMBER/$HEAD_SHA" > /tmp/specter-browser.log 2>&1 & + echo $! > /tmp/specter-browser.pid + for attempt in $(seq 1 30); do curl --fail --silent "$TEST_BASE_URL" >/dev/null && break; sleep 1; done + - name: Run browser, compatibility, and network policy tests + run: | + CI=true npm run test:browser --prefix simulator/web + npm run test:provenance --prefix simulator/web + npm run test:compat --prefix simulator/web + node simulator/web/tests/test-network-policy.mjs + - name: Run pinned Virtual Host and USB transport tests + run: | + usb_site="$RUNNER_TEMP/specter-usb-preview" + mkdir -p "$usb_site" + cp -a "$PAGES_DIR/pr/$PR_NUMBER/$HEAD_SHA/." "$usb_site/" + cp simulator/web/index.html "$usb_site/index.html" + python3 -m http.server 8766 --directory "$usb_site" > /tmp/specter-usb-site.log 2>&1 & + for attempt in $(seq 1 30); do curl --fail --silent http://127.0.0.1:8766/ >/dev/null && break; sleep 1; done + test "$(git -C virtual-host rev-parse HEAD)" = "$(cat simulator/.preview-config/virtual-host-commit)" + (cd virtual-host && go test ./...) + (cd virtual-host && go run . --site http://127.0.0.1:8766/ --no-open) > /tmp/specter-virtual-host.log 2>&1 & + for attempt in $(seq 1 30); do curl --silent --output /dev/null http://127.0.0.1:8788/ && break; sleep 1; done + python3 simulator/web/tests/test-usb-vcp.py + TEST_BASE_URL=http://127.0.0.1:8766/ node simulator/web/tests/test-usb-transport.mjs + + finalize: + name: Publish immutable preview state and PR result + needs: [validate, build, trusted_runtime, verify] + if: ${{ always() && needs.validate.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + concurrency: + group: preview-publish-${{ github.repository }} + cancel-in-progress: false + permissions: + actions: write + contents: write + pages: write + id-token: write + pull-requests: read + env: + GH_TOKEN: ${{ github.token }} + REQUEST_ID: ${{ needs.validate.outputs.request_id }} + ACTION: ${{ needs.validate.outputs.action }} + BASE_REPOSITORY: ${{ needs.validate.outputs.base_repository }} + BASE_SHA: ${{ needs.validate.outputs.base_sha }} + BASE_REF: ${{ needs.validate.outputs.base_ref }} + PR_NUMBER: ${{ needs.validate.outputs.pr_number }} + HEAD_REPOSITORY: ${{ needs.validate.outputs.head_repository }} + HEAD_SHA: ${{ needs.validate.outputs.head_sha }} + HEAD_REF: ${{ needs.validate.outputs.head_ref }} + SOURCE_UPDATED_AT: ${{ needs.validate.outputs.source_updated_at }} + SIMULATOR_REPOSITORY: ${{ github.repository }} + SIMULATOR_SHA: ${{ github.sha }} + PAGES_DIR: ${{ github.workspace }}/pages + TRUSTED_WEB_DIR: ${{ github.workspace }}/simulator/web + BROWSER_ARCHIVE: ${{ github.workspace }}/browser-artifact/browser-preview-payload.tar.gz + FIRMWARE_DIR: ${{ github.workspace }}/firmware-artifact + TRUSTED_RUNTIME_DIR: ${{ github.workspace }}/trusted-runtime-artifact + BUILD_RESULT: ${{ inputs.action == 'delete' && 'deleted' || (needs.build.result == 'success' && needs.trusted_runtime.result == 'success' && needs.verify.result == 'success') && 'success' || (needs.build.result == 'cancelled' || needs.trusted_runtime.result == 'cancelled' || needs.verify.result == 'cancelled') && 'cancelled' || 'failure' }} + FIRMWARE_URL: ${{ needs.build.outputs.firmware_url }} + steps: + - name: Check out trusted publisher implementation + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 pinned commit + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Check out trusted Web Simulator static shell and publisher tools + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 pinned commit + with: + repository: ${{ github.repository }} + ref: ${{ github.sha }} + path: simulator + persist-credentials: false + - name: Download browser artifact data + if: needs.build.result == 'success' && needs.validate.outputs.action == 'build' + continue-on-error: true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: browser-preview-payload + path: browser-artifact + - name: Download firmware artifact data + if: needs.build.result == 'success' && needs.validate.outputs.action == 'build' + continue-on-error: true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ needs.build.outputs.firmware_name }} + path: firmware-artifact + - name: Download trusted JavaScript runtime + if: needs.build.result == 'success' && needs.trusted_runtime.result == 'success' && needs.validate.outputs.action == 'build' + continue-on-error: true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: trusted-micropython-runtime + path: trusted-runtime-artifact + - name: Prepare persistent Pages branch + run: | + gh auth setup-git + remote="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" + if git ls-remote --exit-code "$remote" refs/heads/gh-pages >/dev/null 2>&1; then + git clone --branch gh-pages --single-branch "$remote" pages + else + git clone --no-checkout "$remote" pages + git -C pages switch --orphan gh-pages + git -C pages rm -rf . 2>/dev/null || true + git -C pages clean -fdx + fi + git -C pages config user.name "github-actions[bot]" + git -C pages config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Apply validated result to the persistent Pages tree + id: publish + run: python3 simulator/web/tools/publish_preview.py + - name: Remove replaced previews and evict old PRs only near the Pages limit + id: prune-pages + if: >- + steps.publish.outcome == 'success' && + steps.publish.outputs.applied == 'true' && + needs.validate.outputs.action == 'build' + env: + PAGES_DIR: ${{ github.workspace }}/pages + PR_NUMBER: ${{ needs.validate.outputs.pr_number }} + run: python3 simulator/web/tools/prune_pages.py + - name: Commit and update GitHub Pages source tree + if: steps.publish.outcome == 'success' + run: | + git -C pages add -A + if ! git -C pages diff --cached --quiet; then + git -C pages commit -m "Update PR $PR_NUMBER preview state" + git -C pages push origin gh-pages + fi + - name: Stage the complete public Pages tree without private state + if: steps.publish.outcome == 'success' + run: python3 simulator/web/tools/stage_pages_site.py pages pages-site + - name: Upload static Pages artifact + if: steps.publish.outcome == 'success' + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: pages-site + - name: Deploy Web Simulator Pages + if: steps.publish.outcome == 'success' + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 + - name: Update the single machine-user preview comment + id: report + if: needs.validate.outputs.base_repository == 'cryptoadvance/specter-diy' && steps.publish.outputs.applied == 'true' && steps.deployment.outcome == 'success' + env: + REQUEST_ID: ${{ needs.validate.outputs.request_id }} + ACTION: ${{ needs.validate.outputs.action }} + BASE_REPOSITORY: ${{ needs.validate.outputs.base_repository }} + BASE_SHA: ${{ needs.validate.outputs.base_sha }} + BASE_REF: ${{ needs.validate.outputs.base_ref }} + PR_NUMBER: ${{ needs.validate.outputs.pr_number }} + HEAD_REPOSITORY: ${{ needs.validate.outputs.head_repository }} + HEAD_SHA: ${{ needs.validate.outputs.head_sha }} + HEAD_REF: ${{ needs.validate.outputs.head_ref }} + SOURCE_UPDATED_AT: ${{ needs.validate.outputs.source_updated_at }} + SIMULATOR_REPOSITORY: ${{ github.repository }} + SIMULATOR_SHA: ${{ github.sha }} + BUILD_RESULT: ${{ steps.publish.outputs.status }} + PAGES_DIR: ${{ github.workspace }}/pages + SPECTER_PREVIEW_BOT_TOKEN: ${{ secrets.SPECTER_PREVIEW_BOT_TOKEN }} + SPECTER_PREVIEW_BOT_LOGIN: ${{ vars.SPECTER_PREVIEW_BOT_LOGIN }} + run: python3 simulator/web/tools/report_preview.py + - name: Update machine-user PR comments whose previews were evicted + id: report-capacity-evictions + if: >- + steps.prune-pages.outputs.evicted_pr_numbers != '' && + steps.prune-pages.outputs.evicted_pr_numbers != '[]' && + steps.publish.outputs.applied == 'true' && + steps.deployment.outcome == 'success' && + needs.validate.outputs.base_repository == 'cryptoadvance/specter-diy' && + steps.report.outcome == 'success' && + vars.SPECTER_PREVIEW_BOT_LOGIN != '' + env: + CAPACITY_EVICTED_PR_NUMBERS: ${{ steps.prune-pages.outputs.evicted_pr_numbers }} + PAGES_DIR: ${{ github.workspace }}/pages + SIMULATOR_REPOSITORY: ${{ github.repository }} + SIMULATOR_SHA: ${{ github.sha }} + SPECTER_PREVIEW_BOT_TOKEN: ${{ secrets.SPECTER_PREVIEW_BOT_TOKEN }} + SPECTER_PREVIEW_BOT_LOGIN: ${{ vars.SPECTER_PREVIEW_BOT_LOGIN }} + run: python3 simulator/web/tools/report_preview.py + - name: Remove replaced PR firmware artifacts + id: prune-firmware + if: >- + always() && + steps.publish.outcome == 'success' && + steps.deployment.outcome == 'success' && + steps.publish.outputs.firmware_artifacts_to_prune != '[]' && + (steps.publish.outputs.applied != 'true' || + needs.validate.outputs.base_repository != 'cryptoadvance/specter-diy' || + steps.report.outcome == 'success') + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ needs.validate.outputs.pr_number }} + FIRMWARE_ARTIFACTS_TO_PRUNE: ${{ steps.publish.outputs.firmware_artifacts_to_prune }} + run: python3 simulator/web/tools/prune_firmware_artifacts.py + - name: Add finalization result to the workflow summary + if: always() + run: | + printf 'Request: `%s`\n' "$REQUEST_ID" >> "$GITHUB_STEP_SUMMARY" + printf 'Action: `%s`\n' "$ACTION" >> "$GITHUB_STEP_SUMMARY" + printf 'Build job: `%s`\n' '${{ needs.build.result }}' >> "$GITHUB_STEP_SUMMARY" + printf 'Trusted runtime job: `%s`\n' '${{ needs.trusted_runtime.result }}' >> "$GITHUB_STEP_SUMMARY" + printf 'Trusted verification job: `%s`\n' '${{ needs.verify.result }}' >> "$GITHUB_STEP_SUMMARY" + printf 'Publication result: `%s`\n' '${{ steps.publish.outputs.status }}' >> "$GITHUB_STEP_SUMMARY" + printf 'PR comment report: `%s`\n' '${{ steps.report.outcome }}' >> "$GITHUB_STEP_SUMMARY" + printf 'Pages capacity cleanup: `%s` (`%s` bytes after, evicted PRs `%s`)\n' '${{ steps.prune-pages.outcome }}' '${{ steps.prune-pages.outputs.bytes_after }}' '${{ steps.prune-pages.outputs.evicted_pr_numbers }}' >> "$GITHUB_STEP_SUMMARY" + printf 'Firmware artifact cleanup: `%s`\n' '${{ steps.prune-firmware.outcome }}' >> "$GITHUB_STEP_SUMMARY" diff --git a/.preview-config/emsdk-commit b/.preview-config/emsdk-commit new file mode 100644 index 0000000..0774256 --- /dev/null +++ b/.preview-config/emsdk-commit @@ -0,0 +1 @@ +3d6d8ee910466516a53e665b86458faa81dae9ba diff --git a/.preview-config/virtual-host-commit b/.preview-config/virtual-host-commit new file mode 100644 index 0000000..62a10e8 --- /dev/null +++ b/.preview-config/virtual-host-commit @@ -0,0 +1 @@ +3cf3ecd58a97da0f2cc4b7586ca33abf02f68372 diff --git a/README.md b/README.md index 92bd456..ee4f754 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,34 @@ -# Specter DIY web simulator +# Specter DIY Web Simulator -Browser simulator tooling for Specter DIY. It provides the WebAssembly build, -simulated smartcard/SD-card/USB runtime, browser shell, provenance checks, -and tests used by -[`cryptoadvance/specter-diy`](https://github.com/cryptoadvance/specter-diy). +This repository owns the browser simulator and the complete pull request preview +service for Specter DIY. It builds the requested exact Specter source revision, +builds matching firmware, runs firmware and browser tests, validates the +resulting artifacts, hosts PR previews on this repository's GitHub Pages site, +and keeps preview status and lifecycle state. -The firmware repository resolves simulator `main` once per build, checks out -that exact commit in its read-only build jobs, and runs the simulator scripts -from that checkout. The exact Specter source and simulator commits are recorded -in every browser build for provenance. +Specter DIY only validates and dispatches PR metadata to this paired +repository, then exits; it does not wait for a build, edit PR comments, or host +the browser preview. The Web Simulator publishes the immutable preview and +public status JSON. For upstream `cryptoadvance/specter-diy` PRs, its trusted +finalizer uses the token of a dedicated **non-collaborator machine user** to +create or update a single marked bot-owned preview comment in place, including +a collapsible build-provenance section, firmware link, simulator link, and +strong test-only warning. No token is exposed to an untrusted PR build. +Paired forks can build previews without the upstream bot credential and +therefore skip automatic upstream comments. A separate fork-only account +and secret are needed when testing comment writing on fork PRs. -See [the simulator workflow and release notes](docs/browser-simulator.md) for -the repository contract and migration details. +Start with [the manual build and setup guide](docs/browser-simulator.md). It +covers local builds, paired forks, the narrow dispatch token, Pages setup, and +the build/publisher security boundary. -The official browser build and pull-request previews are published by the -firmware repository at . To -connect a running simulator to wallet software, use the +The simulator runs the actual `src/` application from +[`cryptoadvance/specter-diy`](https://github.com/cryptoadvance/specter-diy); +this repository contains the browser shell, device shims, and build/test +tooling, not another wallet implementation. For desktop integration use the [`cryptoadvance/specter-virtual-host`](https://github.com/cryptoadvance/specter-virtual-host) -local bridge and its [latest release](https://github.com/cryptoadvance/specter-virtual-host/releases/latest). +bridge at the immutable revision documented in `.preview-config/`. + +**Experimental development build. Never enter a real seed phrase or use real +funds.** A PR preview contains code produced from an untrusted PR and executes +in the visitor's browser. diff --git a/docs/browser-simulator.md b/docs/browser-simulator.md index b1bbe37..d0f97aa 100644 --- a/docs/browser-simulator.md +++ b/docs/browser-simulator.md @@ -1,168 +1,332 @@ -# Browser simulator and PR previews +# Browser simulator and PR preview service -The browser simulator runs the **actual** Specter DIY `src/` application under -the fork's MicroPython Unix port, compiled with Emscripten 3.1.74. LVGL draws -the display in a Web Worker. The website presents that framebuffer inside the -physical device image and forwards pointer coordinates to LVGL. Browser shims -replace only device transport: `/state/sd` via `platform.SDCard`, scanner data -via `pyb.UART('YA')`, and MemoryCard APDUs via `uscard.Reader`. +The browser simulator runs the actual Specter DIY `src/` application under +MicroPython compiled to WebAssembly with Emscripten 3.1.74. This repository +provides the browser shell, simulated peripherals, build scripts, and tests; it +does not contain a second wallet implementation. ```text -Browser page → Worker → MicroPython/WASM → Specter Python → LVGL → Canvas - ↑ ↑ - virtual SD/card QR scanner seam +specter-diy PR event + └─ short trusted dispatcher ── workflow_dispatch + exact PR metadata ──┐ + ▼ + specter-diy-web-simulator + ├─ validate live PR + ├─ untrusted runner: firmware + WASM/data + tests + ├─ fresh trusted runner: JS from exact base SHA + ├─ finalizer: validate and combine artifacts + ├─ publish immutable Pages preview + trusted state + └─ machine user updates one marked PR comment ``` +The official preview URL for a PR includes its full source commit SHA, for +example `https://cryptoadvance.github.io/specter-diy-web-simulator/pr/45//`. +A paired fork uses the matching owner, for example +`https://alice.github.io/specter-diy-web-simulator/pr/12//`. + **Experimental development build. Never enter a real seed phrase or use real -funds.** The browser and any automatically built firmware lack the security -assurances of an official release. Use test seeds and dedicated test hardware. -Imported SD files, scanned QR payloads, and virtual card data stay in the tab; -the shell fetches static assets only. Runtime sockets and SSL are disabled. -Reloading discards simulated state. Normal restart retains simulated flash and -peripheral files; factory reset wipes flash separately. The webcam requires -HTTPS or localhost and browser permission. Some browser versions need the -local Unix simulator; physical-device camera, secure element, air-gap, -STM32 timing, battery, and physical card properties are not simulated. - -The SD-card panel starts with its demo-set selector at **None**, beside **Add -files** and **Clear card**. Selecting Testnet or Mainnet replaces the previous -demo files and loads that network's public seed examples and transactions. The -SD card is inserted automatically when the demo needs it; Smartcards are never -inserted automatically. Returning to **None** removes demo files and restores -the previous simulated Smartcard contents and peripheral insertion state. -Unrelated files on the SD card remain in place, and this selection is not saved -across reloads. - -## Build locally - -From Linux or WSL with this repository checked out next to a recursive -Specter-DIY checkout: - -1. Install and activate [Emscripten SDK](https://emscripten.org/docs/getting_started/downloads.html) - **3.1.74** (`emcc --version` must report it), plus the native build - dependencies required by the Specter-DIY source checkout. -2. Run `SPECTER_SRC=/path/to/specter-diy bash web/browser/build-browser.sh` - from this repository's root. The source checkout must be at the exact - commit you intend to simulate. -3. Run `python3 web/browser/verify_build.py` and - `python3 web/tests/test-source-project.py`. -4. Run `npm ci --prefix web`, `npx --prefix web playwright install chromium`, - then `python3 -m http.server 8765 --directory web` in one shell and - `CI=true npm run test:browser --prefix web` in another. Run - `npm run test:compat --prefix web` after installing Firefox and WebKit with - Playwright for additional engine coverage. - -The build output is `web/builds////` with -`micropython.js`, `.wasm`, `.data`, and `build-info.json`. The manifest records -two immutable inputs: `source.repository`/`source.commit` identify the -Specter-DIY code being simulated, while `simulator.repository`/ -`simulator.commit` identify the simulator tooling that built it. It also -records the Emscripten version, build time, and SHA256 of each artifact. -`web/browser/current.json` remains only a pointer to the build. Both generated -directories are ignored by Git. `SPECTER_SOURCE_REPOSITORY=owner/repo`, -`SIMULATOR_REPOSITORY=owner/repo`, and `SIMULATOR_COMMIT=` can be -used when building source and tooling from different repositories. - -The build script applies only browser compatibility changes to the checked-out -MicroPython/LVGL C submodules. It freezes the wallet's `src/` tree without -changing wallet screens or logic. Browser-specific Python, JS, and source -patching stay under `web/browser/`. The existing Unix simulator and hardware -firmware build remain separate. - -For newer board revisions, the browser freeze uses the board's curated -`f469-disco/manifests/common.py`, including embit from its `src/` package path. -Older revisions without that manifest retain the original flat-library freeze. -The build checks the resulting module list before compiling WebAssembly, so -CPython-only embit examples and tests cannot enter a current browser build. - -## CI and Pages - -The Specter-DIY `Build` workflow runs native tests, builds Unix and STM32 -firmware, and runs browser/QR/SD/Smartcard smoke tests. It resolves simulator -`main` once and checks out the exact simulator SHA in every build job before -running simulator scripts. This keeps the execution context and read-only token -in the Specter repository. The browser and firmware artifacts carry separate `source.json` -records, and the browser `build-info.json` carries both provenance records. -The build workflow has **read-only** repository permissions and no deployment -secret. - -The firmware repository does not call a remote reusable workflow at `main`: -GitHub could resolve that workflow independently from the simulator SHA in -build metadata. The privileged publisher implementation lives in the firmware -repository's protected default branch. - -For PRs, the read-only `Build` workflow runs directly on `pull_request` and -uses the exact PR head repository and SHA from the event. PR source runs only -in jobs with read-only permissions. A separate job builds Emscripten JavaScript from -default-branch firmware and trusted simulator tooling. The PR build supplies -WebAssembly and frozen firmware data. The read-only build replaces the PR -build's generated JavaScript with its runtime and updates artifact hashes. A -separate read-only job in the protected publish workflow independently builds -the trusted runtime; the write-enabled job verifies the browser JavaScript -against that artifact. HTML, CSS, images, UI logic, worker, peripheral -implementation, and the experimental warning come from the publisher's -simulator checkout. Its worker inherits a CSP allowing same-site assets and -the local Virtual Host on port 8788 while blocking other outbound connections. - -A separate `Publish browser simulator` workflow runs from the trusted default -branch after `Build` completes. It verifies that the browser manifest, its -artifact hashes, the firmware hashes, and both provenance records identify the -same still-current PR head. It never executes the downloaded build. A passing -default-branch build updates the stable Pages root; a passing PR build updates -`/pr//` and a single PR comment with links to the simulator, firmware -artifact, and build log. A failed current PR build removes its stale preview -and replaces that one comment with a failure notice, even when it uploaded no -artifacts. The PR is identified from the trusted `workflow_run` event and -checked against the current pull-request API record. A run -superseded by a newer PR commit cannot replace the current preview. The -publisher keeps an -`gh-pages` branch as static state and uses `actions/deploy-pages` to deploy the -complete tree. PRs receive no write token or deployment credentials. - -Deploy the simulator changes to its trusted `main` branch before enabling the -new firmware workflow on the firmware default branch. Remove legacy PR -previews from `gh-pages` once, then rebuild open PRs so old published shells -cannot remain reachable. GitHub Pages must use **GitHub Actions** as its -deployment source. A PR with new C imports may require a compatible trusted -Emscripten runtime update before its WebAssembly can run; the browser smoke -tests must pass with the combined artifacts. - -### Rebuild an older open PR without a commit - -Once this workflow is on the default branch, use **Actions → Build → Run -workflow**, select the default branch, and enter the PR number and the first -seven (or more) hexadecimal characters of its current head SHA. The Build job -resolves the prefix against that PR's current full head SHA before checking out -source. If the head changes to a different prefix before publication, the -publisher ignores the stale run. Seven characters are convenient but are not -globally unique; use a longer prefix when comparing closely spaced revisions. -Whitespace around the inputs is ignored. If the SHA does not match the PR's -current head, the target job reports the current prefix and stops the build. -The CLI helper needs only the PR number and reads the SHA itself: +funds.** The firmware and browser build are not official releases. A preview +executes code from an untrusted PR in the visitor's browser. + +## Manual build + +Choose both a source repository and its exact full commit SHA. For a contributor +PR, use the contributor's source repository and the PR head SHA; the base +repository still determines which paired Web Simulator receives CI requests. + +```sh +git clone https://github.com/bob/specter-diy.git +git -C specter-diy checkout --detach <40-character-source-sha> +git clone https://github.com/cryptoadvance/specter-diy-web-simulator.git +``` + +Install the native packages used by Specter, Nix, Python 3.11, Node.js 22, Go +1.22, and Emscripten 3.1.74. CI checks out the emsdk installer at the immutable +commit in `.preview-config/emsdk-commit`, then installs Emscripten 3.1.74 from +that pinned installer. From Linux or WSL: + +```sh +sudo apt-get install build-essential libffi-dev libgmp-dev libreadline-dev \ + libsdl2-dev pkg-config python3 +nix develop -c make disco +``` + +Build the browser simulator from the same checked-out Specter SHA: + +```sh +SPECTER_SRC="$PWD/specter-diy" \ +SPECTER_SOURCE_REPOSITORY="bob/specter-diy" \ +SIMULATOR_REPOSITORY="cryptoadvance/specter-diy-web-simulator" \ +SIMULATOR_COMMIT="$(git -C specter-diy-web-simulator rev-parse HEAD)" \ +bash specter-diy-web-simulator/web/browser/build-browser.sh +``` + +The firmware is written to `specter-diy/bin/`. The browser files are written +under `specter-diy-web-simulator/web/builds////` and +the current-build pointer is `web/browser/current.json`. The browser manifest +records the source repository/SHA, simulator repository/SHA, toolchain, and +SHA256 and size for each generated file. + +Run the firmware and browser tests used by CI: + +```sh +python3 -m pip install -r specter-diy/requirements.txt \ + -r specter-diy/test/integration/requirements.txt +python3 specter-diy/test/run_native_tests.py +make -C specter-diy test +npm ci --prefix specter-diy-web-simulator/web +npx --prefix specter-diy-web-simulator/web playwright install chromium +``` + +Start the static site from the Web Simulator repository and run the browser +checks from another terminal: + +```sh +cd specter-diy-web-simulator/web +python3 -m http.server 8765 +``` ```sh -python3 web/tools/trigger_pr_build.py 123 --repo cryptoadvance/specter-diy +cd specter-diy-web-simulator/web +CI=true npm run test:browser +npm run test:provenance +node tests/test-network-policy.mjs +python3 tests/test-usb-vcp.py ``` -This starts the existing `Build` workflow; it does not create another Actions -workflow or add a commit to the PR. Manual runs check out the PR's exact head -for Specter source and firmware, but use the current default branch's browser -build tools and website shell. The browser manifest records both the PR source -commit and the simulator tooling commit. The publisher checks both, -and it can remove a failed current manual preview without downloading any -artifact. A very old PR with incompatible MicroPython/LVGL or firmware sources -may still fail to build; its build log will show the concrete incompatibility. -GitHub's manual Run workflow button is unavailable until this workflow file is -present on the repository's default branch. - -For `cryptoadvance/specter-diy`, enable **Settings → Pages → Build and deployment → -GitHub Actions** once. Confirm Actions are enabled and allow the publisher workflow -to write to the repository. After the first successful default-branch build, -the stable URL is `https://cryptoadvance.github.io/specter-diy/`; PR previews -are `https://cryptoadvance.github.io/specter-diy/pr//`. The same workflow -uses `GITHUB_REPOSITORY` and works in another fork after its owner enables -Actions and Pages. PR previews are untrusted development code; the warning -is permanent and no wallet secrets should ever be entered. +For Virtual Host compatibility, use the exact commit in +`.preview-config/virtual-host-commit`, then run its Go tests and the browser USB +transport test: + +```sh +git clone https://github.com/cryptoadvance/specter-virtual-host.git +git -C specter-virtual-host checkout --detach \ + "$(cat specter-diy-web-simulator/.preview-config/virtual-host-commit)" +(cd specter-virtual-host && go test ./...) +(cd specter-virtual-host && go run . --site http://127.0.0.1:8765 --no-open) +``` + +In another terminal, run `node tests/test-usb-transport.mjs` from the Web +Simulator `web/` directory. The required CI checkouts use the immutable +revision recorded in `.preview-config/virtual-host-commit`; they never use the +moving `main` branch. To update the pin, review a specific Virtual Host commit, +change both the pin file and the workflow's checkout SHA, then run the full +USB VCP and USB transport checks. + +## Set up paired forks + +Fork **both** repositories under the same GitHub account or organization: + +1. Fork `cryptoadvance/specter-diy`. +2. Fork `cryptoadvance/specter-diy-web-simulator` under the same owner. +3. Enable GitHub Actions in both forks. +4. In the Web Simulator fork, set **Settings → Pages → Build and deployment → + Source: GitHub Actions**. +5. Create a fine-grained personal access token. Choose the same resource owner, + grant repository access to **only** `/specter-diy-web-simulator`, and + grant **Actions: Read and write**. It does not need Contents, Administration, + Issues, or Pull requests permissions. +6. Save the token in the Specter DIY fork as the Actions secret + `WEB_SIMULATOR_DISPATCH_TOKEN`. + +The default pairing is `${github.repository_owner}/specter-diy-web-simulator`. +If the paired service uses another repository name, set the Actions variable +`WEB_SIMULATOR_REPOSITORY` in the Specter DIY repository to `owner/repository`. +The PR source repository can still be a contributor fork; the paired service is +selected from the PR's base repository owner. + +An organization that prefers GitHub Apps can replace the fine-grained PAT with +an installation token limited to the same single Web Simulator repository and +the ability to dispatch Actions workflows. A custom GitHub App is not required +for the basic setup. + +## Request validation and lifecycle + +Specter DIY's `pull_request_target` workflow handles PR open, synchronize, +reopen, ready-for-review, and close events. Same-repository and fork PRs both +start automatically after the dispatcher verifies the current PR and exact +head SHA. Closing a PR always dispatches cleanup. The workflow reads only +trusted event metadata and a script checked out from the protected default +branch; it does not check out or execute PR code. It dispatches a unique request +ID, base repository and exact base SHA/ref, head repository, PR number, exact +head SHA/ref, action, and source `updated_at`. Closing a PR sends +`action=delete`. + +After a successful dispatch, the Specter workflow exits. It does not wait for +the firmware or browser build. The simulator finalizer validates the live PR +again before publication and before reporting. Its persistent state prevents a +stale finalizer from replacing a newer result or comment. + +This workflow uses `pull_request_target`. Repository or organization Actions +policy must permit that event for public repositories. A repository or +organization administrator should verify the policy before GitHub's announced +November 2, 2026 enforcement date. + +The Web Simulator validates the paired base repository and compares the request +to the live GitHub PR before building. Build requests must match an open PR's +base, head repository, branch, number, and exact SHA. The source checkout is by +SHA, including submodules. The trusted finalizer validates the exact request, +run identity, monotonic state, and live PR again before it reports the result. + +The trusted simulator finalizer writes a **single normal PR comment** through a +dedicated, non-collaborator **machine-user account** (for example, +`specter-preview-bot`). The bot must have **only read access** to +`cryptoadvance/specter-diy`: do not add it as a repository collaborator or +organization member with write, triage, or admin permissions. It is a regular +GitHub user, not a GitHub App; therefore GitHub does not display a Bot badge. +Its profile should clearly identify the account as automated. + +The finalizer confirms the PAT belongs to the configured GitHub user through +`GET /user`, then revalidates the exact live PR and trusted published state. +It creates a marked comment only if none exists; otherwise it **PATCHes the +existing comment in place**, preserving its ID and avoiding duplicate timeline +entries/notifications. The comment includes the compact `Specter PR Build` +status, collapsed build provenance, browser simulator and matching firmware +links, and a prominent experimental-build safety warning. The reporter only +updates/deletes comments that BOTH belong to the expected bot user AND contain +``. Additional older matching comments +are cleaned up, but other users' comments are not touched. Closing a PR removes +only its own marked comments. Pages capacity eviction updates the existing +comment without linking to a removed preview. + +Configure these settings on the **real Web Simulator repository**, not on +Specter DIY and not in the bot's personal account: + +- Repository secret `SPECTER_PREVIEW_BOT_TOKEN`: a **production** Personal + Access Token (classic) created by the dedicated machine user, with only + `public_repo` scope and a limited expiration. Rotate/revoke this credential + promptly if exposed. **Do not reuse the fork test PAT.** +- Repository variable `SPECTER_PREVIEW_BOT_LOGIN`: the machine user's exact + GitHub login, without `@`. + +A classic PAT's `public_repo` scope is wider than PR comments, but it never +grants repository permissions beyond the machine user's own. Because this user +has no write access to Specter DIY, it cannot edit third-party PR descriptions +or other users' comments. It may still perform ordinary GitHub public-user +actions (such as opening its own issues). The bot token must be kept ONLY in +the trusted finalizer; untrusted build, verification, and dispatch jobs must +never receive it. Keep `WEB_SIMULATOR_DISPATCH_TOKEN` separately in the +Specter DIY repository: it only dispatches the Web Simulator workflow. + +### Production merge prerequisite: protect `main` + +An organization administrator must configure branch protection or a repository +ruleset for `cryptoadvance/specter-diy-web-simulator` targeting `main` before +the service is deployed as the trusted preview publisher: + +- Require a maintainer PR review and the `checks` CI status before merging. +- Prevent direct pushes and force pushes, and avoid broad bypass exceptions. +- Treat changes to `.github/workflows/` and `web/tools/` as security-sensitive; + require review from maintainers who understand the preview trust boundary. +- Verify the actual branch/ruleset enforcement in GitHub Settings, not just a + documented policy or a green status badge. + +The `main` branch must be protected because its workflow code can publish +browser content and, once the machine-user token is configured, manage the +machine user's marked comments on Specter PRs. Repository Actions secrets, +variables, branch protection and organization policy require an administrator. + +The trusted finalizer's `GITHUB_TOKEN` has `Actions: write` **only** in +the Web Simulator repository to delete superseded firmware artifacts. The +machine-user PAT is used separately to manage its own marked comments on +`cryptoadvance/specter-diy`. PR-controlled build jobs never receive either +privileged token. + +External contributors targeting `cryptoadvance/specter-diy` are supported. +Paired Web Simulator forks can build previews separately without receiving +the **production** bot credential. Their own tests require a different +fork-scoped machine-user token and explicit fork-only repository guards. + +The service uses its workflow token with `pull-requests: read` to validate the +live PR. Published PR pages receive a stricter copy of the trusted shell's +Content Security Policy: `connect-src 'self'`. This blocks external network +requests and the local Virtual Host WebSocket from PR previews. The stable +simulator keeps the local Virtual Host allowance. Browser tests exercise both +policies. + +The main simulator page has an expandable **Virtual USB Connection** panel +with a link to the latest Specter Virtual Host release, setup steps, and live +local bridge status. The release link follows GitHub's latest-release redirect +instead of pinning a version or asset filename. It opens the localhost WebSocket +only when the visitor expands the panel (or explicitly enables +`?virtual-host=1`). PR previews keep the panel visible but explain that the local +connection is disabled there; their stricter CSP remains in force. + +Successful PR previews use commit-specific paths `/pr///`. +After a newer preview has been built successfully, the trusted publisher removes +the older browser pages for that PR, so only its latest successful preview URL +remains online. A failed or cancelled build keeps the last successful page. +The publisher retains its provenance and firmware link in +`.preview-state/pr/.json` on the persistent `gh-pages` branch. Each PR has +one current firmware artifact after a successful publication, retained for up +to 90 days. After a newer preview is published and its PR report succeeds, the +finalizer deletes the replaced firmware artifacts for that PR. + +Before deployment, the workflow measures the public Pages files. It does not +remove pages for other PRs during normal publishing. If the public tree exceeds +its 950 MB safety budget, it evicts previews from the least recently updated +PRs until the tree is under budget, while protecting the PR currently being +published. Evicted PR comments are updated to explain that their browser page +was removed for Pages capacity; their firmware artifact remains available. If +older PR pages cannot bring the tree under budget, publication stops instead +of deleting the current PR's new preview. Closing a PR removes every public +preview under `/pr//`, deletes the firmware artifacts recorded for the PR, +clears its success history, and leaves a minimal tombstone to reject delayed +requests. The state is excluded from public Pages output. +The firmware download ZIP is named `specter-firmware_PR-440_47e6588891f3.zip` +(PR number and the first 12 characters of its exact source commit). Inside the +ZIP, the files use the same base name with `.bin` and `.hex` extensions; +`source.json` continues to carry full-source-SHA, hashes and provenance. +On a GitHub Actions rerun (attempt 2+), only the ZIP artifact name adds +`_attempt-2` etc. This avoids collisions with an earlier artifact from the +same workflow run without overwriting the previous firmware. + +The untrusted job builds firmware and WebAssembly from the exact PR head SHA. +Its browser archive contains only `micropython.wasm` and `micropython.data`; +the PR-generated `micropython.js` is checked locally during that job but is +never included in the artifact. A separate fresh runner checks out the exact +live PR base SHA and trusted Web Simulator tooling, builds the browser runtime, +and uploads only `micropython.js` with its provenance. The trusted finalizer +checks both artifacts, binds them to the live base/head SHAs, then combines the +trusted JavaScript with the validated PR WebAssembly/data. It checks exact file +allowlists, paths, file types, hashes, manifests, and provenance; safe +extraction rejects traversal, absolute paths, symlinks, executables, malformed +archives, and unexpected files. Artifact contents are never executed during +publication. The final JavaScript and WebAssembly are loaded by the visitor's +browser as the preview. + +## Security boundary and trade-off + +The untrusted build job assumes the Specter PR controls all source, Makefiles, +Python, submodules, tests, generated files, and build artifacts. It has only +`contents: read`, no repository secrets, no Pages or Contents write permission, +and no `id-token: write`. PR-controlled code cannot read the dispatch token or +the Specter PR-comment token. + +The trusted runtime job uses a separate fresh runner, has only `contents: read`, +and never checks out the PR head. It builds JavaScript from the validated base +commit and trusted Web Simulator code. The finalizer alone receives +`contents: write`, `pages: write`, and `id-token: write`, plus read access to +the workflow artifacts and PR metadata. It parses both artifacts as files and +publishes only the allowlisted WASM/data and trusted runtime JavaScript with +the default-branch shell. It does not run scripts, Python, or JavaScript from +an artifact. The `gh-pages` branch is the persistent complete tree; GitHub +Pages deploys it from a Pages artifact created by the trusted finalizer. + +This makes the Web Simulator default branch a stronger trust root: a malicious +change merged there could publish deceptive previews, serve malicious browser +code, or misuse the machine-user token held by the trusted finalizer. The +machine user has no Specter repository write access, and GitHub enforces +ownership on edits/deletes to existing comments. The PAT can still perform +ordinary public-user actions; it is not a comment-only API permission. +Never grant the bot repository write permissions. Review changes to the +Web Simulator default branch, reporter, and workflows accordingly. + +The preview workflow pins third-party actions to immutable commit SHAs. Existing +general-purpose repository checks continue their established version-tag +convention. There is no custom server, database, callback workflow, or second +wallet logic implementation. + +## Developer Options The optional **Developer Options** panel inspects browser simulator state, flash and peripheral files, and the WebAssembly memory buffer. At normal startup its @@ -182,6 +346,8 @@ current Specter-DIY WebAssembly runtime from source, then verifies inspection activation, shutdown, request-ID-safe baseline comparison, and normal restart with real firmware. +## Browser compatibility + GitHub Pages does not provide COOP/COEP response headers. This build does not require SharedArrayBuffer. The DIY display has a Canvas pixel bridge for browsers without transferable OffscreenCanvas. Chromium is covered by CI; diff --git a/web/browser/build-browser.sh b/web/browser/build-browser.sh index d03de68..cf06ed5 100644 --- a/web/browser/build-browser.sh +++ b/web/browser/build-browser.sh @@ -31,17 +31,7 @@ test "$(emcc --version | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" } python3 "$ROOT/browser/patch-source.py" "$SPECTER_SRC" -cat > "$SPECTER_SRC/browser.manifest.py" <<'EOF' -import os -freeze('f469-disco/usermods/udisplay_f469/display_unixport') -# Newer boards curate embit's src/ layout and omit CPython-only examples/tests. -# Older board revisions had flat common libraries and no curated manifest. -if os.path.isfile('f469-disco/manifests/common.py'): - include('f469-disco/manifests/common.py') -else: - freeze('f469-disco/libs/common') -freeze('src') -EOF +python3 "$ROOT/browser/write-browser-manifest.py" "$SPECTER_SRC" python3 "$ROOT/tests/test-browser-manifest.py" "$SPECTER_SRC" make -C "$SPECTER_SRC/f469-disco/micropython/mpy-cross" -j4 \ CFLAGS_EXTRA="-Wno-dangling-pointer -Wno-enum-int-mismatch" diff --git a/web/browser/preview-route.js b/web/browser/preview-route.js new file mode 100644 index 0000000..613f636 --- /dev/null +++ b/web/browser/preview-route.js @@ -0,0 +1,4 @@ +/** Identify isolated Specter PR previews, including commit-specific URLs. */ +export function isIsolatedPreviewRoute(pathname) { + return /(?:^|\/)pr\/[1-9]\d*\/(?:[a-f0-9]{40}\/)?$/.test(pathname); +} diff --git a/web/browser/replace_glue.py b/web/browser/replace_glue.py index 9399db7..78d8caf 100644 --- a/web/browser/replace_glue.py +++ b/web/browser/replace_glue.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Replace PR-built Emscripten JavaScript with glue from trusted source.""" +"""Compose trusted Emscripten JavaScript with untrusted preview data files.""" from hashlib import sha256 from pathlib import Path import json @@ -7,7 +7,7 @@ import sys -def replace_glue(root: Path, trusted_js: Path): +def replace_glue(root: Path, trusted_js: Path, runtime_provenance: dict | None = None): root = root.resolve() if trusted_js.is_symlink() or not trusted_js.is_file(): raise ValueError("Trusted Emscripten JavaScript artifact missing") @@ -30,17 +30,24 @@ def replace_glue(root: Path, trusted_js: Path): raise ValueError("Build manifest missing") manifest = json.loads(manifest_path.read_text()) artifacts = manifest.get("artifacts") - names = ("micropython.js", "micropython.wasm", "micropython.data") - if not isinstance(artifacts, dict) or set(artifacts) != set(names): + data_names = ("micropython.wasm", "micropython.data") + if not isinstance(artifacts, dict) or set(artifacts) != set(data_names): raise ValueError("Unexpected build artifacts") - for name in names: + for name in data_names: path = build / name if path.is_symlink() or not path.is_file(): raise ValueError(f"Invalid build artifact: {name}") - (build / "micropython.js").write_bytes(js) - for name in names: + js_path = build / "micropython.js" + if js_path.exists() or js_path.is_symlink(): + raise ValueError("Untrusted JavaScript was included in the browser payload") + js_path.write_bytes(js) + artifacts["micropython.js"] = {"bytes": len(js), "sha256": sha256(js).hexdigest()} + names = ("micropython.js", *data_names) + for name in data_names: data = (build / name).read_bytes() artifacts[name] = {"bytes": len(data), "sha256": sha256(data).hexdigest()} + if runtime_provenance is not None: + manifest["trusted_runtime"] = runtime_provenance artifact_set = sha256("".join(artifacts[name]["sha256"] for name in sorted(names)).encode()).hexdigest() manifest["artifact_set_sha256"] = artifact_set pointer["version"] = artifact_set[:16] diff --git a/web/browser/site.js b/web/browser/site.js index c7932f0..9b9ea97 100644 --- a/web/browser/site.js +++ b/web/browser/site.js @@ -1,4 +1,5 @@ import { validateBuildProvenance } from './build-provenance.js'; +import { isIsolatedPreviewRoute } from './preview-route.js'; const $ = selector => document.querySelector(selector); const siteRoot = new URL('../', import.meta.url); @@ -76,6 +77,11 @@ let inspectorPending = null; let inspectorBaseline = null; let inspectorFileRequest = 0; let inspectorGeneration = -1; +const virtualHostDetails = $('#virtual-host'); +const virtualHostStatus = $('#virtual-host-status'); +const virtualHostStatusText = $('#virtual-host-status-text'); +const virtualHostPreviewNote = $('#virtual-host-preview-note'); +const isolatedPreviewRoute = isIsolatedPreviewRoute(siteRoot.pathname); let virtualHostSocket; let virtualHostRetryTimer; let virtualHostClientId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -252,16 +258,41 @@ function setStatus(message, running = false) { status.textContent = message; dot.classList.toggle('on', running); } -function updateVirtualHostStatus() { +function updateVirtualHostStatus(message) { const bridge = $('#virtual-host-bridge-status'); const wallet = $('#virtual-host-wallet-status'); - if (!bridge || !wallet) return; - bridge.textContent = virtualHostRunning ? 'Running' : 'Stopped'; - wallet.textContent = virtualHostWalletConnected ? 'Connected' : 'Waiting'; - wallet.classList.toggle('waiting', !virtualHostWalletConnected); + if (bridge && wallet) { + bridge.textContent = virtualHostRunning ? 'Running' : 'Stopped'; + wallet.textContent = virtualHostWalletConnected ? 'Connected' : 'Waiting'; + wallet.classList.toggle('waiting', !virtualHostWalletConnected); + } + if (!virtualHostStatus || !virtualHostStatusText) return; + const connected = virtualHostRunning && virtualHostWalletConnected; + virtualHostStatus.classList.toggle('connected', connected); + virtualHostStatus.classList.toggle('waiting', !connected); + if (message) { + virtualHostStatusText.textContent = message; + } else if (isolatedPreviewRoute) { + virtualHostStatusText.textContent = 'Local Virtual Host connections are disabled on isolated pull-request previews.'; + } else if (!virtualHostRunning) { + virtualHostStatusText.textContent = 'Virtual Host not detected — run it on this computer to connect.'; + } else if (!virtualHostWalletConnected) { + virtualHostStatusText.textContent = 'Virtual Host is running — waiting for Specter Desktop.'; + } else { + virtualHostStatusText.textContent = 'Connected to Specter Desktop.'; + } +} +function scheduleVirtualHostRetry() { + clearTimeout(virtualHostRetryTimer); + if (isolatedPreviewRoute || (!virtualHostDetails?.open && params.get('virtual-host') !== '1')) return; + virtualHostRetryTimer = setTimeout(connectVirtualHost, 2000); } function connectVirtualHost() { - if (!$('#virtual-host') || params.get('virtual-host') !== '1' || + if (isolatedPreviewRoute) { + updateVirtualHostStatus(); + return; + } + if (!virtualHostDetails || (params.get('virtual-host') !== '1' && !virtualHostDetails.open) || virtualHostSocket?.readyState === WebSocket.OPEN || virtualHostSocket?.readyState === WebSocket.CONNECTING) return; const query = `?client=${encodeURIComponent(virtualHostClientId)}`; const localPage = location.hostname === '127.0.0.1' && location.port === '8788'; @@ -269,9 +300,8 @@ function connectVirtualHost() { try { socket = new WebSocket(`${localPage ? `ws://${location.host}` : 'ws://127.0.0.1:8788'}/bridge${query}`); } catch (error) { - log(`Virtual Host unavailable: ${error.message}`); - clearTimeout(virtualHostRetryTimer); - virtualHostRetryTimer = setTimeout(connectVirtualHost, 2000); + updateVirtualHostStatus(`Virtual Host unavailable: ${error.message}`); + scheduleVirtualHostRetry(); return; } virtualHostSocket = socket; @@ -302,10 +332,7 @@ function connectVirtualHost() { virtualHostRunning = false; virtualHostWalletConnected = false; updateVirtualHostStatus(); - if (event.code !== 4001 && params.get('virtual-host') === '1') { - clearTimeout(virtualHostRetryTimer); - virtualHostRetryTimer = setTimeout(connectVirtualHost, 2000); - } + if (event.code !== 4001) scheduleVirtualHostRetry(); }; socket.onerror = () => { /* onclose updates the status and schedules a retry */ }; } @@ -314,6 +341,14 @@ addEventListener('specter-virtual-host-send', event => { const bytes = event.detail; if (bytes instanceof ArrayBuffer || ArrayBuffer.isView(bytes)) virtualHostSocket.send(bytes); }); +if (virtualHostDetails) { + virtualHostDetails.addEventListener('toggle', () => { + if (virtualHostDetails.open) connectVirtualHost(); + else if (params.get('virtual-host') !== '1') clearTimeout(virtualHostRetryTimer); + }); + if (virtualHostPreviewNote) virtualHostPreviewNote.hidden = !isolatedPreviewRoute; + updateVirtualHostStatus(); +} function clearStartupTimer() { if (startupTimer !== undefined) clearTimeout(startupTimer); startupTimer = undefined; @@ -1139,7 +1174,7 @@ function formatBuildPresentation(manifest) { const isFork = Boolean(forkValue) || manifest.is_fork === true || manifest.fork === true; const cleanVersion = versionLabel ? String(versionLabel).replace(/^v/i, '') : ''; const topLabel = ['GitHub', isFork && 'Fork', cleanVersion && `v${cleanVersion}`, prLabel, - prLabel && commit.slice(0, 7)].filter(Boolean).join(' · '); + commit && commit.slice(0, 7)].filter(Boolean).join(' · '); const context = [ cleanVersion && `Version: v${cleanVersion}`, manifest.branch && `Branch: ${manifest.branch}`, @@ -1154,7 +1189,7 @@ function updateBuildMetadata(manifest) { const buildPresentation = formatBuildPresentation(manifest); const sourceCommitLink = $('#source-commit-link'); if (sourceCommitLink) { - sourceCommitLink.href = buildPresentation.repositoryUrl; + sourceCommitLink.href = buildPresentation.commitUrl; sourceCommitLink.textContent = buildPresentation.topLabel; } const buildRepositoryLink = $('#build-repository-link'); @@ -1191,7 +1226,7 @@ function updateBuildMetadata(manifest) { try { if (params.get('virtual-host') === '1' && !embedded && !gallery) { - $('#virtual-host').hidden = false; + virtualHostDetails.open = true; connectVirtualHost(); } stateFiles = await awaitPeripherals(); diff --git a/web/browser/write-browser-manifest.py b/web/browser/write-browser-manifest.py new file mode 100644 index 0000000..857b3e2 --- /dev/null +++ b/web/browser/write-browser-manifest.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Write the trusted frozen-module manifest into a Specter source checkout.""" +from pathlib import Path +import sys + + +MANIFEST = '''import os +freeze('f469-disco/usermods/udisplay_f469/display_unixport') +# Newer boards curate embit's src/ layout and omit CPython-only examples/tests. +# Older board revisions had flat common libraries and no curated manifest. +if os.path.isfile('f469-disco/manifests/common.py'): + include('f469-disco/manifests/common.py') +else: + freeze('f469-disco/libs/common') +freeze('src') +''' + + +def write_manifest(source: Path) -> Path: + source = source.resolve() + if not source.is_dir(): + raise ValueError("Specter source directory does not exist") + manifest = source / "browser.manifest.py" + if manifest.is_symlink(): + raise ValueError("Browser manifest path must not be a symlink") + manifest.write_text(MANIFEST, encoding="utf-8") + return manifest + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("Usage: write-browser-manifest.py SPECTER_SOURCE_DIR") + write_manifest(Path(sys.argv[1])) diff --git a/web/index.html b/web/index.html index 44ff6bf..c4e1403 100644 --- a/web/index.html +++ b/web/index.html @@ -82,8 +82,7 @@ } } - .hardware-link { width:100%; max-width:580px; text-align:center; color:var(--specter-light-secondary); font-size:14px; line-height:1.45; } - .hardware-link a { color:var(--specter-primary); font-weight:700; text-underline-offset:3px; } + .simulator-note { width:100%; max-width:580px; text-align:center; color:var(--specter-light-secondary); font-size:14px; line-height:1.45; } /* The LVGL canvas sits UNDER the existing device mockup image. */ #screen-overlay { @@ -321,7 +320,8 @@ html.gallery-pending::after { content: 'Loading Simulator…'; position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: #000; color: var(--specter-light-secondary); font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; z-index: 999; } html.gallery-pending.gallery body { visibility: visible; } html.gallery-pending.gallery::after { display: none; } - .embedded header, .embedded .hardware-link, .embedded .demo-import, .embedded footer { display:none; } + .embedded header, .embedded .simulator-note, .embedded .demo-import, .embedded footer, + .embedded .virtual-host { display:none; } .embedded, .embedded body { min-height:0; } .embedded .page { padding:10px 12px 22px; } .embedded .phone-wrapper { max-width:330px; } @@ -330,13 +330,30 @@ .gallery .warning, .gallery .device-actions, .gallery .sim-panel, .gallery .status-card, .gallery .developer-options { display:none; } .gallery .phone-wrapper { width:100%; height:100%; max-width:none; } .gallery .phone-wrapper .phone-mockup { display:none; } - .virtual-host { border:1px solid #3a4d61; border-radius:10px; padding:16px; background:#182434; } + .virtual-host { width:100%; max-width:1120px; justify-self:center; margin:0 auto; border:1px solid #35506d; border-radius:14px; background:linear-gradient(135deg,#101b2a,#17283b); color:var(--specter-light); overflow:hidden; } .virtual-host[hidden] { display:none; } - .virtual-host h2 { margin-top:0; } - .virtual-host-status { display:flex; gap:24px; flex-wrap:wrap; } - .virtual-host-status strong { color:#8fe0a4; } - .virtual-host-status .waiting { color:#ffd17d; } - .virtual-host-settings { color:#8fd2ff; } + .virtual-host summary { cursor:pointer; display:flex; align-items:center; justify-content:space-between; gap:16px; padding:17px 20px; font-size:15px; font-weight:700; list-style:none; } + .virtual-host summary::-webkit-details-marker { display:none; } + .virtual-host summary::after { content:'+'; color:var(--specter-primary); font-size:24px; font-weight:400; line-height:1; } + .virtual-host[open] summary::after { content:'\2212'; } + .virtual-host-summary small { display:block; margin-top:4px; color:var(--specter-light-secondary); font-size:11px; font-weight:400; } + .virtual-host-body { border-top:1px solid #35506d; padding:18px 20px 20px; } + .virtual-host-intro { margin:0 0 15px; color:#d8e6f3; line-height:1.5; } + .virtual-host-status { display:flex; align-items:center; gap:9px; padding:10px 12px; margin:0 0 12px; border:1px solid #3b4a5e; border-radius:9px; background:#0b1624; color:var(--specter-light-secondary); font-size:12px; } + .virtual-host-status-dot { width:9px; height:9px; border-radius:50%; background:#6b7785; flex:none; } + .virtual-host-status.connected .virtual-host-status-dot { background:#45c96b; box-shadow:0 0 9px #45c977; } + .virtual-host-status.waiting .virtual-host-status-dot { background:#f2b84b; box-shadow:0 0 9px #f2b84b66; } + .virtual-host-connections { display:flex; gap:24px; flex-wrap:wrap; color:var(--specter-light-secondary); font-size:12px; } + .virtual-host-connections strong { color:#8fe0a4; } + .virtual-host-connections .waiting { color:#ffd17d; } + .virtual-host-actions { display:flex; flex-wrap:wrap; gap:9px; margin:14px 0; } + .virtual-host-download { display:inline-flex; align-items:center; justify-content:center; min-height:38px; padding:0 15px; border-radius:7px; background:var(--specter-primary); color:white; font-size:12px; font-weight:700; text-decoration:none; } + .virtual-host-download:hover { filter:brightness(1.12); } + .virtual-host-repository { margin:8px 0 0; color:var(--specter-light-secondary); font-size:12px; line-height:1.5; } + .virtual-host-repository a { color:#8fd2ff; } + .virtual-host-steps { margin:17px 0 10px; padding-left:22px; color:#d8e6f3; line-height:1.55; } + .virtual-host-steps li { margin:8px 0; padding-left:4px; } + .virtual-host-note { margin:12px 0 0; color:var(--specter-light-secondary); font-size:11px; line-height:1.5; } .gallery .virtual-host { display:none; } .gallery #screen-overlay { left:0; top:0; width:100%; height:100%; border-radius:0; } @media (min-width:900px) { @@ -346,7 +363,7 @@ .device-actions { grid-area:restart; } .virtual-host { grid-area:virtual-host; } .sim-panel { grid-area:peripherals; max-width:none; } - .hardware-link { grid-area:hardware; justify-self:center; margin-top:16px; } + .simulator-note { grid-area:hardware; justify-self:center; margin-top:16px; } .demo-import { grid-area:demo; justify-self:center; } .developer-options { grid-area:developer; justify-self:center; } .embedded .page { grid-template-columns:330px 380px; max-width:760px; } @@ -454,15 +471,38 @@

Virtual Smart Cards

- +
@@ -475,7 +515,7 @@

Virtual Host