diff --git a/.github/workflows/ccdp-image.yml b/.github/workflows/ccdp-image.yml new file mode 100644 index 00000000..ed67d8f6 --- /dev/null +++ b/.github/workflows/ccdp-image.yml @@ -0,0 +1,220 @@ +name: CCDP image + +# Builds the static ceremony artifact and the pinned SWS image for linux/amd64, +# proves the image against the running container and the pinned native +# binary, and — when `push` is true — publishes it to ghcr. Called by ci.yml +# (build and test on pull requests; publish `:sha-` and `:main` +# from main). A manual dispatch is a dry run: it builds and tests and never +# pushes. release.yml never builds; it promotes the `:sha-` image +# published from main. +# +# No `permissions` here: a called workflow runs with its caller's token and +# can only narrow it, so each caller declares what its run needs — +# `packages: read` for pull requests, which execute PR-controlled build code, +# and `packages: write` only for the runs that push. A dispatch runs with the +# repository's default token, which is read-only. +# +# The image is always tagged `ghcr.io/libid-org/ccdp:sha-` (the +# full 40-hex sha, so two commits can never share a tag) — the reference the +# checks run against and the one release.yml promotes after checking that +# the image's `org.opencontainers.image.revision` label names the released +# commit; `tags` adds further references (any whitespace separates them). +# The build is seeded from the previously published `:main` image so immutable +# assets stay available through the compatibility window (see "Publication and +# upgrades" in ts/packages/ceremony/docs/distribution.md). +# +# Every third-party action is pinned by commit SHA, with the tag in a +# comment, so a moved tag cannot change what executes. + +on: + workflow_call: + inputs: + push: + description: Push the image to ghcr once the checks pass. + type: boolean + default: false + tags: + description: Image references to tag besides ghcr.io/libid-org/ccdp:sha-, one per line. + type: string + default: "" + workflow_dispatch: + +jobs: + image: + name: Build, test and push + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + IMAGE: ghcr.io/libid-org/ccdp + ARTIFACTS: ts/packages/ceremony/dist-artifacts + SWS_VERSION: v3.0.0-beta.1 + # sha256 of static-web-server-$SWS_VERSION-x86_64-unknown-linux-musl.tar.gz, + # computed from the downloaded release asset; recompute when bumping SWS_VERSION. + SWS_SHA256: c4b043f61eb63ea0fb6b13d42cc29d5732e893ab17fcee320982dcf57574378b + # A dispatch has no inputs, so this is "false" unless a caller asked. + PUSH: ${{ inputs.push == true }} + EXTRA_TAGS: ${{ inputs.tags }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + with: + version: 10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: ts/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm -C ts install --frozen-lockfile + + - name: Build + run: pnpm -C ts --filter '@libid/ceremony...' build + + # SHA_REF is the commit's own tag; REFS is every reference the image gets + # (SHA_REF first). Each extra reference must be a tag of this image. + # IMAGE_VERSION, the `org.opencontainers.image.version` label, is the + # first extra tag (`main`) or else the sha tag. + - name: Resolve the image references + run: | + set -euo pipefail + sha_ref="$IMAGE:sha-$GITHUB_SHA" + refs="$sha_ref" + version="" + for ref in $EXTRA_TAGS; do + case "$ref" in + "$IMAGE":*) ;; + *) echo "::error::'$ref' is not a tag of $IMAGE"; exit 1 ;; + esac + case " $refs " in + *" $ref "*) ;; + *) refs="$refs $ref" ;; + esac + : "${version:=${ref#"$IMAGE":}}" + done + : "${version:=${sha_ref#"$IMAGE":}}" + echo "Image references: $refs (push: $PUSH, version label: $version)" + { + echo "SHA_REF=$sha_ref" + echo "REFS=$refs" + echo "IMAGE_VERSION=$version" + } >> "$GITHUB_ENV" + + - name: Log in to ghcr + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + # The previous publication's served tree and graph let build/distribution.ts + # retain its immutable assets and verify reused URLs are unchanged. Only + # the registry's word that the image does not exist (first publication, + # or a package without a `:main` tag yet; "manifest unknown" for the + # read-only pull-request token too) skips the seed. Any other pull + # failure — network, registry, auth — fails the run, so a publication + # never drops retained assets silently and is simply re-run. + - name: Seed retention from the previously published image + run: | + set -euo pipefail + if docker pull --platform linux/amd64 "$IMAGE:main" 2>"$RUNNER_TEMP/pull.err"; then + previous=$(docker image inspect -f '{{index .RepoDigests 0}}' "$IMAGE:main") + cid=$(docker create --platform linux/amd64 "$IMAGE:main") + mkdir -p "$ARTIFACTS" + docker cp "$cid:/home/sws/public" "$ARTIFACTS/public" + docker cp "$cid:/home/sws/distribution-graph.json" "$ARTIFACTS/distribution-graph.json" + docker rm "$cid" >/dev/null + echo "Seeded $(find "$ARTIFACTS/public" -type f | wc -l) files from $previous" + exit 0 + fi + cat "$RUNNER_TEMP/pull.err" + if grep -qiE 'manifest unknown|not found|name unknown' "$RUNNER_TEMP/pull.err"; then + echo "No previous image at $IMAGE:main; building without retained assets." + if [ "$PUSH" = "true" ]; then + echo "::warning::Retention seed unavailable; this publication retains no previous immutable assets." + fi + exit 0 + fi + echo "::error::Could not pull $IMAGE:main; fix the cause (or re-run) rather than building without retained assets." + exit 1 + + - name: Build the CCDP artifact + run: pnpm -C ts --filter @libid/ceremony build:ccdp-artifacts + + # The OCI labels carry the built commit inside the image, where a moved + # or reused tag cannot change it; release.yml refuses to promote an + # image whose revision label is not the released commit. + - name: Build the image (linux/amd64) + run: | + set -euo pipefail + tags=() + for ref in $REFS; do tags+=(-t "$ref"); done + docker buildx build --platform linux/amd64 --load "${tags[@]}" \ + --label "org.opencontainers.image.revision=$GITHUB_SHA" \ + --label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ + --label "org.opencontainers.image.version=$IMAGE_VERSION" \ + -f ts/packages/ceremony/ccdp.Dockerfile "$ARTIFACTS" + docker image inspect "$SHA_REF" \ + -f 'built {{.Os}}/{{.Architecture}} {{.Id}} revision {{index .Config.Labels "org.opencontainers.image.revision"}}' + + - name: Run the image hardened and wait for /health + run: | + set -euo pipefail + docker run -d --name ccdp --read-only --cap-drop ALL --security-opt no-new-privileges \ + -p 127.0.0.1:28787:8787 "$SHA_REF" + for _ in $(seq 1 50); do + if curl -fsS -o /dev/null http://127.0.0.1:28787/health; then + echo "CCDP image answers /health" + exit 0 + fi + sleep 0.2 + done + docker logs ccdp + echo "::error::CCDP image did not answer /health" + exit 1 + + # The same-length ETag regression and the header-matching canary need the + # native binary; its bytes must hash to the sha256 pinned in SWS_SHA256. + - name: Fetch the pinned SWS binary and check its sha256 + run: | + set -euo pipefail + asset="static-web-server-$SWS_VERSION-x86_64-unknown-linux-musl.tar.gz" + curl -fsSL -o "$RUNNER_TEMP/sws.tar.gz" \ + "https://github.com/static-web-server/static-web-server/releases/download/$SWS_VERSION/$asset" + echo "$SWS_SHA256 $RUNNER_TEMP/sws.tar.gz" | sha256sum -c - + mkdir -p "$RUNNER_TEMP/sws" + tar -xzf "$RUNNER_TEMP/sws.tar.gz" -C "$RUNNER_TEMP/sws" + bin=$(find "$RUNNER_TEMP/sws" -type f -name static-web-server) + "$bin" --version + echo "CEREMONY_SWS_BINARY=$bin" >> "$GITHUB_ENV" + + - name: Distribution tests against the running image and the native binary + env: + CEREMONY_SWS_URL: http://127.0.0.1:28787 + CEREMONY_SWS_TEST_PORT: "28790" + run: | + set -euo pipefail + pnpm -C ts --filter @libid/ceremony test:distribution 2>&1 | tee "$RUNNER_TEMP/distribution-tests.log" + # Both inputs above are set, so nothing may have been skipped. + if grep -Eq 'skipped [1-9]' "$RUNNER_TEMP/distribution-tests.log"; then + echo "::error::distribution tests were skipped; the server/binary inputs did not reach them" + exit 1 + fi + + - name: Push the image + if: env.PUSH == 'true' + run: | + set -euo pipefail + for ref in $REFS; do docker push "$ref"; done + digest=$(docker image inspect -f '{{index .RepoDigests 0}}' "$SHA_REF") + { + echo "### CCDP image published" + echo + echo "- \`$digest\`" + for ref in $REFS; do echo "- \`$ref\`"; done + } >> "$GITHUB_STEP_SUMMARY" + + - name: Summary (not pushed) + if: env.PUSH != 'true' + run: echo "### CCDP image built and tested; not pushed (push is false on ${{ github.event_name }})" >> "$GITHUB_STEP_SUMMARY" + + - name: Container logs on failure + if: failure() + run: docker logs ccdp || true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d7ffb44..1fedf026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI -# TypeScript checks, workspace browser tests, infrastructure smoke and DCO. +# TypeScript checks, workspace browser tests, infrastructure smoke, CCDP image and DCO. # # Every third-party action is pinned by commit SHA, with the tag in a # comment, so a moved tag cannot change what executes. @@ -52,7 +52,7 @@ jobs: # --------------------------------------------------------------------------- # Integration smoke: boot the compose stack (fresh anvil + released - # libid-deploy + released notary/backend/keeper images) and assert the + # libid-deploy/backend plus backported notary/keeper builds) and assert the # seams: the declarative deploy converged onto the declared canonical # addresses, the RPC answers, the notary serves its signer identity, the # keeper's real MPC-TLS JWKS rotation landed roots on-chain, and the @@ -79,7 +79,7 @@ jobs: env: GH_OAUTH_CLIENT_ID: dummy GH_OAUTH_CLIENT_SECRET: dummy - run: docker compose up -d --wait --wait-timeout 300 + run: docker compose up -d --wait --build --wait-timeout 300 - name: Deploy service exited 0 (declarative convergence check passed) working-directory: harness @@ -158,6 +158,38 @@ jobs: working-directory: harness run: docker compose logs + # --------------------------------------------------------------------------- + # CCDP image: build the static ceremony artifact and the pinned SWS image for + # linux/amd64, prove the image against the running container and the pinned + # native binary, and publish it to ghcr on pushes to main. The steps live in + # the reusable ccdp-image.yml. A release only promotes the `sha-` + # image published here to the release version; it never builds one. + # + # Two callers so the token matches the run. Pull requests execute + # PR-controlled build code, so they get a read-only token (the called + # workflow inherits these permissions and cannot widen them) and never push. + # Only a push to main holds `packages: write`, and only it publishes. + # --------------------------------------------------------------------------- + ccdp-image: + name: CCDP image + if: github.event_name != 'push' || github.ref != 'refs/heads/main' + permissions: + contents: read + packages: read + uses: ./.github/workflows/ccdp-image.yml + + ccdp-publish: + name: CCDP image (publish) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + uses: ./.github/workflows/ccdp-image.yml + with: + push: true + # `sha-` is always tagged; `:main` seeds the next build's retention. + tags: ghcr.io/libid-org/ccdp:main + # --------------------------------------------------------------------------- # DCO: every commit carries a Signed-off-by trailer. Plain git over the # event's commit range — no marketplace action, so there is nothing to pin diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8f0af85..bdf86443 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,11 @@ name: Release # Publishing a GitHub Release tagged `v` publishes @libid/claim -# and @libid/claim-full (whose version tracks claim's) to npm. The tag is a -# pointer, never a source: it must equal both package.json versions. Auth -# is token-first (org NPM_TOKEN secret) with OIDC trusted publishing as the -# fallback — same model as libid-contracts. +# and @libid/claim-full (whose version tracks claim's) to npm, and the CCDP +# image to ghcr as `ghcr.io/libid-org/ccdp:` (plus `:latest` for a +# stable version). The tag is a pointer, never a source: it must equal both +# package.json versions. npm auth is token-first (org NPM_TOKEN secret) with +# OIDC trusted publishing as the fallback — same model as libid-contracts. on: release: @@ -109,3 +110,96 @@ jobs: (cd "ts/packages/$dir" \ && pnpm publish --access public --no-git-checks --tag "${{ steps.dist-tag.outputs.dist-tag }}") done + + # --------------------------------------------------------------------------- + # CCDP image: publish the released commit's image under the version. The + # image that ci.yml built, tested and pushed as `sha-` when the + # commit landed on main is promoted — retagged registry-side by digest, no + # rebuild — so the release image is byte-identical to the tested one and + # needs no retention seed. The tag is the full sha and the image's own + # revision label must name this commit, so a promotion cannot pick up + # another commit's image. A release never builds: without that image the + # job fails, because only images published from main enter the `:main` + # retention history every later build seeds from. + # --------------------------------------------------------------------------- + publish-ccdp-image: + name: Publish the CCDP image + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [verify-tag] + permissions: + contents: read + packages: write + env: + IMAGE: ghcr.io/libid-org/ccdp + steps: + # `` is the tag without its `v`, as on npm; a `-` marks a + # prerelease (the dist-tag rule above), which never moves `latest`. + - name: Derive the image tags from the release version + id: tags + env: + TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + version="${TAG#v}" + printf '%s' "$version" | grep -qE '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' || { + echo "::error::version '$version' is not usable as an image tag" >&2 + exit 1 + } + tags="$IMAGE:$version" + if [ "$version" = "${version#*-}" ]; then + tags="$tags"$'\n'"$IMAGE:latest" + fi + echo "$tags" + { echo "tags<> "$GITHUB_OUTPUT" + + - name: Log in to ghcr + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + + # `--prefer-index=false`: with a single source, imagetools would otherwise + # wrap a plain manifest in a new index, which has a different digest. + # "not found" means the commit never published from main (or its run + # failed): the fix is to merge and let ccdp-publish run, never to build + # here. Before retagging, the image's `org.opencontainers.image.revision` + # label (baked in by ccdp-image.yml) must equal the released commit: the + # tag alone is a mutable pointer. `.Image` is one config for a plain + # manifest and a per-platform map for an index. + - name: Promote the tested main image of this commit + env: + TAGS: ${{ steps.tags.outputs.tags }} + run: | + set -euo pipefail + source="$IMAGE:sha-$GITHUB_SHA" + if ! digest=$(docker buildx imagetools inspect "$source" --format '{{json .Manifest.Digest}}' \ + 2>"$RUNNER_TEMP/inspect.err" | jq -r .); then + cat "$RUNNER_TEMP/inspect.err" + if grep -qiE 'not found|manifest unknown|name unknown' "$RUNNER_TEMP/inspect.err"; then + echo "::error::no tested main image for $GITHUB_SHA: merge to main and let ccdp-publish run, then re-publish the release" + else + echo "::error::could not inspect $source; fix the cause and re-run" + fi + exit 1 + fi + echo "$source is $digest" + revision=$(docker buildx imagetools inspect "$IMAGE@$digest" --format '{{json .Image}}' \ + | jq -r 'if has("config") then . else .["linux/amd64"] end + | .config.Labels["org.opencontainers.image.revision"] // empty') + if [ "$revision" != "$GITHUB_SHA" ]; then + echo "::error::$source carries revision '${revision:-none}', not $GITHUB_SHA; refusing to promote" + exit 1 + fi + echo "$source was built from $revision" + args=() + for ref in $TAGS; do args+=(-t "$ref"); done + docker buildx imagetools create --prefer-index=false "${args[@]}" "$IMAGE@$digest" + { + echo "### CCDP image promoted" + echo + echo "- \`$source\` = \`$digest\`" + } >> "$GITHUB_STEP_SUMMARY" + for ref in $TAGS; do + got=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest.Digest}}' | jq -r .) + echo "$ref is $got" + [ "$got" = "$digest" ] || { echo "::error::$ref resolved to $got, not $digest"; exit 1; } + echo "- \`$ref\` = \`$got\`" >> "$GITHUB_STEP_SUMMARY" + done diff --git a/.gitignore b/.gitignore index 04f499dd..85b79436 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,18 @@ harness/.env ts/packages/popup/e2e/dist/ ts/packages/*/test-results/ ts/packages/*/playwright-report/ + +# Ceremony local tooling and qualification output +.ceremony-local/ +ts/packages/ceremony/.cache/ +ts/packages/ceremony/dist-artifacts/ + +# Shared development application +ts/apps/dev/.cache/ +ts/apps/dev/.env* +!ts/apps/dev/.env.example +ts/apps/dev/test-results/ +ts/apps/dev/playwright-report/ + +# Updating the checkout does not move developers' untracked old credentials. +ts/packages/ceremony/dev/.env* diff --git a/README.md b/README.md index 1f49d50b..007bc0df 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,5 @@ carries the browser claim library and the integration harness: across configured on-chain deployments. - [`repository-template`](https://github.com/libid-org/repository-template) — shared licensing, contribution, and AI-agent defaults for new repositories. + +For shared local services and the browser development app, see [@libid/dev](ts/apps/dev/README.md). diff --git a/harness/README.md b/harness/README.md index 0308bbf1..53679d3d 100644 --- a/harness/README.md +++ b/harness/README.md @@ -2,7 +2,7 @@ Everything needed to run a real, manual, end-to-end handle claim against a local chain: anvil, the factory-first contract stack (declaratively applied -by libid-deploy 0.6.0), the released notary, libid-server-rs and keeper +by libid-deploy 0.6.0), the notary, libid-server-rs and keeper images, and the buttons-only demo UI on top of `@libid/claim`. The local addresses **equal the canonical cross-network addresses**: every @@ -45,7 +45,7 @@ which does, in order: `harness/.env` (compose interpolation) and `ts/apps/demo/.env.local` (VITE_ vars, including the anvil #4 dev-fallback signer so no wallet extension is needed). Both outputs are generated, never committed. -3. **`docker compose up --wait`** — the stack below. +3. **`docker compose up --wait --build`** — the stack below. 4. Health checks (anvil RPC, notary `/info`, backend `/health`) + a status table. 5. The vite dev server, foreground, at `http://localhost:5173`. Exit tears @@ -57,10 +57,17 @@ which does, in order: |---|---|---| | `anvil` | `ghcr.io/foundry-rs/foundry:v1.5.1` | chain 31337, `--code-size-limit 65536` (the Honk verifiers exceed EIP-170); the default Arachnid CREATE2 predeploy is kept on purpose — `ensure_*` is idempotent and the canonical addresses are the same either way | | `deploy` | `debian:bookworm-slim` (one-shot) | downloads released `libid-deploy` 0.6.0 for the container arch, fresh-applies the declarative network file on its read-only mount, **asserts convergence** (below) | -| `notary` | `ghcr.io/libid-org/notary:0.2.0` | MPC-TLS/ProxyMode notary; TCP 7047 + HTTP/WS 7048; also serves the JWKS notarization duty | -| `keeper` | `ghcr.io/libid-org/keeper:0.2.0` (one-shot) | one real rotation tick: MPC-TLS reading of Google's live JWKS through the notary, then `rotate()` on `identity_jwks_roots` and `google_oidc_verifier` | +| `notary` | local build of 0.2.0 with the driver-close fix | MPC-TLS/ProxyMode notary; TCP 7047 + HTTP/WS 7048; also serves the JWKS notarization duty | +| `keeper` | local build of 0.2.0 with the driver-close fix (one-shot) | one real rotation tick: MPC-TLS reading of Google's live JWKS through the notary, then `rotate()` on `identity_jwks_roots` and `google_oidc_verifier` | | `libid-server-rs` | `ghcr.io/libid-org/libid-server-rs:0.2.2` | GitHub OAuth + MPC-TLS proof service on 8722; also serves the Google fragment relay | +Keeper and notary are built from pinned 0.2.0 sources with the upstream +[session-driver fix](https://github.com/libid-org/libid-rs/commit/8954d8480b2f7856aa11c87efd8a35f9fa6880c3) +backported. Their lockfiles and legacy wire format stay unchanged. Docker caches +the build; replace these targets with matched released images once available. +This covers the keeper rotation path; the legacy backend retains its released +MPC-TLS client. + Two addresses that look confusable and are not: the notary's `VERIFYING_CONTRACT_ADDRESS` (and the backend's, same value) is **GitHubIdentityVerifier**; the notary's `X_ZK_VERIFIER_ADDRESS` is diff --git a/harness/boot.sh b/harness/boot.sh index 050fba60..b728a3c3 100755 --- a/harness/boot.sh +++ b/harness/boot.sh @@ -58,7 +58,7 @@ cleanup() { trap cleanup EXIT echo "==> docker compose up" -docker compose -f "$HARNESS/docker-compose.yml" up -d --wait +docker compose -f "$HARNESS/docker-compose.yml" up -d --wait --build # ── health checks ────────────────────────────────────────────────────────── check() { # name command... diff --git a/harness/compose/legacy-rust.Dockerfile b/harness/compose/legacy-rust.Dockerfile new file mode 100644 index 00000000..a9e7ef9f --- /dev/null +++ b/harness/compose/legacy-rust.Dockerfile @@ -0,0 +1,26 @@ +# The released 0.2.0 pair rejects a clean mux close during final verification. +# Backport the upstream fix to both binaries without changing their locked +# dependency graph or the wire format used by the legacy backend and contracts. +FROM rust:1.97-slim-bookworm AS build +RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev git \ + && rm -rf /var/lib/apt/lists/* +ADD https://github.com/libid-org/keeper/archive/35538d252f84b630c951bf002e809af77d05df08.tar.gz /keeper.tar.gz +ADD https://github.com/libid-org/notary/archive/c55efeb9830ba78796d7f98f183abffe78a58101.tar.gz /notary.tar.gz +RUN mkdir -p /src/keeper /src/notary \ + && tar -xzf /keeper.tar.gz --strip-components=1 -C /src/keeper \ + && tar -xzf /notary.tar.gz --strip-components=1 -C /src/notary \ + && cargo fetch --locked --manifest-path /src/keeper/Cargo.toml \ + && cargo fetch --locked --manifest-path /src/notary/Cargo.toml +COPY compose/session-driver.patch /session-driver.patch +RUN for source in "$CARGO_HOME"/git/checkouts/libid-rs-*/ec71e15; do \ + git -C "$source" apply /session-driver.patch || exit 1; \ + done \ + && cargo build --locked --release --manifest-path /src/keeper/Cargo.toml --target-dir /target --bin keeper \ + && cargo build --locked --release --manifest-path /src/notary/Cargo.toml --target-dir /target --bin notary + +# Preserve the release images' entrypoints, libraries and health checks. +FROM ghcr.io/libid-org/notary:0.2.0 AS notary +COPY --from=build /target/release/notary /usr/local/bin/notary + +FROM ghcr.io/libid-org/keeper:0.2.0 AS keeper +COPY --from=build /target/release/keeper /usr/local/bin/keeper diff --git a/harness/compose/session-driver.patch b/harness/compose/session-driver.patch new file mode 100644 index 00000000..540f2313 --- /dev/null +++ b/harness/compose/session-driver.patch @@ -0,0 +1,113 @@ +# Backport of libid-rs 8954d8480b2f7856aa11c87efd8a35f9fa6880c3 onto v0.2.0. +# https://github.com/libid-org/libid-rs/commit/8954d8480b2f7856aa11c87efd8a35f9fa6880c3 +# Keep the legacy TLSN wire format while accepting clean late mux completion. +--- a/crates/libid-tlsn/src/session.rs ++++ b/crates/libid-tlsn/src/session.rs +@@ -6,7 +6,10 @@ + StatusCode, + }; + use hyper_util::rt::TokioIo; +-use std::future::IntoFuture; ++use std::{ ++ future::IntoFuture, ++ sync::atomic::{AtomicBool, Ordering}, ++}; + use tlsn::{ + attestation::{ + request::{ +@@ -337,6 +340,10 @@ + // dropping this future — aborts the driver instead of detaching it. + let mut driver_task = AbortOnDrop::new(tokio::spawn(driver)); + ++ // Backport libid-rs 8954d848: clean mux completion after the session ran ++ // is the peer closing, not an early disconnect. ++ let established = AtomicBool::new(false); ++ let established = &established; + let setup = async { + info!("Setting up MPC-TLS"); + let prover = handle +@@ -524,6 +531,7 @@ + detail: format!("prove: {e}"), + })?; + info!("MPC-TLS proof complete"); ++ established.store(true, Ordering::Release); + on_progress(ProverStep::MpcProofFinalized); + + let tls_transcript = prover.tls_transcript().clone(); +@@ -589,17 +597,24 @@ + // connection to the verifier died under the session — a protocol request + // already submitted to it may then never resolve, so fail instead of + // pending forever. ++ let mut finished_driver = None; + let (body, recv_segments, att_request, secrets, handshake) = tokio::select! { + biased; + res = &mut setup => res?, + driver_res = driver_task.handle_mut() => { +- return Err(driver_finished_early(driver_res)); ++ if !established.load(Ordering::Acquire) { ++ return Err(driver_finished_early(driver_res)); ++ } ++ finished_driver = Some(driver_res); ++ (&mut setup).await? + } + }; + +- let recovered_compat: Compat = driver_task +- .into_inner() +- .await ++ let driver_res = match finished_driver { ++ Some(res) => res, ++ None => driver_task.into_inner().await, ++ }; ++ let recovered_compat: Compat = driver_res + .map_err(|e| Error::MpcTlsFailed { + detail: format!("driver task join: {e}"), + })? +@@ -629,6 +644,10 @@ + // dropping this future — aborts the driver instead of detaching it. + let mut driver_task = AbortOnDrop::new(tokio::spawn(driver)); + ++ // Backport libid-rs 8954d848: clean mux completion after the session ran ++ // is the peer closing, not an early disconnect. ++ let established = AtomicBool::new(false); ++ let established = &established; + let setup = async { + let verifier = handle + .new_verifier( +@@ -676,6 +695,7 @@ + let verifier = verifier.run().await.map_err(|e| Error::MpcTlsFailed { + detail: format!("run: {e}"), + })?; ++ established.store(true, Ordering::Release); + + let tls_transcript = verifier.tls_transcript().clone(); + +@@ -732,17 +752,24 @@ + // connection died under the session (e.g. a health probe that connected + // and immediately closed) — a protocol request already submitted to it + // may then never resolve, so fail instead of pending forever. ++ let mut finished_driver = None; + let (server_name, transcript, tls_transcript, transcript_commitments) = tokio::select! { + biased; + res = &mut setup => res?, + driver_res = driver_task.handle_mut() => { +- return Err(driver_finished_early(driver_res)); ++ if !established.load(Ordering::Acquire) { ++ return Err(driver_finished_early(driver_res)); ++ } ++ finished_driver = Some(driver_res); ++ (&mut setup).await? + } + }; + +- let recovered_compat: Compat = driver_task +- .into_inner() +- .await ++ let driver_res = match finished_driver { ++ Some(res) => res, ++ None => driver_task.into_inner().await, ++ }; ++ let recovered_compat: Compat = driver_res + .map_err(|e| Error::MpcTlsFailed { + detail: format!("driver task join: {e}"), + })? diff --git a/harness/docker-compose.yml b/harness/docker-compose.yml index 213134a7..67525173 100644 --- a/harness/docker-compose.yml +++ b/harness/docker-compose.yml @@ -1,5 +1,6 @@ # The libID integration stack: anvil + a one-shot contract deploy + the -# notary + the libID server, all from released images/binaries. The +# notary + the libID server. Keeper/notary carry a driver-close backport; +# other services use released images/binaries. The # demo UI and its static assets stay host-side (vite dev server) — see # boot.sh, which orchestrates the whole thing. # @@ -60,7 +61,10 @@ services: entrypoint: ["bash", "/deploy.sh"] notary: - image: ghcr.io/libid-org/notary:0.2.0 + build: + context: . + dockerfile: compose/legacy-rust.Dockerfile + target: notary depends_on: deploy: condition: service_completed_successfully @@ -91,7 +95,10 @@ services: # For a long-lived local rotation loop, run instead: # docker compose run --rm keeper --config /input/keeper.toml run keeper: - image: ghcr.io/libid-org/keeper:0.2.0 + build: + context: . + dockerfile: compose/legacy-rust.Dockerfile + target: keeper depends_on: deploy: condition: service_completed_successfully diff --git a/ts/apps/dev/README.md b/ts/apps/dev/README.md new file mode 100644 index 00000000..ed0ee38d --- /dev/null +++ b/ts/apps/dev/README.md @@ -0,0 +1,101 @@ +# @libid/dev + +Local application for testing real ceremonies with the OAuth Bridge, notary and +CCDP distribution. It uses a synthetic ledger fixture and submits no transactions. + +## Run + +Use the workspace's Node and pnpm setup, plus Docker with Compose. Docker Desktop +on macOS needs amd64 emulation for the notary image. No host Rust toolchain or +local certificates are needed. + +From the TypeScript workspace: + +```sh +pnpm install --frozen-lockfile +pnpm dev +``` + +Open **http://localhost:4691**. The command builds dependencies and CCDP, starts +Docker services, waits for Bridge readiness, then starts the frontend. The first +build takes longer; later launches reuse cached downloads and container layers. +Ctrl-C stops the app and that checkout's containers. + +| Service | Local URL | +|---|---| +| Application | http://localhost:4691 | +| OAuth Bridge | http://localhost:4682 | +| CCDP | http://localhost:4683 | +| Notary HTTP/WebSocket | http://localhost:4687 | + +These are separate browser origins. Ports are fixed and services bind to loopback. + +## Configure and develop + +Edit configuration directly where it is used: + +- [compose.yaml](compose.yaml): services. +- [bridge-config.toml](bridge-config.toml): Bridge configuration and development OAuth credentials. +- [src/app.ts](src/app.ts): Bridge/CCDP URLs and the local ledger/notary fixture. +- [vite.config.ts](vite.config.ts): frontend port. + +The setup includes shared development OAuth credentials and a public development +notary signing key; no environment file is needed. Bridge publishes the GitHub +`clientCredential` from its platform configuration. OAuth registrations must use +**`http://localhost:4682/auth/callback`**. To connect another application, add its +exact origin to `allowed_app_origins` in `bridge-config.toml`. + +Frontend edits reload through Vite. After changing ceremony or popup source, +run this in another terminal while `pnpm dev` stays running: + +```sh +pnpm dev:ccdp +``` + +It rebuilds the packages and static distribution, then recreates only CCDP. +Bridge, notary and the app stay running; no Docker images are built. Refresh +the app and start a fresh ceremony to use updated Prover/Prefetch code. +Bridge caches Callback for up to five minutes, so restart `pnpm dev` after +Callback changes to apply them immediately. Also restart after changing service +configuration or dependency images; unchanged Bridge builds reuse Docker layers. + +To run services and frontend separately: + +```sh +pnpm dev:services +# Another terminal: +pnpm dev:app +``` + +Choose a platform and complete provider consent. Multiple ceremonies can run at +once, including for the same platform. Each history row has its own status and +Close button. Success and denial close only that run’s popup automatically; +failed popups remain open for DevTools inspection and can be closed with the same +button while its connection remains usable. The Close control disappears when +the connection ends. Reported closure marks the run Interrupted; detected +transport failure reports its error. With a native-anchor launch, Close becomes +available after the popup authenticates and can be controlled. +Results are available in `window.results`, a Map keyed by each row's +`data-ceremony-id`. See the [manual qualification checkpoints](../../packages/ceremony/docs/testing.md#manual-consent-and-device-checks); the displayed outcomes alone do not establish cryptographic verification. + +## Checks + +```sh +pnpm --filter @libid/dev typecheck +pnpm --filter @libid/dev... build +pnpm --filter @libid/dev test:e2e +``` + +The browser tests use port 4692 and cover frontend behavior with intercepted +responses. Real OAuth and proving checks are documented in the +[ceremony qualification guide](../../packages/ceremony/docs/qualification.md). + +Run history shows core operation durations from their occurrence timestamps, plus +total ceremony time. The status uses the package’s stage +projection. Timings freeze at the terminal outcome and clear on page reload; +interrupted operations are marked, and overlapping durations are not added together. + +When isolation fallback occurs, **Prover fallback** shows navigation through Prover +readiness, using `prover-fallback` and `prover.started` occurrence timestamps. It +excludes work before navigation and does not measure the difference from a run +without fallback. Direct runs have no fallback row. diff --git a/ts/apps/dev/bridge-config.toml b/ts/apps/dev/bridge-config.toml new file mode 100644 index 00000000..064570e6 --- /dev/null +++ b/ts/apps/dev/bridge-config.toml @@ -0,0 +1,19 @@ +ccdp_origin = "http://localhost:4683" +allowed_app_origins = ["http://localhost:4691"] + +[[platforms]] +id = "google" +client_id = "391814431594-94274lch0aosjgsei87k41a08napunhd.apps.googleusercontent.com" +versions = [1] + +[[platforms]] +id = "x" +client_id = "QW5QY1ZGdVRsaDEyTFIwZDVfa2Q6MTpjaQ" +versions = [1] + +[[platforms]] +id = "github" +client_id = "Iv23lioEM9NAR9vO8CmT" +versions = [1] +# Intentionally public credential for the shared development app; uses PKCE. +client_credential = "b020b671192879cb993d1cdd7852c920fe745020" diff --git a/ts/apps/dev/compose.yaml b/ts/apps/dev/compose.yaml new file mode 100644 index 00000000..3563eba4 --- /dev/null +++ b/ts/apps/dev/compose.yaml @@ -0,0 +1,47 @@ +# Started by services.ts after building CCDP. +services: + notary: + image: ghcr.io/libid-org/notary:0.3.0-rc.3 + # This upstream release publishes amd64 only; Docker Desktop emulates it on ARM. + platform: linux/amd64 + environment: + NOTARY_HOST: 0.0.0.0 + # Public development key, never a production notary identity. + SIGNING_KEY: "0000000000000000000000000000000000000000000000000000000000000001" + ports: + - "127.0.0.1:4687:7048" + # Shared namespace lets Bridge retrieve CCDP through localhost. + - "127.0.0.1:4682:8722" + - "127.0.0.1:4683:4683" + + bridge: + build: https://github.com/libid-org/libid-server-rs.git#ea8121f4e05c39a0b383ecb383e2625feb088c91 + network_mode: service:notary + depends_on: + ccdp: + condition: service_healthy + notary: + condition: service_healthy + command: ["--config", "/bridge.toml"] + configs: + - source: bridge + target: /bridge.toml + + ccdp: + network_mode: service:notary + command: ["--port", "4683"] + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:4683/health"] + interval: 1s + timeout: 2s + retries: 30 + image: ghcr.io/static-web-server/static-web-server@sha256:4e804280b5b5b1be4563d4a9e0f3a0ea38e7887967d0cc00bc8c07030f529b3f + environment: + SERVER_CONFIG_FILE: /etc/sws.toml + volumes: + - .cache/ccdp/public:/home/sws/public:ro + - .cache/ccdp/sws.toml:/etc/sws.toml:ro + +configs: + bridge: + file: ./bridge-config.toml diff --git a/ts/apps/dev/package.json b/ts/apps/dev/package.json new file mode 100644 index 00000000..2f4ef330 --- /dev/null +++ b/ts/apps/dev/package.json @@ -0,0 +1,30 @@ +{ + "name": "@libid/dev", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Shared local libID services and development application.", + "license": "(MIT OR Apache-2.0)", + "scripts": { + "dev": "pnpm build:deps && node services.ts --app", + "dev:services": "pnpm build:deps && node services.ts", + "dev:ccdp": "pnpm build:deps && node services.ts --ccdp", + "dev:app": "pnpm build:deps && vite", + "build:deps": "pnpm --filter '@libid/dev^...' build", + "build": "vite build", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" + }, + "dependencies": { + "@libid/ceremony": "workspace:^", + "@libid/ledger": "workspace:^", + "@libid/popup": "workspace:^", + "@noble/hashes": "^2.3.0" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/node": "^22.0.0", + "typescript": "^5.9.0", + "vite": "^7.3.6" + } +} diff --git a/ts/apps/dev/playwright.config.ts b/ts/apps/dev/playwright.config.ts new file mode 100644 index 00000000..c86bbc3e --- /dev/null +++ b/ts/apps/dev/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: 'src', + testMatch: '*.spec.ts', + timeout: 30000, + workers: 1, + use: { + baseURL: 'http://localhost:4692', + trace: 'off', + video: 'off', + screenshot: 'off', + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + { name: 'android-emulated', use: { ...devices['Pixel 7'], browserName: 'chromium' } }, + { name: 'ios-emulated', use: { ...devices['iPhone 15'], browserName: 'webkit' } }, + ], + webServer: { + command: 'pnpm dev:app --port 4692', + cwd: new URL('.', import.meta.url).pathname, + url: 'http://localhost:4692', + reuseExistingServer: false, + timeout: 60000, + }, +}) diff --git a/ts/apps/dev/services.ts b/ts/apps/dev/services.ts new file mode 100644 index 00000000..ef6e5eaa --- /dev/null +++ b/ts/apps/dev/services.ts @@ -0,0 +1,99 @@ +// Start the real Bridge, notary and emitted CCDP; no OAuth mocks. +import { execFileSync, spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath } from 'node:url' +import { createServer as createViteServer, type ViteDevServer } from 'vite' + +const root = fileURLToPath(new URL('.', import.meta.url)) +// Distinct Compose ownership for each checkout. No shared container names. +const project = `libid-dev-${createHash('sha256').update(root).digest('hex').slice(0, 12)}` +const composeArgs = ['compose', '-p', project, '-f', join(root, 'compose.yaml')] +// Rebuild the shared distribution; immutable assets reuse the build cache. +execFileSync( + 'pnpm', + ['--filter', '@libid/ceremony', 'build:ccdp-artifacts', '--out-dir', join(root, '.cache/ccdp')], + { + cwd: root, + stdio: 'inherit', + }, +) +// Refresh the bind mounts after the build atomically replaces its output directory. +if (process.argv.includes('--ccdp')) { + execFileSync( + 'docker', + [...composeArgs, 'up', '--wait', '--no-deps', '--no-build', '--force-recreate', 'ccdp'], + { + stdio: 'inherit', + }, + ) + process.exit(0) +} +let frontend: ViteDevServer | undefined +let stopping = false +function stop(code: number) { + if (stopping) return + stopping = true + process.exitCode = code + void frontend?.close() + // Stop the producer before teardown; CLI plugins share this owned process group. + if (compose.pid) { + try { + process.kill(-compose.pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + } + // Keep this child alive through completion, including after a partial startup. + const down = spawn('docker', [...composeArgs, 'down'], { stdio: 'inherit' }) + down.on('error', () => console.error('Could not stop development containers. Check Docker.')) + down.on('close', (code) => { + if (code !== 0) process.exitCode = 1 + }) +} +process.once('SIGINT', () => stop(0)) +process.once('SIGTERM', () => stop(0)) +const compose = spawn('docker', [...composeArgs, 'up', '--build', '--abort-on-container-failure'], { + stdio: 'inherit', + detached: true, +}) +compose.on('error', () => { + console.error('Could not start Docker Compose. Install Docker with Compose and start its engine.') + stop(1) +}) +compose.on('exit', (code) => { + if (!stopping) stop(code || 1) +}) +if (process.argv.includes('--app')) { + try { + console.info('Waiting for Bridge readiness before starting the frontend…') + while (!stopping) { + try { + const response = await fetch('http://127.0.0.1:4682/api/v1/ceremony/config', { + headers: { Origin: 'http://localhost:4691' }, + redirect: 'error', + signal: AbortSignal.timeout(1000), + }) + await response.body?.cancel() + if (response.status === 200) break + } catch { + /* Compose may still be building or starting the Bridge. */ + } + await delay(500) + } + if (!stopping) { + frontend = await createViteServer({ configFile: join(root, 'vite.config.ts') }) + if (stopping) await frontend.close() + else { + await frontend.listen() + frontend.printUrls() + } + } + } catch { + if (!stopping) { + console.error('Could not start the development frontend.') + stop(1) + } + } +} diff --git a/ts/apps/dev/src/app.spec.ts b/ts/apps/dev/src/app.spec.ts new file mode 100644 index 00000000..91ae37fc --- /dev/null +++ b/ts/apps/dev/src/app.spec.ts @@ -0,0 +1,824 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { type BrowserContext, expect, test } from '@playwright/test' + +const configUrl = 'http://localhost:4682/api/v1/ceremony/config' +const ccdp = 'http://localhost:4683' +const config = { + ccdpOrigin: ccdp, + platforms: { + google: { clientId: '407408718192.apps.googleusercontent.com', ceremonyVersions: [1] }, + github: { clientId: 'test-client', ceremonyVersions: [2], clientCredential: 'fixture-public' }, + }, +} +// Real popup transport with synthetic ceremony documents; no OAuth or proof qualification. +async function servePopup(context: BrowserContext) { + await context.route(`${ccdp}/popup-test/**`, (route) => + route.fulfill({ + contentType: 'text/javascript', + body: readFileSync( + new URL( + new URL(route.request().url()).pathname.slice('/popup-test/'.length), + import.meta.resolve('@libid/popup'), + ), + ), + }), + ) + return `${ccdp}/popup-test/index.js` +} + +async function serveCeremony(context: BrowserContext) { + const popupModule = await servePopup(context) + const document = ( + prover: boolean, + ) => `Event transport fixture` + await context.route(`${ccdp}/ccdp/v1/prefetch**`, (route) => + route.fulfill({ contentType: 'text/html', body: document(false) }), + ) + await context.route(`${ccdp}/event-test**`, (route) => + route.fulfill({ contentType: 'text/html', body: document(true) }), + ) + await context.route(/https:\/\/(accounts\.google\.com|x\.com|github\.com)\//, (route) => { + const id = new URL(route.request().url()).searchParams.get('state')!.slice(3) + return route.fulfill({ + contentType: 'text/html', + body: ``, + }) + }) +} + +test('unavailable Bridge disables launch; reload loads compatible platforms', async ({ page }) => { + let available = false + await page.route(configUrl, (route) => + available + ? route.fulfill({ json: config }) + : route.fulfill({ status: 503, body: 'Unavailable' }), + ) + await page.goto('/') + await expect(page.getByRole('status')).toContainText('Could not load Bridge configuration') + await expect(page.locator('#platforms').getByRole('button')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Retry connection' })).toHaveCount(0) + available = true + await page.reload() + await expect(page.getByRole('status')).toContainText('Ready.') + await expect(page.locator('dl')).toContainText('http://localhost:4687') + await expect(page.locator('dl')).not.toContainText('Ledger') + await expect(page.locator('#platforms').getByRole('button')).toHaveText(['Google']) + await expect(page.getByRole('button', { name: 'Google', exact: true })).toBeEnabled() +}) +test('no compatible platforms stays unavailable', async ({ page }) => { + await page.route(configUrl, (route) => route.fulfill({ json: { ...config, platforms: {} } })) + await page.goto('/') + await expect(page.getByRole('status')).toContainText('no compatible platforms') + await expect(page.locator('#platforms').getByRole('button')).toHaveCount(0) +}) +for (const [platform, name] of [ + ['google', 'Google'], + ['x', 'X'], + ['github', 'GitHub'], +]) { + for (const blocked of [false, true]) { + test(`${name} popup launch${blocked ? ' through the native anchor' : ''}, closure and retry`, async ({ + page, + context, + }) => { + if (blocked) + await page.addInitScript(() => { + window.open = () => null + }) + await page.route(configUrl, (route) => + route.fulfill({ + json: { + ...config, + platforms: { + ...config.platforms, + x: { clientId: 'test-client', ceremonyVersions: [1] }, + github: { + clientId: 'test-client', + ceremonyVersions: [1], + clientCredential: 'fixture-public', + }, + }, + }, + }), + ) + // Only transport setup is exercised here. No simulated proof delivery or OAuth consent. + const popupModule = await servePopup(context) + await context.route(`${ccdp}/ccdp/v1/prefetch**`, (route) => + route.fulfill({ + contentType: 'text/html', + body: `Prefetch test boundary`, + }), + ) + await page.goto('/') + await expect(page.getByRole('status')).toContainText('Ready.') + const opened = page.waitForEvent('popup') + const launch = page.getByRole('button', { name, exact: true }) + if (platform === 'google') await launch.press(blocked ? 'Space' : 'Enter') + else await launch.click() + const popup = await opened + await expect(popup).toHaveURL(/\/ccdp\/v1\/prefetch#/) + if (blocked) { + await expect( + page.locator('#history tr').first().getByRole('button', { name: 'Close' }), + ).toBeDisabled() + await popup.waitForFunction( + () => + typeof (window as unknown as { authenticate?: unknown }).authenticate === 'function', + ) + await popup.evaluate(() => (window as unknown as { authenticate(): void }).authenticate()) + } + await popup.waitForFunction(() => (window as unknown as { connected: boolean }).connected) + expect(new URLSearchParams(new URL(popup.url()).hash.slice(1)).get('platformId')).toBe( + platform, + ) + await expect(page.locator('#platforms').getByRole('button')).toHaveCount(3) + for (const button of await page.locator('#platforms').getByRole('button').all()) + await expect(button).toBeEnabled() + const rows = page.locator('#history tr') + await expect(rows).toHaveCount(1) + await expect(rows.first().getByRole('cell').nth(1)).toHaveText(name) + await expect(rows.first().locator('.run-outcome')).toHaveText('Running') + await expect( + page.locator('#history tr').first().getByRole('button', { name: 'Close' }), + ).toBeEnabled() + await page.locator('#history tr').first().getByRole('button', { name: 'Close' }).click() + await expect(page.locator('#history tr').first().locator('.run-status')).toHaveText( + 'Popup connection ended', + ) + await expect.poll(() => popup.isClosed()).toBe(true) + for (const button of await page.locator('#platforms').getByRole('button').all()) + await expect(button).toBeEnabled() + expect(await page.evaluate(() => [...window.results.values()])).toEqual([ + { status: 'closed' }, + ]) + await expect(rows.first().locator('.run-outcome')).toHaveText('Interrupted') + await expect(rows.first().getByRole('cell').nth(3)).toHaveText(/^\d+\.\d s$/) + await expect(rows.first().locator('.operation-timings li')).toContainText('Prefetch dispatch') + await expect(rows.first().locator('.operation-timings li')).toContainText('(interrupted)') + if (platform === 'google' && !blocked) { + const secondOpened = page.waitForEvent('popup') + await page.getByRole('button', { name: 'X', exact: true }).click() + const secondPopup = await secondOpened + await secondPopup.waitForFunction( + () => (window as unknown as { connected: boolean }).connected, + ) + await expect(rows).toHaveCount(2) + await expect(rows.first().getByRole('cell').nth(1)).toHaveText('X') + await expect(rows.first().locator('.run-outcome')).toHaveText('Running') + await expect(rows.nth(1).getByRole('cell').nth(1)).toHaveText('Google') + await expect(rows.nth(1).locator('.run-outcome')).toHaveText('Interrupted') + await page.locator('#history tr').first().getByRole('button', { name: 'Close' }).click() + await expect(rows.first().locator('.run-outcome')).toHaveText('Interrupted') + await expect.poll(() => secondPopup.isClosed()).toBe(true) + await page.reload() + await expect(rows).toHaveCount(0) + await expect(page.locator('#history-empty')).toBeVisible() + } + }) + } +} + +for (const blocked of [false, true]) { + for (const transportFailure of [false, true]) { + test(`${transportFailure ? 'transport' : 'ceremony'} failure keeps the popup open${blocked ? ' through the native anchor' : ''} until manually closed`, async ({ + page, + context, + }) => { + if (blocked) + await page.addInitScript(() => { + window.open = () => null + }) + await page.route(configUrl, (route) => route.fulfill({ json: config })) + await context.route(`${ccdp}/ccdp/v1/prefetch**`, (route) => + route.fulfill({ + contentType: 'text/html', + body: 'Failure test boundary', + }), + ) + await page.goto('/') + const launch = page.getByRole('button', { name: 'Google', exact: true }) + await expect(launch).toBeEnabled() + const opened = page.waitForEvent('popup') + await launch.click() + const popup = await opened + await expect(popup).toHaveURL(/\/ccdp\/v1\/prefetch#/) + // A synthetic failure over the actual popup transport; no OAuth or proof is simulated. + // Serve the real package at the popup origin, avoiding cross-origin dev-server imports. + const popupModule = await servePopup(context) + await popup.evaluate( + async ({ moduleUrl, transportFailure }) => { + const { PopupConnection, PopupWindow } = await import(/* @vite-ignore */ moduleUrl) + const id = new URLSearchParams(location.hash.slice(1)).get('ceremonyId') + const connection = PopupConnection.accept( + PopupWindow.current(location.hash, { scope: '/' }), + { + connectionId: id, + allowedApplicationOrigins: ['http://localhost:4692'], + }, + ) + await connection.ready + connection.send( + transportFailure + ? { type: 'event' } + : { + type: 'ceremony-failed', + event: 'identity-fetch', + message: ' Invalid GitHub id', + }, + ) + }, + { moduleUrl: popupModule, transportFailure }, + ) + if (transportFailure) { + await expect(page.locator('.run-actions')).toBeEmpty() + await expect(page.locator('.run-outcome')).toHaveText('Failed (prefetch-dispatch)') + expect(popup.isClosed()).toBe(false) + await popup.close() + return + } + await expect(page.locator('.run-status')).toContainText('Invalid GitHub id') + await expect( + page.locator('#history tr').first().getByRole('button', { name: 'Close' }), + ).toBeEnabled() + await expect(page.locator('#history')).toContainText('Failed (identity-fetch)') + expect(popup.isClosed()).toBe(false) + await expect(page.locator('#history tr').first().locator('.run-status')).toHaveText( + ' Invalid GitHub id', + ) + await expect(page.locator('.run-status img')).toHaveCount(0) + await expect(launch).toBeEnabled() + await page.locator('#history tr').first().getByRole('button', { name: 'Close' }).click() + await expect.poll(() => popup.isClosed()).toBe(true) + await expect(page.locator('#history')).toContainText('Failed (identity-fetch)') + await expect(launch).toBeEnabled() + }) + } +} + +test('private configuration and generated files are not served', async ({ request }) => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const directory = mkdtempSync(join(root, '.cache/private-file-test-')) + const file = join(directory, 'probe.json') + const privateConfig = join(root, `.env.${basename(directory)}`) + writeFileSync(file, '{}') + writeFileSync(privateConfig, 'PRIVATE_TEST_VALUE=fixture', { flag: 'wx' }) + try { + for (const path of [privateConfig, file]) { + const response = await request.get(`/@fs${path}`) + expect(response.status(), path).toBe(403) + } + } finally { + rmSync(privateConfig) + rmSync(directory, { recursive: true, force: true }) + } +}) + +for (const [platform, name, outcome = 'failed', fallback = false] of [ + ['google', 'Google'], + ['x', 'X'], + ['github', 'GitHub'], + ['google', 'Google', 'success'], + ['google', 'Google', 'denied'], + ['google', 'Google', 'closed'], + ['google', 'Google', 'success', true], + ['google', 'Google', 'failed', true], +] as const) { + test(`${name} operation timings${fallback ? ' with fallback' : ''} preserve occurrences and freeze on ${outcome}`, async ({ + page, + context, + }) => { + await page.route(configUrl, (route) => + route.fulfill({ + json: { + ...config, + platforms: { + [platform]: { + clientId: 'client', + ceremonyVersions: [1], + ...(platform === 'github' ? { clientCredential: 'bridge-provided' } : {}), + }, + }, + }, + }), + ) + await serveCeremony(context) + await page.clock.install() + await page.goto('/') + const opened = page.waitForEvent('popup') + await page.getByRole('button', { name, exact: true }).click() + const popup = await opened + await popup.waitForFunction(() => !!(window as unknown as { returnUrl?: string }).returnUrl) + const prefetchDetails = page.locator('.operation-timings details').filter({ + has: page.locator('summary', { hasText: /^Prefetch dispatch/ }), + }) + await expect(prefetchDetails.locator('dl')).toBeHidden() + await prefetchDetails.locator('summary').click() + await expect(prefetchDetails.locator('dt')).toHaveText([ + 'document startup', + 'connection', + 'worker ready', + 'dispatch', + ]) + await expect(prefetchDetails.locator('dd')).toHaveText(['25 ms', '2000 ms', '75 ms', '30 ms']) + await prefetchDetails.locator('summary').click() + await expect(prefetchDetails.locator('dl')).toBeHidden() + await page.clock.runFor(5000) + await popup.evaluate(() => + location.replace((window as unknown as { returnUrl: string }).returnUrl), + ) + await popup.waitForFunction( + () => !!(window as unknown as { eventConnection?: unknown }).eventConnection, + ) + const returnedAt = await page.evaluate(() => performance.timeOrigin + performance.now()) + // Explicit occurrence times test transport delay independently of the app's delivery clock. + const send = async ( + event: string, + phase: 'started' | 'finished' | undefined, + offset: number, + attributes?: Record, + ) => + popup.evaluate( + ({ event, phase, timestamp, attributes }) => { + ;( + window as unknown as { eventConnection: { send(value: unknown): void } } + ).eventConnection.send({ + type: 'event', + event, + ...(phase ? { phase } : {}), + timestamp, + ...(attributes ? { instrumentation: { attributes } } : {}), + }) + }, + { event, phase, timestamp: returnedAt + offset, attributes }, + ) + await send('authorization', 'finished', 0) + if (fallback) { + await page.clock.runFor(1000) + await send('prover-fallback', undefined, 100) + await expect(page.locator('.operation-timings')).toContainText('Prover fallback') + await page.clock.runFor(1500) + await expect(page.locator('.operation-timings')).toContainText( + /Prover fallback · \d+\.\d s \(running\)/, + ) + } + await send('prover', 'started', fallback ? 710 : 10) + const fallbackTiming = page + .locator('.operation-timings li') + .filter({ hasText: 'Prover fallback' }) + if (fallback) await expect(fallbackTiming).toHaveText('Prover fallback · 0.6 s') + else await expect(fallbackTiming).toHaveCount(0) + await popup.waitForFunction(() => (window as unknown as { requested?: boolean }).requested) + if (platform === 'github') + expect( + await popup.evaluate( + () => + (window as unknown as { proveIdentity: { clientCredential: string } }).proveIdentity + .clientCredential, + ), + ).toBe('bridge-provided') + if (outcome === 'closed') { + await popup.evaluate(() => + ( + window as unknown as { eventConnection: { close(): Promise } } + ).eventConnection.close(), + ) + await expect(page.locator('.run-outcome')).toHaveText('Interrupted') + await expect(page.locator('.run-status')).toHaveText('Popup connection ended') + await expect(page.getByRole('button', { name: 'Close', exact: true })).toHaveCount(0) + await expect(page.locator('.operation-timings [data-status="running"]')).toHaveCount(0) + expect(await page.evaluate(() => [...window.results.values()])).toEqual([ + { status: 'closed' }, + ]) + const row = await page.locator('#history').textContent() + await page.clock.runFor(2000) + await expect(page.locator('#history')).toHaveText(row!) + return + } + if (outcome === 'denied') { + await popup.evaluate(() => { + ;( + window as unknown as { eventConnection: { send(value: unknown): void } } + ).eventConnection.send({ type: 'user-denied' }) + }) + await expect.poll(() => popup.isClosed()).toBe(true) + await expect(page.locator('.run-outcome')).toHaveText('Denied') + await expect(page.locator('.operation-timings [data-status="running"]')).toHaveCount(0) + await expect( + page.locator('.operation-timings li').filter({ hasText: /^Proving ·/ }), + ).toHaveAttribute('data-status', 'interrupted') + await expect(page.locator('.run-status')).toHaveText('Authorization was denied.') + await expect(page.getByRole('button', { name: 'Close', exact: true })).toHaveCount(0) + const row = await page.locator('#history').textContent() + await page.clock.runFor(2000) + await expect(page.locator('#history')).toHaveText(row!) + return + } + await send('zk-proof-preparation', 'started', fallback ? 720 : 20) + await expect(page.locator('.run-status')).toHaveText('Preparing your identity proof') + const preparation = page + .locator('.operation-timings li') + .filter({ hasText: 'ZK proof preparation' }) + await expect(preparation).toHaveAttribute('data-status', 'running') + await expect(preparation).toHaveCSS('font-weight', '600') + const runningColor = await preparation.evaluate((element) => getComputedStyle(element).color) + const attributes = { + 'openings-ms': 150, + 'finalization-ms': 30, + 'sent-bytes': 60, + 'received-bytes': 40, + 'committed-sent-bytes': 20, + 'committed-received-bytes': 30, + 'commitment-count': 2, + } + if (platform !== 'google') { + const token = 'token-fetch' + await send(token, 'started', 20) + await send(token, 'finished', 1000) + await send('token-attestation', 'started', 1000) + await send('identity-fetch', 'started', 1000) + await send('identity-fetch', 'finished', 2000) + await send('identity-attestation', 'started', 2000) + await send('token-attestation', 'finished', 1180, { + ...attributes, + 'response-header-bytes': 19, + 'response-body-bytes': 21, + }) + const details = page.locator('.operation-timings details').filter({ + has: page.locator('summary', { hasText: /attestation/ }), + }) + await expect(details).toHaveCount(1) + await expect(details.locator('summary')).toHaveText('Token attestation · 0.2 s') + // Token completion arrives after identity fetch, but occurred earlier. + await expect + .poll(() => page.locator('.operation-timings li span').allTextContents()) + .toEqual([ + expect.stringMatching(/^Prefetch dispatch ·/), + expect.stringMatching(/^Authorization ·/), + expect.stringMatching(/^Token fetch ·/), + expect.stringMatching(/^Token attestation ·/), + expect.stringMatching(/^Identity fetch ·/), + expect.stringMatching(/^Proving ·.*\(running\)$/), + expect.stringMatching(/^ZK proof preparation ·.*\(running\)$/), + expect.stringMatching(/^Identity attestation ·.*\(running\)$/), + ]) + await expect(details.locator('dl')).toBeHidden() + await details.locator('summary').click() + await expect(details.locator('dd')).toHaveText([ + '150 ms', + '30 ms', + '60 B', + '40 B', + '20 B', + '30 B', + '2', + '19 B', + '21 B', + ]) + await expect(details.locator('dl')).toBeVisible() + await page.clock.runFor(100) + await expect(details.locator('dl')).toBeVisible() + await details.locator('summary').press('Enter') + await expect(details.locator('dl')).toBeHidden() + } + await send('zk-proof-generation', 'started', 2500) + await send('zk-proof-preparation', 'finished', 2600) + await expect(preparation).toHaveAttribute('data-status', 'completed') + await expect(preparation).toHaveCSS('font-weight', '400') + expect(await preparation.evaluate((element) => getComputedStyle(element).color)).not.toBe( + runningColor, + ) + expect( + await preparation.evaluate((element) => getComputedStyle(element, '::marker').content), + ).toContain('✓') + await send('zk-proof-generation', 'finished', 3500) + await expect(page.locator('.run-status')).toHaveText('Creating your identity proof with ZK') + await expect(page.locator('.operation-timings')).toContainText('ZK proof generation · 1.0 s') + await expect(page.locator('#history tr').first().locator('.run-outcome')).toHaveText('Running') + // This synthetic delivery checks UI only; no browser proof generation is claimed. + await page.clock.runFor(4500) + if (platform !== 'google') { + await send('identity-attestation', 'finished', 4000, { ...attributes, 'openings-ms': 1970 }) + const details = page.locator('.operation-timings details').filter({ + has: page.locator('summary', { hasText: /attestation/ }), + }) + await expect(details).toHaveCount(2) + await expect(details.locator('summary')).toHaveText([ + 'Token attestation · 0.2 s', + 'Identity attestation · 2.0 s', + ]) + for (const item of await details.all()) await expect(item.locator('dl')).toBeHidden() + // Missing header/body observations do not turn into zero-valued measurements. + await expect(details.last().locator('dt')).not.toContainText([ + 'response header', + 'response body', + ]) + } + // pauseAt affects both documents; their clocks can differ after navigation. + const times = await Promise.all([page, popup].map((p) => p.evaluate(() => Date.now()))) + await page.clock.pauseAt(Math.max(...times) + 1000) + await popup.evaluate((success) => { + const connection = (window as unknown as { eventConnection: { send(value: unknown): void } }) + .eventConnection + connection.send( + success + ? { + type: 'identity-proof', + identity: { + platformId: 'google', + oauthClientId: 'client', + userId: '1', + userName: 'a@b.c', + }, + proof: { + identityProof: new Uint8Array([1]), + tokenExpiresAt: 42, + signingKeyModulus: new Uint8Array(256), + }, + } + : { type: 'ceremony-failed', event: 'identity-fetch', message: 'Invalid GitHub id' }, + ) + }, outcome === 'success') + await expect(page.locator('#history')).toContainText( + outcome === 'success' ? 'Proof received' : 'Failed (identity-fetch)', + ) + // The app closes success without waiting for an application timer. + if (outcome === 'success') await expect.poll(() => popup.isClosed()).toBe(true) + else expect(popup.isClosed()).toBe(false) + const timings = page.locator('.operation-timings li') + await expect(timings).toHaveCount((platform === 'google' ? 5 : 9) + Number(fallback)) + await expect + .poll(async () => + (await timings.locator('span').allTextContents()).map((text) => text.split(' · ')[0]), + ) + .toEqual([ + 'Prefetch dispatch', + 'Authorization', + ...(fallback ? ['Prover fallback'] : []), + ...(platform === 'google' ? [] : ['Token fetch', 'Token attestation', 'Identity fetch']), + 'ZK proof preparation', + 'ZK proof generation', + ...(platform === 'google' ? [] : ['Identity attestation']), + 'Proving', + ]) + await expect(page.locator('.operation-timings [data-status="running"]')).toHaveCount(0) + await expect(timings.filter({ hasText: /^Proving ·/ })).toHaveAttribute( + 'data-status', + outcome === 'success' ? 'completed' : 'interrupted', + ) + await expect(preparation).toHaveAttribute('data-status', 'completed') + if (fallback) await expect(fallbackTiming).toHaveText('Prover fallback · 0.6 s') + const cells = page.locator('#history tr').first().getByRole('cell') + const total = Number.parseFloat((await cells.nth(3).textContent())!) + await expect(cells).toHaveCount(6) + expect(total).toBeGreaterThanOrEqual(9.4) + const row = await page.locator('#history').textContent() + await page.clock.runFor(2000) + await expect(page.locator('#history')).toHaveText(row!) + }) +} + +for (const blocked of [false, true]) { + test(`concurrent runs keep controls, stages and results separate${blocked ? ' with native anchors' : ''}`, async ({ + page, + context, + }) => { + if (blocked) + await page.addInitScript(() => { + window.open = () => null + }) + await page.route(configUrl, (route) => + route.fulfill({ + json: { + ...config, + platforms: { + google: { clientId: 'client', ceremonyVersions: [1] }, + x: { clientId: 'client', ceremonyVersions: [1] }, + }, + }, + }), + ) + await serveCeremony(context) + await page.goto('/') + await expect(page.locator('#status')).toContainText('Ready.') + + async function launch(name: string) { + const opened = page.waitForEvent('popup') + await page.getByRole('button', { name, exact: true }).click() + const popup = await opened + await popup.waitForFunction(() => !!(window as unknown as { returnUrl?: string }).returnUrl) + const id = (await page.locator('#history tr').first().getAttribute('data-ceremony-id'))! + const row = page.locator(`[data-ceremony-id="${id}"]`) + return { popup, id, row } + } + const first = await launch('Google') + const second = await launch('Google') + const third = await launch('X') + expect(new Set([first.id, second.id, third.id]).size).toBe(3) + await expect(page.locator('#history tr')).toHaveCount(3) + await expect(page.getByRole('button', { name: 'Close', exact: true })).toHaveCount(3) + for (const run of [first, second, third]) { + expect(run.popup.isClosed()).toBe(false) + await run.popup.evaluate(() => + location.replace((window as unknown as { returnUrl: string }).returnUrl), + ) + await run.popup.waitForFunction( + () => !!(window as unknown as { eventConnection?: unknown }).eventConnection, + ) + await run.popup.evaluate(() => { + const connection = ( + window as unknown as { eventConnection: { send(value: unknown): void } } + ).eventConnection + const timestamp = performance.timeOrigin + performance.now() + connection.send({ type: 'event', event: 'authorization', phase: 'finished', timestamp }) + connection.send({ type: 'event', event: 'prover', phase: 'started', timestamp }) + }) + await run.popup.waitForFunction( + () => (window as unknown as { requested?: boolean }).requested, + ) + } + for (const [run, event] of [ + [first, 'zk-proof-generation'], + [third, 'token-fetch'], + ] as const) + await run.popup.evaluate((event) => { + const connection = ( + window as unknown as { eventConnection: { send(value: unknown): void } } + ).eventConnection + connection.send({ + type: 'event', + event, + phase: 'started', + timestamp: performance.timeOrigin + performance.now(), + }) + }, event) + await expect(first.row.locator('.run-status')).toHaveText( + 'Creating your identity proof with ZK', + ) + await expect(second.row.locator('.run-status')).toHaveText('Preparing your identity proof') + await expect(third.row.locator('.run-status')).toHaveText('Notarizing your identity data') + // Complete the later Google run first; this is synthetic UI delivery, not a generated proof. + await second.popup.evaluate(() => { + const connection = (window as unknown as { eventConnection: { send(value: unknown): void } }) + .eventConnection + connection.send({ + type: 'identity-proof', + identity: { platformId: 'google', oauthClientId: 'client', userId: '1', userName: 'a@b.c' }, + proof: { + identityProof: new Uint8Array([1]), + tokenExpiresAt: 42, + signingKeyModulus: new Uint8Array(256), + }, + }) + }) + await expect.poll(() => second.popup.isClosed()).toBe(true) + await expect(second.row.locator('.run-outcome')).toHaveText('Proof received') + await expect(first.row.locator('.run-outcome')).toHaveText('Running') + await expect(third.row.locator('.run-outcome')).toHaveText('Running') + expect(first.popup.isClosed()).toBe(false) + expect(third.popup.isClosed()).toBe(false) + + await first.row.getByRole('button', { name: 'Close' }).click() + await expect(first.row.locator('.run-outcome')).toHaveText('Interrupted') + await expect.poll(() => first.popup.isClosed()).toBe(true) + await expect(third.row.getByRole('button', { name: 'Close' })).toBeEnabled() + expect(third.popup.isClosed()).toBe(false) + await third.popup.evaluate(() => { + const connection = (window as unknown as { eventConnection: { send(value: unknown): void } }) + .eventConnection + connection.send({ + type: 'ceremony-failed', + event: 'identity-fetch', + message: 'Identity request failed', + }) + }) + await expect(third.row.locator('.run-outcome')).toHaveText('Failed (identity-fetch)') + expect(third.popup.isClosed()).toBe(false) + expect( + await page.evaluate(() => + Object.fromEntries([...window.results].map(([id, result]) => [id, result.status])), + ), + ).toEqual({ + [first.id]: 'closed', + [second.id]: 'accepted', + [third.id]: 'failed', + }) + + const fourth = await launch('Google') + await third.popup.close() + await page.evaluate(() => { + const encode = TextEncoder.prototype.encode + TextEncoder.prototype.encode = () => { + TextEncoder.prototype.encode = encode + throw new Error('Input preparation failed after opening the popup') + } + }) + const failedPopupOpened = blocked ? undefined : page.waitForEvent('popup') + await page.getByRole('button', { name: 'Google', exact: true }).click() + const failedPopup = await failedPopupOpened + await expect(page.locator('#history tr').first().locator('.run-outcome')).toHaveText( + 'Failed to start', + ) + if (failedPopup) { + expect(failedPopup.isClosed()).toBe(false) + await page.locator('#history tr').first().getByRole('button', { name: 'Close' }).click() + await expect.poll(() => failedPopup.isClosed()).toBe(true) + await expect(page.locator('#history tr').first().locator('.run-outcome')).toHaveText( + 'Failed to start', + ) + } + await expect(fourth.row.locator('.run-outcome')).toHaveText('Running') + expect(fourth.popup.isClosed()).toBe(false) + await fourth.row.getByRole('button', { name: 'Close' }).click() + await expect(fourth.row.locator('.run-outcome')).toHaveText('Interrupted') + await expect.poll(() => fourth.popup.isClosed()).toBe(true) + await expect(fourth.row.locator('.run-outcome')).toHaveText('Interrupted') + expect(await page.evaluate((id) => window.results.get(id)?.status, fourth.id)).toBe('closed') + await expect(second.row.locator('.run-outcome')).toHaveText('Proof received') + }) +} + +for (const credential of ['bridge-provided', undefined, null, '']) { + test(`validates the Bridge client credential: ${JSON.stringify(credential)}`, async ({ + page, + }) => { + await page.route(configUrl, (route) => + route.fulfill({ + json: { + ...config, + platforms: { + github: { + clientId: 'test-client', + ceremonyVersions: [1], + clientCredential: credential, + }, + }, + }, + }), + ) + await page.goto('/') + if (credential) + await expect(page.getByRole('button', { name: 'GitHub', exact: true })).toBeEnabled() + else await expect(page.getByRole('status')).toContainText('Could not load Bridge configuration') + }) +} + +for (const blocked of [false, true]) + test(`GitHub consent-page closure fails the run${blocked ? ' after native-anchor launch' : ''}`, async ({ + page, + context, + }) => { + if (blocked) + await page.addInitScript(() => { + window.open = () => null + }) + await page.route(configUrl, (route) => + route.fulfill({ + json: { + ...config, + platforms: { + github: { + ...config.platforms.github, + ceremonyVersions: [1], + clientCredential: 'test-public-credential', + }, + }, + }, + }), + ) + await serveCeremony(context) + await page.goto('/') + await expect(page.getByRole('status')).toContainText('Ready.') + const opened = page.waitForEvent('popup') + await page.getByRole('button', { name: 'GitHub', exact: true }).click() + const popup = await opened + await expect(popup).toHaveURL(/^https:\/\/github\.com\/login\/oauth\/authorize/) + await popup.waitForFunction(() => 'returnUrl' in window) + await popup.close() + const row = page.locator('#history tr').first() + await expect(row.locator('.run-outcome')).toHaveText('Failed (authorization)') + await expect(row.locator('.run-status')).toContainText('closed or isolated') + await expect(row.locator('.run-actions')).toBeEmpty() + await expect + .poll(() => page.evaluate(() => [...window.results.values()])) + .toEqual([{ status: 'failed' }]) + }) diff --git a/ts/apps/dev/src/app.ts b/ts/apps/dev/src/app.ts new file mode 100644 index 00000000..f03cf324 --- /dev/null +++ b/ts/apps/dev/src/app.ts @@ -0,0 +1,289 @@ +import { CeremonyError } from '@libid/ceremony' +import { + type CCDPClient, + type CeremonyEvent, + CeremonyStage, + createCCDPClient, + type IdentityResult, + type PlatformId, +} from '@libid/ceremony/ccdp/client' +import type { LedgerId } from '@libid/ledger' +import { testnet } from '@libid/ledger/testing' +import { type Message, PopupConnection, PopupWindow } from '@libid/popup' +import { sha256 } from '@noble/hashes/sha2.js' + +declare global { + interface Window { + results: Map + } +} +const settings = { bridge: 'http://localhost:4682', ccdp: 'http://localhost:4683' } +const ledger: LedgerId = { ...testnet, notaryAddress: () => 'http://localhost:4687' } +const platforms = document.querySelector('#platforms')! +const status = document.querySelector('#status')! +window.results = new Map() +document.querySelector('#bridge')!.textContent = settings.bridge +document.querySelector('#ccdp')!.textContent = settings.ccdp +document.querySelector('#notary')!.textContent = ledger.notaryAddress() +const names: Record = { google: 'Google', x: 'X', github: 'GitHub' } +let client: CCDPClient | undefined +async function initialize() { + try { + client = await createCCDPClient({ oauthBridge: settings.bridge }) + platforms.replaceChildren( + ...client.enabledPlatforms.map((platform) => { + const launch = document.createElement('a') + launch.className = 'launch' + launch.href = '/' + launch.setAttribute('role', 'button') + launch.textContent = names[platform] + launch.addEventListener('click', (event) => start(event, launch, platform)) + launch.addEventListener('keydown', (event) => { + if (event.key === ' ') { + event.preventDefault() + launch.click() + } + }) + return launch + }), + ) + status.textContent = client.enabledPlatforms.length + ? 'Ready. Click a platform to start a ceremony.' + : 'The Bridge has no compatible platforms enabled.' + } catch { + status.textContent = + 'Could not load Bridge configuration. Check its address, certificate and application allowlist, then reload this page.' + } +} +const operationNames: Record = { + 'prefetch-dispatch': 'Prefetch dispatch', + authorization: 'Authorization', + prover: 'Proving', + 'prover-fallback': 'Prover fallback', + 'token-fetch': 'Token fetch', + 'token-attestation': 'Token attestation', + 'identity-fetch': 'Identity fetch', + 'identity-attestation': 'Identity attestation', + 'zk-proof-preparation': 'ZK proof preparation', + 'proof-backend-initialization': 'ZK backend initialization', + 'zk-proof-generation': 'ZK proof generation', +} +/** One row owns its timings and presentation; its controls are bound to that run only. */ +function beginRun(platform: PlatformId, id: string) { + const now = () => performance.timeOrigin + performance.now() + const row = document.createElement('tr') + row.dataset.ceremonyId = id + const cells = [new Date().toLocaleTimeString(), names[platform], 'Running', '—'].map((text) => { + const cell = document.createElement('td') + cell.textContent = text + row.append(cell) + return cell + }) + const outcome = document.createElement('strong') + outcome.className = 'run-outcome' + outcome.textContent = 'Running' + const message = document.createElement('p') + message.className = 'run-status' + message.setAttribute('role', 'status') + message.textContent = 'Opening authorization…' + cells[2]!.replaceChildren(outcome, message) + document.querySelector('#history')!.prepend(row) + document.querySelector('#history-empty')!.hidden = true + const timings = document.createElement('ol') + timings.className = 'operation-timings' + const timingsCell = document.createElement('td') + timingsCell.append(timings) + row.append(timingsCell) + const actions = document.createElement('td') + const close = document.createElement('button') + close.type = 'button' + close.textContent = 'Close' + close.disabled = true + actions.append(close) + actions.className = 'run-actions' + row.append(actions) + const operations = new Map< + string, + { name: string; started: number; finished?: number; cell: HTMLLIElement; label: HTMLElement } + >() + let started: number | undefined, + finished = false + const duration = (start: number, end: number) => + `${Math.max(0, (end - start) / 1000).toFixed(1)} s` + const render = (timestamp = now()) => { + if (started !== undefined) cells[3]!.textContent = duration(started, timestamp) + for (const op of operations.values()) { + op.cell.dataset.status = + op.finished !== undefined ? 'completed' : finished ? 'interrupted' : 'running' + op.label.textContent = `${op.name} · ${duration(op.started, op.finished ?? timestamp)}${op.finished === undefined ? (finished ? ' (interrupted)' : ' (running)') : ''}` + } + } + const timer = setInterval(render, 100) + const finish = (text: string, timestamp = now()) => { + if (finished) return + finished = true + clearInterval(timer) + render(timestamp) + outcome.textContent = text + } + return { + finish, + message, + close, + onEvent(event: CeremonyEvent) { + if (finished) return + if ( + (event.status === 'active' || event.status === 'completed') && + operationNames[event.event] + ) { + if (event.event === 'prefetch-dispatch' && event.phase === 'started') + started = event.timestamp + const op = operations.get(event.event) + if ((event.phase === 'started' || event.event === 'prover-fallback') && !op) { + const cell = document.createElement('li') + const label = document.createElement('span') + cell.append(label) + operations.set(event.event, { + name: operationNames[event.event], + started: event.timestamp, + cell, + label, + }) + timings.append(cell) + } else if (event.phase === 'finished' && op) { + op.finished = event.timestamp + const attributes = + event.status === 'active' ? event.instrumentation?.attributes : undefined + if (attributes && Object.keys(attributes).length) { + const details = document.createElement('details') + const summary = document.createElement('summary') + const values = document.createElement('dl') + for (const [key, value] of Object.entries(attributes)) { + const term = document.createElement('dt') + const description = document.createElement('dd') + term.textContent = key.replace(/-(ms|bytes)$/, '').replaceAll('-', ' ') + term.title = + key === 'openings-ms' + ? 'TLSNotary proof work until commitment openings arrive, including worker delivery.' + : key === 'finalization-ms' + ? 'From openings until the final correlated attestation arrives.' + : '' + description.textContent = + typeof value === 'number' && key.endsWith('-ms') + ? `${value.toFixed(0)} ms` + : typeof value === 'number' && key.endsWith('-bytes') + ? `${value} B` + : String(value) + values.append(term, description) + } + summary.append(op.label) + details.append(summary, values) + op.cell.replaceChildren(details) + } + } + // The single-shot fallback observation begins the interval ending at Prover readiness. + if (event.event === 'prover' && event.phase === 'started') { + const fallback = operations.get('prover-fallback') + if (fallback) fallback.finished = event.timestamp + } + const ordered = [...operations.values()].sort( + (a, b) => (a.finished ?? Infinity) - (b.finished ?? Infinity) || a.started - b.started, + ) + // Move existing rows only when necessary, preserving expanded details. + for (const [index, { cell }] of ordered.entries()) { + const next = timings.children[index] + if (next !== cell) timings.insertBefore(cell, next ?? null) + } + } + if (event.status !== 'active') + finish( + event.status === 'completed' + ? 'Proof received' + : event.status === 'closed' + ? 'Interrupted' + : event.status === 'denied' + ? 'Denied' + : `Failed (${'event' in event ? event.event : 'ceremony'})`, + event.timestamp, + ) + else render() + }, + } +} +function start(event: MouseEvent, launch: HTMLAnchorElement, platform: PlatformId) { + if (!client) { + event.preventDefault() + return + } + const id = crypto.randomUUID() + const run = beginRun(platform, id) + launch.target = `ceremony-dev-${id}` + // Keep creation and the native-anchor fallback inside the same user gesture. + try { + const popup = PopupWindow.open(launch.target, 'width=480,height=720') + const current = PopupConnection.connect(popup, { + connectionId: id, + allowedPopupOrigins: [...new Set([settings.bridge, settings.ccdp])], + }) + run.close.disabled = !popup.opened + // A native-anchor popup supplies its window handle only when it authenticates. + void current.ready + .then(() => { + run.close.disabled = false + }) + .catch(() => {}) + void current.closed.then(() => { + run.close.onclick = null + run.close.remove() + }) + run.close.onclick = () => { + void current.close().catch(() => { + run.message.textContent = 'Could not close the popup. Close its window manually.' + }) + } + const ceremony = client.new( + current, + id, + platform, + ledger, + sha256(new TextEncoder().encode('libid/ceremony/dev')), + new TextEncoder().encode('Ceremony development walkthrough'), + ) + launch.href = ceremony.launchUrl + if (popup.opened) event.preventDefault() + const off = ceremony.onEvent(run.onEvent) + const offStage = ceremony.onStage((event) => { + if (event.status === 'active') + run.message.textContent = CeremonyStage.message(event.stage, names[platform]) + }) + void ceremony + .proveUserIdentity() + .then(async (outcome) => { + window.results.set(id, outcome) + run.message.textContent = + outcome.status === 'denied' + ? 'Authorization was denied.' + : 'Proof received. Independent verification has not been run. No transaction was submitted.' + try { + await current.close() + } catch { + run.message.textContent = + 'Could not close the popup automatically. Close its window manually.' + } + }) + .catch((error: unknown) => { + window.results.set(id, { status: error instanceof CeremonyError ? error.status : 'failed' }) + run.message.textContent = error instanceof Error ? error.message : 'Ceremony failed.' + }) + .finally(() => { + off() + offStage() + }) + } catch { + event.preventDefault() + run.message.textContent = 'Could not start the ceremony. Close any remaining popup and retry.' + window.results.set(id, { status: 'failed' }) + run.finish('Failed to start') + } +} +void initialize() diff --git a/ts/apps/dev/src/index.html b/ts/apps/dev/src/index.html new file mode 100644 index 00000000..2b4baa75 --- /dev/null +++ b/ts/apps/dev/src/index.html @@ -0,0 +1,63 @@ + + + + + + Ceremony development + + + +

Ceremony development

+

Real OAuth and browser proving. No transaction is submitted.

+
+
Bridge
+
CCDP
+
Notary
+
+

Connecting to the Bridge…

+
+ Start a ceremony +
+
+

Consent happens in the provider’s window. The popup closes after success or denial. Failed attempts stay open for inspection. Use Close to close a popup.

+
+

Run history

+

Newest first. Cleared when you reload this page.

+

No ceremonies started.

+
+ + + +
StartedPlatformOutcomeDurationOperationsActions
+
+
+ + + diff --git a/ts/apps/dev/tsconfig.json b/ts/apps/dev/tsconfig.json new file mode 100644 index 00000000..8851128b --- /dev/null +++ b/ts/apps/dev/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["*.ts", "src"] +} diff --git a/ts/apps/dev/vite.config.ts b/ts/apps/dev/vite.config.ts new file mode 100644 index 00000000..1fef7bc0 --- /dev/null +++ b/ts/apps/dev/vite.config.ts @@ -0,0 +1,18 @@ +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { defineConfig } from 'vite' + +const root = fileURLToPath(new URL('.', import.meta.url)) +const directory = join(root, '.cache/dev') +export default defineConfig({ + root: join(root, 'src'), + cacheDir: join(directory, 'vite'), + envPrefix: [], + server: { + host: 'localhost', + port: 4691, + strictPort: true, + fs: { deny: ['.env', '.env.*', '*.{crt,pem}', '**/.git/**', '**/.cache/**'] }, + }, + build: { outDir: join(directory, 'app'), emptyOutDir: true }, +}) diff --git a/ts/apps/dev/vitest.config.ts b/ts/apps/dev/vitest.config.ts new file mode 100644 index 00000000..537f61ab --- /dev/null +++ b/ts/apps/dev/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config' +export default defineConfig({ test: { include: ['*.test.ts'] } }) diff --git a/ts/biome.json b/ts/biome.json index 86d30a9e..ff6c67ce 100644 --- a/ts/biome.json +++ b/ts/biome.json @@ -7,7 +7,14 @@ "!**/dist", "!**/node_modules", "!apps/demo/public", - "!packages/claim-full/assets" + "!packages/claim-full/assets", + "!packages/ceremony/.cache", + "!packages/ceremony/dist-artifacts", + "!packages/ceremony/test-results", + "!packages/ceremony/playwright-report", + "!apps/dev/.cache", + "!apps/dev/test-results", + "!apps/dev/playwright-report" ] }, "formatter": { diff --git a/ts/package.json b/ts/package.json index 987bad87..8cb3a32c 100644 --- a/ts/package.json +++ b/ts/package.json @@ -7,9 +7,13 @@ "build": "pnpm -r build", "test": "pnpm -r test", "typecheck": "pnpm -r typecheck", - "lint": "biome lint .", + "lint": "biome lint . && biome check --formatter-enabled=false --only=assist/source/organizeImports packages/ceremony", "fmt": "biome format --write .", - "fmt:check": "biome format ." + "fmt:check": "biome format .", + "dev": "pnpm --filter @libid/dev dev", + "dev:services": "pnpm --filter @libid/dev dev:services", + "dev:ccdp": "pnpm --filter @libid/dev dev:ccdp", + "dev:app": "pnpm --filter @libid/dev dev:app" }, "devDependencies": { "@biomejs/biome": "^2.5.8" diff --git a/ts/packages/ceremony/README.md b/ts/packages/ceremony/README.md new file mode 100644 index 00000000..bbd5b710 --- /dev/null +++ b/ts/packages/ceremony/README.md @@ -0,0 +1,40 @@ +# @libid/ceremony + +Browser identity ceremonies for Google, X and GitHub over a caller-supplied +`@libid/popup` connection. The application owns popup lifetime, ledger operations +and submission; ceremony owns OAuth orchestration, proving and popup UI. + +An accepted result has passed structural checks. The ledger verifier remains +authoritative for proof and attestation validity. **Release qualification is +incomplete**; see [evidence and remaining gates](docs/qualification.md). + +## Use and develop + +- [Client guide](docs/client.md): launch, platform/version discovery, results, + events, errors and closure. +- [Shared development app](../../apps/dev/README.md): `pnpm -C ts dev` starts the + frontend, Bridge, notary and CCDP for manual OAuth testing. +- [Build and deployment](docs/distribution.md): artifact commands, resource + declarations, headers, compression and compatible updates. +- [Testing](docs/testing.md): unit, distribution, browser and manual checks. + +## Maintain + +- [Architecture](docs/architecture.md): module map, document lifecycle and boundaries. +- [Platform pipelines](docs/pipelines.md): concurrency and adding a platform/version. +- [Proving](docs/proving.md): Noir/Barretenberg integration and dependency upgrades. +- [Notarization](docs/notarization.md): session lifecycle and evidence handling. +- [Assets](docs/assets.md): prefetch, Service Worker ownership and caches. +- [Measurements](docs/metrics.md): event accounting and telemetry boundaries. +- [Requirement index](docs/test-plan.md) and [traceability](docs/traceability.md): + stable test IDs, existing coverage and outstanding properties. + +## Specifications + +The [CCDP](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md), +[Bridge](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/oauth-bridge.md), +[Distribution](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp-distribution.md), +and [platform](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/platform-ceremonies.md) +specifications own interoperability and proof semantics. Package docs explain +this implementation. [Pending contract updates](docs/qualification.md#pending-contract-updates) +identify the coordinated changes still required. diff --git a/ts/packages/ceremony/build/archive.test.ts b/ts/packages/ceremony/build/archive.test.ts new file mode 100644 index 00000000..7942776d --- /dev/null +++ b/ts/packages/ceremony/build/archive.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { test } from 'node:test' +import { gzipSync } from 'node:zlib' +import { Header } from 'tar' +import { executionWorker } from '../src/ccdp/headers.ts' +import { readArchive, safePath, selectMember } from './archive.ts' +import { assetHeaders, externalRequest, loadAssetCatalog, resolveAssets } from './assets.ts' +import { cache, packageDir } from './release.ts' + +function tar(entries: { path: string; type?: 'File' | 'SymbolicLink' | 'Link'; body?: string }[]) { + const chunks: Buffer[] = [] + for (const entry of entries) { + const body = Buffer.from(entry.body ?? '') + const header = new Header({ + path: entry.path, + type: entry.type ?? 'File', + size: body.length, + mode: 0o644, + linkpath: entry.type ? '../../outside' : undefined, + }) + header.encode() + chunks.push(header.block!, body, Buffer.alloc((512 - (body.length % 512)) % 512)) + } + return gzipSync(Buffer.concat([...chunks, Buffer.alloc(1024)])) +} + +test('safe archives preserve paths and wildcard selectors select exactly once [LIBID-ASSET-024] [LIBID-ASSET-025]', async () => { + mkdirSync(cache, { recursive: true }) + const dir = mkdtempSync(join(cache, 'archive-test-')), + path = join(dir, 'bundle.tar.gz') + try { + writeFileSync( + path, + tar([ + { path: './module.js', body: 'import "./snippets/web-spawn-ab/js/spawn.js"' }, + { path: 'snippets/web-spawn-ab/js/spawn.js', body: 'export {}' }, + { path: 'unselected.json', body: '{}' }, + ]), + ) + const files = await readArchive(path) + assert.equal(files.size, 3) + assert.deepEqual(await readArchive(relative(packageDir, path)), files) + assert.equal( + selectMember(files, 'snippets/web-spawn-*/js/spawn.js'), + 'snippets/web-spawn-ab/js/spawn.js', + ) + assert.throws(() => selectMember(files, 'snippets/*/spawn.js'), /exactly once/) + files.set('snippets/web-spawn-cd/js/spawn.js', Buffer.from('')) + assert.throws(() => selectMember(files, 'snippets/web-spawn-*/js/spawn.js'), /exactly once/) + for (const entries of [ + [{ path: '../escape' }], + [{ path: '/absolute' }], + [{ path: 'a', type: 'Link' as const }], + [{ path: 'a', type: 'SymbolicLink' as const }], + [{ path: 'a' }, { path: 'a' }], + [{ path: 'a' }, { path: 'a/b' }], + ]) { + writeFileSync(path, tar(entries)) + await assert.rejects(readArchive(path)) + } + for (const path of [ + '../a', + '/a', + 'a/../b', + 'a?b', + 'a%2fb', + 'a\\b', + 'a//b', + '[a].js', + '{a,b}.js', + ]) + assert.throws(() => safePath(path)) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('asset resolution publishes only declared files and archive members [LIBID-ASSET-024]', async () => { + const { local, urls } = await resolveAssets() + assert.deepEqual(new Set(local.keys()), new Set(Object.values(urls))) +}) + +test('policy cannot override server metadata or weaken immutable resources [LIBID-ASSET-026]', () => { + for (const name of [ + 'ETag', + 'Last-Modified', + 'Content-Length', + 'Content-Encoding', + 'Content-Range', + ]) + assert.throws(() => assetHeaders('file.js', { [name]: 'x' })) + assert.throws(() => assetHeaders('file.js', { 'Content-Type': 'a', 'content-type': 'b' })) + assert.throws(() => assetHeaders('file.js', { 'Cache-Control': 'no-store' })) + assert.throws(() => assetHeaders('file.wasm', { 'Content-Type': 'text/javascript' })) + assert.throws( + () => + assetHeaders('spawn.js', { + ...executionWorker, + 'Content-Security-Policy': `SCRIPT-SRC *; ${executionWorker['Content-Security-Policy']}`, + }), + /Duplicate CSP directive/, + ) + assert.equal(assetHeaders('file.wasm')['content-type'], 'application/wasm') + assert.throws(() => + assetHeaders('spawn.js', { + 'Content-Security-Policy': 'default-src *; script-src *; worker-src *', + 'Cross-Origin-Embedder-Policy': 'unsafe-none', + }), + ) +}) + +test('external declarations retain exact URL/range and derive size without downloading [LIBID-ASSET-022]', async () => { + const fetch = globalThis.fetch + globalThis.fetch = () => { + throw new Error('Unexpected download') + } + try { + assert.deepEqual( + externalRequest({ source: 'https://cdn.test/g1', isExternal: true, range: 'bytes=0-31' }), + { url: 'https://cdn.test/g1', range: 'bytes=0-31', bytes: 32 }, + ) + assert.throws(() => externalRequest({ source: 'http://cdn.test/x', isExternal: true })) + assert.throws(() => + externalRequest({ source: 'https://cdn.test/x', isExternal: true, range: 'bytes=9-3' }), + ) + const catalog = await loadAssetCatalog() + assert.deepEqual(Object.keys(catalog.assetsByPlatform), ['google', 'x', 'github']) + } finally { + globalThis.fetch = fetch + } +}) diff --git a/ts/packages/ceremony/build/archive.ts b/ts/packages/ceremony/build/archive.ts new file mode 100644 index 00000000..8a92e016 --- /dev/null +++ b/ts/packages/ceremony/build/archive.ts @@ -0,0 +1,80 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { Parser } from 'tar' +import { download, packageDir } from './release.ts' + +export function safePath(path: string, selector = false): string { + if ( + !path || + path.includes('\\') || + /[?#%:[\]{}]/.test(path) || + [...path].some((c) => c.charCodeAt(0) <= 32 || c.charCodeAt(0) === 127) || + path.startsWith('/') || + path.split('/').some((p) => !p || p === '.' || p === '..') || + (!selector && path.includes('*')) || + path.split('/').at(-1)!.includes('*') + ) + throw new Error(`Invalid resource path: ${path}`) + return path +} + +/** Read regular files into memory; archive entries never get filesystem write authority. */ +export async function readArchive(source: string): Promise> { + const bytes = source.startsWith('https:') + ? await download(source) + : readFileSync(resolve(packageDir, source)) + const files = new Map(), + entries = new Set() + await new Promise((resolve, reject) => { + const parser = new Parser({ strict: true }) + parser.on('error', reject) + parser.on('end', resolve) + parser.on('entry', (entry) => { + try { + const path = entry.path.replace(/^(\.\/)+/, '').replace(/\/$/, '') + if (!path && entry.type === 'Directory') { + entry.resume() + return + } + safePath(path) + if (entries.has(path)) throw new Error(`Duplicate archive entry: ${path}`) + entries.add(path) + if (entry.type === 'Directory') { + entry.resume() + return + } + if (entry.type !== 'File' && entry.type !== 'OldFile') + throw new Error(`Unsupported archive entry: ${path}`) + const chunks: Buffer[] = [] + entry.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk))) + entry.on('end', () => files.set(path, Buffer.concat(chunks))) + entry.on('error', reject) + } catch (error) { + parser.abort(error as Error) + } + }) + parser.end(bytes) + }) + for (const path of entries) { + const parts = path.split('/') + for (let i = 1; i < parts.length; i++) + if (files.has(parts.slice(0, i).join('/'))) + throw new Error('Archive file/directory collision') + } + return files +} + +export function selectMember(files: ReadonlyMap, selector: string): string { + safePath(selector, true) + const pattern = new RegExp( + '^' + + selector + .split('*') + .map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('[^/]*') + + '$', + ) + const matches = [...files.keys()].filter((path) => pattern.test(path)) + if (matches.length !== 1) throw new Error(`Archive member must match exactly once: ${selector}`) + return matches[0] +} diff --git a/ts/packages/ceremony/build/asset-plugin.test.ts b/ts/packages/ceremony/build/asset-plugin.test.ts new file mode 100644 index 00000000..c03976fb --- /dev/null +++ b/ts/packages/ceremony/build/asset-plugin.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { test } from 'node:test' +import { build, type Rollup } from 'vite' +import { assetPlugin } from './asset-plugin.ts' +import { packageDir } from './release.ts' + +test('runtime lowering preserves named/chained calls and external request options [LIBID-MOD-021] [LIBID-ASSET-022]', async () => { + const id = join(packageDir, 'src/asset-lowering-fixture.ts') + const result = await build({ + configFile: false, + logLevel: 'silent', + plugins: [ + { + name: 'resource-fixture', + resolveId: (file) => (file === id ? id : undefined), + load: (file) => + file === id + ? ` + import {archive as release,file,external,resolve,headers} from './assets/index.js'; + import * as assets from './assets/index.js'; + const a=release('https://secret-build-source.test/a.tar.gz','a/v1').member('snippets/x-*/worker.js',headers.executionWorker); + const b=assets.archive('https://secret-build-source.test/b.tar.gz','b/v1'); + const c=file('npm:build-only/file.wasm','file/v1.wasm',headers.wasm); + const request=external('https://CDN.test:443/g1',{range:'bytes=0-31',bytes:32,fallback:['https://fallback.test/g1']}); + export const resolved=[resolve(a),resolve(b.member('data.json',headers.json)),resolve(c),resolve(request)]; + export const options={range:request.range,bytes:request.bytes,fallback:request.fallback}; + ` + : undefined, + }, + assetPlugin({ + urls: { + 'a/v1/snippets/x-*/worker.js': '/ccdp/assets/a/v1/snippets/x-123/worker.js', + 'b/v1/data.json': '/ccdp/assets/b/v1/data.json', + 'file/v1.wasm/': '/ccdp/assets/file/v1.wasm', + }, + moduleUrls: {}, + requestsByProfile: {}, + allowedRequests: [], + }), + ], + build: { write: false, minify: false, lib: { entry: id, formats: ['es'] } }, + }) + const code = ((Array.isArray(result) ? result[0] : result) as Rollup.RollupOutput).output.find( + (o) => o.type === 'chunk', + )!.code + assert.doesNotMatch(code, /secret-build-source|npm:build-only|Content-Security-Policy/) + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + Object.defineProperty(globalThis, 'location', { + value: { origin: 'https://ccdp.test' }, + configurable: true, + }) + try { + const runtime = await import( + `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` + ) + assert.deepEqual(runtime.resolved, [ + 'https://ccdp.test/ccdp/assets/a/v1/snippets/x-123/worker.js', + 'https://ccdp.test/ccdp/assets/b/v1/data.json', + 'https://ccdp.test/ccdp/assets/file/v1.wasm', + 'https://CDN.test:443/g1', + ]) + assert.deepEqual(runtime.options, { + range: 'bytes=0-31', + bytes: 32, + fallback: ['https://fallback.test/g1'], + }) + } finally { + if (descriptor) Object.defineProperty(globalThis, 'location', descriptor) + else Reflect.deleteProperty(globalThis, 'location') + } +}) diff --git a/ts/packages/ceremony/build/asset-plugin.ts b/ts/packages/ceremony/build/asset-plugin.ts new file mode 100644 index 00000000..424e94a8 --- /dev/null +++ b/ts/packages/ceremony/build/asset-plugin.ts @@ -0,0 +1,146 @@ +import { dirname, join, resolve } from 'node:path' +import type { Node } from 'estree' +import { type Plugin, transformWithEsbuild } from 'vite' +import type { ResolvedAssets } from './assets.ts' +import { packageDir } from './release.ts' + +export function assetPlugin( + data?: Pick, +): Plugin { + return { + name: 'ceremony-assets', + enforce: 'pre', + async transform(source, id) { + if (!data || !id.endsWith('.ts') || !source.includes('assets/index.js')) return + const code = (await transformWithEsbuild(source, id, { loader: 'ts', target: 'es2022' })).code + const ast = this.parse(code) + const namespaces = new Set(), + bindings = new Map(), + archives = new Set() + for (const node of ast.body) { + if ( + node.type !== 'ImportDeclaration' || + typeof node.source.value !== 'string' || + resolve(dirname(id), node.source.value) !== join(packageDir, 'src/assets/index.js') + ) + continue + for (const spec of node.specifiers) { + if (spec.type === 'ImportNamespaceSpecifier') namespaces.add(spec.local.name) + if (spec.type === 'ImportSpecifier') + bindings.set( + spec.local.name, + spec.imported.type === 'Identifier' + ? spec.imported.name + : String(spec.imported.value), + ) + } + } + if (!namespaces.size && !bindings.size) return + const method = (node: Node): string | undefined => { + if (node.type === 'Identifier') return bindings.get(node.name) + if ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + namespaces.has(node.object.name) && + node.property.type === 'Identifier' + ) + return node.property.name + } + const edits: { start: number; end: number; text: string }[] = [] + const span = (node: Node) => node as Node & { start: number; end: number } + const visit = (value: unknown) => { + if (!value || typeof value !== 'object') return + const node = value as Node + if ( + node.type === 'VariableDeclarator' && + node.id.type === 'Identifier' && + node.init?.type === 'CallExpression' && + method(node.init.callee) === 'archive' + ) + archives.add(node.id.name) + if (node.type === 'CallExpression') { + const name = method(node.callee) + if (name === 'archive' || name === 'file') { + const source = node.arguments[0], + mount = node.arguments[1] + if (!source || !mount) throw new Error('Missing resource source/mount') + edits.push({ start: span(source).start, end: span(source).end, text: 'undefined' }) + if (node.arguments.length > 2) + edits.push({ + start: span(mount).end, + end: span(node.arguments.at(-1)!).end, + text: '', + }) + } + const callee = node.callee + if ( + callee.type === 'MemberExpression' && + callee.property.type === 'Identifier' && + callee.property.name === 'member' && + ((callee.object.type === 'Identifier' && archives.has(callee.object.name)) || + (callee.object.type === 'CallExpression' && + method(callee.object.callee) === 'archive')) && + node.arguments.length > 1 + ) + edits.push({ + start: span(node.arguments[0]).end, + end: span(node.arguments.at(-1)!).end, + text: '', + }) + } + if (node.type === 'ObjectExpression') { + const props = node.properties + for (let i = 0; i < props.length; i++) { + const prop = props[i] + if ( + prop.type === 'Property' && + prop.key.type === 'Identifier' && + prop.key.name === 'bundledUrlModules' + ) + edits.push({ + start: i ? span(props[i - 1]).end : span(prop).start, + end: i ? span(prop).end : props.length > 1 ? span(props[1]).start : span(prop).end, + text: '', + }) + } + } + for (const child of Object.values(node)) { + if (Array.isArray(child)) child.forEach(visit) + else if (child && typeof child === 'object') visit(child) + } + } + visit(ast) + if (!edits.length) return + let result = code + for (const edit of edits.sort((a, b) => b.start - a.start)) + result = result.slice(0, edit.start) + edit.text + result.slice(edit.end) + return { code: result, map: null } + }, + resolveId(id) { + if (id === 'virtual:ceremony-assets') return `\0${id}` + }, + load(id) { + if (data && id === join(packageDir, 'src/assets/index.ts')) + return ` + import {urls} from 'virtual:ceremony-assets'; + export * as headers from ${JSON.stringify(join(packageDir, 'src/ccdp/headers.ts'))}; + export function archive(_source,mount){return {member(member){return {url:urls[mount+'/'+member]}}}} + export function file(_source,mount){return {url:urls[mount+'/']}} + export function external(source,options){return {url:source,isExternal:true,...options}} + export function resolve(asset){if(!asset.url)throw new Error('Missing built asset');return asset.isExternal ? asset.url : new URL(asset.url,location.origin).href} + ` + for (const [module, url] of Object.entries(data?.moduleUrls ?? {})) + if (id.endsWith(`/${module}`)) return `export default ${JSON.stringify(url)};` + if (id === '\0virtual:ceremony-assets') { + const runtime = { + urls: data?.urls ?? {}, + requestsByProfile: data?.requestsByProfile ?? {}, + allowedRequests: data?.allowedRequests ?? [], + } + return Object.entries(runtime) + .map(([k, v]) => `export const ${k}=${JSON.stringify(v)};`) + .join('\n') + } + }, + } +} diff --git a/ts/packages/ceremony/build/assets.ts b/ts/packages/ceremony/build/assets.ts new file mode 100644 index 00000000..f9abde60 --- /dev/null +++ b/ts/packages/ceremony/build/assets.ts @@ -0,0 +1,244 @@ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { gunzipSync } from 'node:zlib' +import type { Rollup } from 'vite' +import { build } from 'vite' +import type { Asset, AssetRequest, ExternalAsset, LocalAsset } from '../src/assets/index.js' +import * as headers from '../src/ccdp/headers.ts' +import { readArchive, safePath, selectMember } from './archive.ts' +import { assetPlugin } from './asset-plugin.ts' +import { validateCircuitCapacity } from './circuits.ts' +import { responseHeaders } from './profiles.ts' +import { download, hash, packageDir } from './release.ts' + +export { assetPlugin } from './asset-plugin.ts' + +const require = createRequire(import.meta.url) + +export async function loadAssetCatalog() { + const result = await build({ + configFile: false, + logLevel: 'silent', + plugins: [assetPlugin()], + build: { + write: false, + minify: false, + lib: { entry: join(packageDir, 'src/platforms/platforms.assets.ts'), formats: ['es'] }, + }, + }) + const output = ((Array.isArray(result) ? result[0] : result) as Rollup.RollupOutput).output + const code = output.find((o) => o.type === 'chunk')! + return (await import( + `data:text/javascript;base64,${Buffer.from(code.code).toString('base64')}` + )) as { + assetsByPlatform: Record> + circuits: readonly LocalAsset[] + SRS_SIZE: number + } +} + +export function mediaType(path: string): string { + return path.endsWith('.js') || path.endsWith('.mjs') + ? headers.javascript['Content-Type'] + : path.endsWith('.wasm') + ? headers.wasm['Content-Type'] + : path.endsWith('.json') + ? headers.json['Content-Type'] + : path.endsWith('.html') + ? 'text/html; charset=utf-8' + : 'application/octet-stream' +} + +export function assetHeaders(path: string, policy: Readonly> = {}) { + const seen = new Set() + for (const [name, value] of Object.entries(policy)) { + const lower = name.toLowerCase() + if ( + seen.has(lower) || + ['etag', 'last-modified', 'content-length', 'content-encoding', 'content-range'].includes( + lower, + ) + ) + throw new Error(`Invalid declared header: ${name}`) + seen.add(lower) + if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(name) || /[\r\n\0]/.test(value)) + throw new Error('Invalid header') + } + const merged = new Headers({ ...headers.immutable, 'Content-Type': mediaType(path) }) + for (const [name, value] of Object.entries(policy)) merged.set(name, value) + for (const [name, value] of Object.entries(headers.immutable)) + if (merged.get(name) !== value) throw new Error(`Asset policy weakened: ${name}`) + if (merged.get('Content-Type') !== mediaType(path)) + throw new Error(`Wrong asset media type: ${path}`) + const policyCsp = merged.get('Content-Security-Policy') + if (policyCsp || merged.has('Cross-Origin-Embedder-Policy')) { + if (!policyCsp || merged.get('Cross-Origin-Embedder-Policy') !== 'require-corp') + throw new Error('Worker isolation policy missing') + const directives = new Map() + for (const clause of policyCsp.split(';').filter((c) => c.trim())) { + const [rawName, ...values] = clause.trim().split(/\s+/) + const name = rawName.toLowerCase() + if (directives.has(name)) throw new Error('Duplicate CSP directive') + directives.set(name, values) + } + for (const name of ['default-src', 'object-src', 'base-uri', 'form-action', 'frame-ancestors']) + if (directives.get(name)?.join(' ') !== "'none'") throw new Error('Worker CSP base weakened') + const scripts = directives.get('script-src') ?? [], + workers = directives.get('worker-src') ?? [] + if ( + !scripts.includes("'self'") || + !scripts.includes("'wasm-unsafe-eval'") || + scripts.some((s) => !["'self'", "'wasm-unsafe-eval'"].includes(s)) || + !workers.length || + workers.some((s) => !["'none'", "'self'", 'blob:'].includes(s)) || + (workers.includes("'none'") && workers.length !== 1) + ) + throw new Error('Worker code policy weakened') + } + return Object.fromEntries(merged) +} + +export function externalRequest(asset: ExternalAsset): AssetRequest { + for (const source of [asset.source, ...(asset.fallback ?? [])]) { + const url = new URL(source) + if (url.protocol !== 'https:' || url.username || url.password || url.hash) + throw new Error('Invalid external URL') + } + let bytes = asset.bytes + if (asset.range) { + const match = /^bytes=(0|[1-9][0-9]*)-(0|[1-9][0-9]*)$/.exec(asset.range) + if (!match) throw new Error('Invalid external range') + const size = Number(match[2]) - Number(match[1]) + 1 + if ( + !Number.isSafeInteger(Number(match[2])) || + size <= 0 || + (bytes !== undefined && bytes !== size) + ) + throw new Error('Invalid external range size') + bytes = size + } + if (bytes !== undefined && (!Number.isSafeInteger(bytes) || bytes <= 0)) + throw new Error('Invalid external size') + return { url: asset.source, range: asset.range, bytes } +} + +function installedFile(source: string): Buffer { + const path = source.slice(4), + parts = path.split('/'), + pkg = path.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] + let directory = dirname(require.resolve(pkg)) + while ( + !existsSync(join(directory, 'package.json')) || + JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')).name !== pkg + ) { + const parent = dirname(directory) + if (parent === directory) throw new Error('Package root missing') + directory = parent + } + return readFileSync(join(directory, safePath(path.slice(pkg.length + 1)))) +} + +export async function resolveAssets() { + const catalog = await loadAssetCatalog() + const profiles = Object.fromEntries( + Object.entries(catalog.assetsByPlatform).flatMap(([p, vs]) => + Object.entries(vs).map(([v, as]) => [`${p}/${v}`, as]), + ), + ) + const declarations = Object.values(profiles).flat() + const archives = new Map>>() + const urls: Record = {}, + moduleUrls: Record = {}, + bodyHashes: Record = {}, + sizes: Record = {} + const local = new Map }>() + const selections = new Map(), + mounts = new Map() + const register = (path: string, bytes: Buffer, policy: Record) => { + // WASM resources have decoded bodies; HTTP compression belongs to the static server. + if (path.endsWith('.wasm') && bytes[0] === 0x1f && bytes[1] === 0x8b) bytes = gunzipSync(bytes) + const old = local.get(path) + if (old && !old.bytes.equals(bytes)) throw new Error(`Conflicting asset body: ${path}`) + local.set(path, { bytes, headers: policy }) + } + for (const asset of declarations) { + if (asset.isExternal) { + externalRequest(asset) + continue + } + safePath(asset.mount) + const key = `${asset.mount}/${asset.member ?? ''}` + let path: string, bytes: Buffer + if (asset.member !== undefined) { + if (mounts.has(asset.mount) && mounts.get(asset.mount) !== asset.source) + throw new Error('Conflicting archive mount') + const first = !mounts.has(asset.mount) + if ( + first && + [...mounts.keys()].some( + (mount) => mount.startsWith(`${asset.mount}/`) || asset.mount.startsWith(`${mount}/`), + ) + ) + throw new Error('Overlapping archive mounts') + mounts.set(asset.mount, asset.source) + let archive = archives.get(asset.source) + if (!archive) { + archive = readArchive(asset.source) + archives.set(asset.source, archive) + } + const files = await archive + const member = selectMember(files, asset.member) + path = `/ccdp/assets/${asset.mount}/${member}` + bytes = files.get(member)! + } else { + path = `/ccdp/assets/${asset.mount}` + bytes = asset.source.startsWith('npm:') + ? installedFile(asset.source) + : asset.source.startsWith('https:') + ? await download(asset.source) + : readFileSync(resolve(packageDir, asset.source)) + } + const policy = assetHeaders(path, asset.headers), + signature = JSON.stringify(policy) + if (selections.has(path) && selections.get(path) !== signature) + throw new Error(`Conflicting asset policy: ${path}`) + selections.set(path, signature) + register(path, bytes, policy) + urls[key] = path + for (const module of asset.bundledUrlModules ?? []) moduleUrls[module] = path + } + const circuits = new Map( + catalog.circuits.map((asset) => { + const path = urls[`${asset.mount}/${asset.member ?? ''}`] + return [asset.member!.replace(/\.json$/, ''), local.get(path)!.bytes] + }), + ) + await validateCircuitCapacity(circuits, catalog.SRS_SIZE) + for (const [path, { bytes }] of local) { + bodyHashes[path] = hash(bytes) + sizes[path] = bytes.length + } + // Bundled code changes URL when its execution policy changes, even if its code does not. + const policyId = hash( + JSON.stringify( + ['executionWorker', 'proofWorker', 'leafWorker'].map((p) => + responseHeaders(p as 'executionWorker', {}), + ), + ), + ).slice(0, 12) + return { + policyId, + urls, + moduleUrls, + profiles, + local, + bodyHashes, + sizes, + hashBody: hash, + requestsByProfile: {} as Record, + allowedRequests: [] as AssetRequest[], + } +} + +export type ResolvedAssets = Awaited> diff --git a/ts/packages/ceremony/build/bundle.ts b/ts/packages/ceremony/build/bundle.ts new file mode 100644 index 00000000..3708587b --- /dev/null +++ b/ts/packages/ceremony/build/bundle.ts @@ -0,0 +1,239 @@ +import { dirname, join, posix } from 'node:path' +import type { Node } from 'estree' +import type { ChunkMetadata, Plugin, Rollup } from 'vite' +import { build, transformWithEsbuild } from 'vite' +import type { ResolvedAssets } from './assets.ts' +import { assetPlugin } from './assets.ts' +import { popupPlugin } from './popup.ts' +import { packageDir } from './release.ts' + +type Edit = readonly [start: number, end: number, replacement: string] + +// Rollup supplies offsets on every parsed node; ESTree's base types omit them. +function replacement(node: Node, text: string): Edit { + const { start, end } = node as Node & { start: number; end: number } + return [start, end, text] +} + +export type BundleNode = { + entry: string | null + modules: string[] + dependencies: string[] +} + +type ViteChunk = Rollup.OutputChunk & { viteMetadata?: ChunkMetadata } + +/** Compiler AST rewriting keeps inline module imports rooted at the distribution. */ +function absoluteImports(): Plugin { + return { + name: 'ceremony-absolute-imports', + renderChunk(code, chunk) { + const edits: Edit[] = [] + const walk = (value: unknown) => { + if (!value || typeof value !== 'object') return + const node = value as Node + if ( + (node.type === 'ImportDeclaration' || + node.type === 'ExportNamedDeclaration' || + node.type === 'ExportAllDeclaration' || + node.type === 'ImportExpression') && + node.source?.type === 'Literal' && + typeof node.source.value === 'string' && + /^\.\.?\//.test(node.source.value) + ) + edits.push( + replacement( + node.source, + JSON.stringify( + `/${posix.normalize(posix.join(posix.dirname(chunk.fileName), node.source.value))}`, + ), + ), + ) + for (const v of Object.values(node)) + if (Array.isArray(v)) v.forEach(walk) + else if (v && typeof v === 'object') walk(v) + } + walk(this.parse(code)) + for (const [start, end, text] of edits.sort((a, b) => b[0] - a[0])) + code = code.slice(0, start) + text + code.slice(end) + return { code, map: null } + }, + } +} + +/** Make native Worker URL dependencies ordinary bundler edges, including dependency workers. */ +function workerImports(): Plugin { + return { + name: 'ceremony-worker-imports', + enforce: 'pre', + async transform(source, id) { + if (!source.includes('Worker') || id.includes('?')) return + const code = id.endsWith('.ts') + ? (await transformWithEsbuild(source, id, { loader: 'ts', target: 'es2022' })).code + : source + let ast: ReturnType + try { + ast = this.parse(code) + } catch { + return + } + const edits: Edit[] = [], + imports: string[] = [] + const walk = (value: unknown) => { + if (!value || typeof value !== 'object') return + const node = value as Node + if ( + node.type === 'NewExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'Worker' + ) { + const url = node.arguments[0] + if ( + url?.type === 'NewExpression' && + url.callee.type === 'Identifier' && + url.callee.name === 'URL' && + url.arguments[0]?.type === 'Literal' && + typeof url.arguments[0].value === 'string' && + url.arguments[1]?.type === 'MemberExpression' && + url.arguments[1].object?.type === 'MetaProperty' + ) { + const name = `__ceremonyWorker${imports.length}` + imports.push( + `import ${name} from ${JSON.stringify(`${join(dirname(id), url.arguments[0].value)}?worker&url`)};`, + ) + edits.push(replacement(url, name)) + } + } + for (const v of Object.values(node)) + if (Array.isArray(v)) v.forEach(walk) + else if (v && typeof v === 'object') walk(v) + } + walk(ast) + if (!edits.length) return + let result = code + for (const [start, end, value] of edits.sort((a, b) => b[0] - a[0])) + result = result.slice(0, start) + value + result.slice(end) + return { code: `${imports.join('\n')}\n${result}`, map: null } + }, + } +} + +export async function bundle( + entry: string, + data: ResolvedAssets, + { + selfContained = false, + invoke, + groupModules = true, + }: { selfContained?: boolean; invoke?: string; groupModules?: boolean } = {}, +) { + const graph = new Map(), + workerFiles = new Set() + const record = (worker: boolean): Plugin => ({ + name: 'ceremony-emitted-graph', + generateBundle(_, output) { + for (const item of Object.values(output)) { + if (worker) workerFiles.add(item.fileName) + if (item.type === 'chunk') + graph.set(item.fileName, { + entry: item.facadeModuleId, + modules: Object.keys(item.modules), + dependencies: [ + ...item.imports, + ...item.dynamicImports, + ...item.referencedFiles, + ...((item as ViteChunk).viteMetadata?.importedAssets ?? []), + ], + }) + } + }, + }) + const entryPlugin: Plugin = { + name: 'ceremony-entry', + resolveId(id) { + if (id === 'virtual:ceremony-entry') return `\0${id}` + }, + load(id) { + if (id === '\0virtual:ceremony-entry' && selfContained) + return `import {${invoke}} from ${JSON.stringify(join(packageDir, entry))};${invoke}()` + if (id === '\0virtual:ceremony-entry') + return `import {${invoke}} from ${JSON.stringify(join(packageDir, entry))};if(typeof window!=='undefined'&&Object.hasOwn(window,'__libidCeremonyInput')){const fragment=window.__libidCeremonyInput;delete window.__libidCeremonyInput;void ${invoke}(fragment)}` + }, + } + const plugins = (worker: boolean) => [ + workerImports(), + entryPlugin, + popupPlugin(), + assetPlugin(data), + absoluteImports(), + record(worker), + ] + const assetName = (asset: Rollup.PreRenderedAsset) => { + const owned = Object.entries(data.bodyHashes ?? {}).find( + ([, hash]) => hash === data.hashBody?.(asset.source), + ) + return owned ? owned[0].slice(1) : `ccdp/assets/${data.policyId}/[name]-[hash][extname]` + } + const result = await build({ + configFile: false, + root: packageDir, + base: '/', + logLevel: 'warn', + plugins: plugins(false), + worker: { + format: 'es', + plugins: () => plugins(true), + rollupOptions: { + output: { + entryFileNames: `ccdp/assets/${data.policyId}/[name]-[hash].js`, + chunkFileNames: `ccdp/assets/${data.policyId}/[name]-[hash].js`, + assetFileNames: assetName, + }, + }, + }, + build: { + write: false, + minify: true, + target: 'es2022', + assetsInlineLimit: 0, + modulePreload: false, + rollupOptions: { + input: invoke ? 'virtual:ceremony-entry' : join(packageDir, entry), + preserveEntrySignatures: 'strict', + output: { + inlineDynamicImports: selfContained, + entryFileNames: `ccdp/assets/${data.policyId}/[name]-[hash].js`, + chunkFileNames: `ccdp/assets/${data.policyId}/[name]-[hash].js`, + assetFileNames: assetName, + manualChunks: + selfContained || !groupModules + ? undefined + : (id) => { + if (id.includes('/src/barretenberg/')) return 'proof-engine' + if (id.includes('/src/notary/')) return 'notary' + if ( + id.includes('/src/') && + !id.includes('/platforms/') && + !id.includes('/src/ccdp/documents/prover.ts') && + !id.includes('/popup/') + ) + return 'shared' + }, + }, + }, + }, + }) + for (const node of graph.values()) + for (const module of node.modules) { + if (module.endsWith('?worker&url')) { + const child = [...graph.entries()].find(([, v]) => v.entry === module.slice(0, -11)) + if (!child) throw new Error(`Missing worker graph entry: ${module}`) + node.dependencies.push(child[0]) + } + } + return { + output: ((Array.isArray(result) ? result[0] : result) as Rollup.RollupOutput).output, + graph, + workerFiles, + } +} diff --git a/ts/packages/ceremony/build/circuits.test.ts b/ts/packages/ceremony/build/circuits.test.ts new file mode 100644 index 00000000..04722794 --- /dev/null +++ b/ts/packages/ceremony/build/circuits.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readArchive } from './archive.ts' +import { loadAssetCatalog } from './assets.ts' +import { validateCircuitCapacity } from './circuits.ts' + +test('released circuit statistics fit the fixed launch SRS [LIBID-ASSET-013]', async () => { + const catalog = await loadAssetCatalog() + const releases = new Map( + await Promise.all( + catalog.circuits.map( + async (asset) => + [ + asset.member!.replace(/\.json$/, ''), + (await readArchive(asset.source)).get(asset.member!)!, + ] as const, + ), + ), + ) + const bearer = releases.get('bearer_link')! + const stats = await validateCircuitCapacity(releases, 2 ** 18) + assert.deepEqual(stats, { + bearer_link: { gates: 42006, dyadic: 2 ** 16 }, + oidc_google: { gates: 179443, dyadic: 2 ** 18 }, + }) + await assert.rejects(validateCircuitCapacity(releases, 2 ** 17), /exceeds/) + await assert.rejects( + validateCircuitCapacity(new Map([['bearer_link', bearer]]), 2 ** 16), + /loader floor/, + ) + await validateCircuitCapacity(new Map([['bearer_link', bearer]]), 2 ** 17) +}) diff --git a/ts/packages/ceremony/build/circuits.ts b/ts/packages/ceremony/build/circuits.ts new file mode 100644 index 00000000..7c485832 --- /dev/null +++ b/ts/packages/ceremony/build/circuits.ts @@ -0,0 +1,42 @@ +import { gunzipSync } from 'node:zlib' +import { BackendType, Barretenberg } from '@aztec/bb.js' + +/** Build-time circuit statistics use the pinned EVM proof settings, without SRS downloads. */ +export async function validateCircuitCapacity( + releases: ReadonlyMap, + srsPoints: number, +) { + if ( + !Number.isSafeInteger(srsPoints) || + srsPoints < 2 ** 17 || + (srsPoints * 32) % (4 * 1024 * 1024) !== 0 + ) + throw new Error('SRS does not satisfy the pinned browser loader floor') + const api = await Barretenberg.new({ backend: BackendType.Wasm, threads: 1, skipSrsInit: true }) + const stats: Record = {} + try { + for (const [name, bytes] of releases) { + const circuit = JSON.parse(bytes.toString('utf8')) as { bytecode: string } + const result = await api.circuitStats({ + circuit: { + name, + bytecode: gunzipSync(Buffer.from(circuit.bytecode, 'base64')), + verificationKey: new Uint8Array(), + }, + includeGatesPerOpcode: false, + settings: { + ipaAccumulation: false, + oracleHashType: 'keccak', + disableZk: false, + optimizedSolidityVerifier: false, + }, + }) + if (result.numGatesDyadic > srsPoints) + throw new Error(`Circuit exceeds the launch SRS: ${name}`) + stats[name] = { gates: result.numGates, dyadic: result.numGatesDyadic } + } + return stats + } finally { + await api.destroy() + } +} diff --git a/ts/packages/ceremony/build/distribution.test.ts b/ts/packages/ceremony/build/distribution.test.ts new file mode 100644 index 00000000..0bc41d4b --- /dev/null +++ b/ts/packages/ceremony/build/distribution.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import { test } from 'node:test' +import { brotliDecompressSync, gunzipSync } from 'node:zlib' +import { parse, type TomlTable } from 'smol-toml' +import type { DistributionMetadata } from './distribution.ts' +import { packageDir } from './release.ts' +import { errorHeaders } from './sws.ts' + +const out = process.env.CEREMONY_ARTIFACT_DIR ?? join(packageDir, 'dist-artifacts'), + graph: DistributionMetadata = JSON.parse( + readFileSync(join(out, 'distribution-graph.json'), 'utf8'), + ) + +test('static artifact has complete bodies, immutable policies, exact subsets and valid sidecars [LIBID-ASSET-001] [LIBID-ASSET-023]', () => { + const config = parse(readFileSync(join(out, 'sws.toml'), 'utf8')) + assert.equal((config.general as TomlTable)['text-charset'], false) + assert.equal(Object.hasOwn(config.general as object, 'port'), false) + assert.equal((config.general as TomlTable).health, true) + assert.equal((config.general as TomlTable)['redirect-trailing-slash'], true) + // The error policy catch-all first, then exactly one exact rule per file with its declared headers. + const [first, ...exact] = (config.advanced as TomlTable).headers as { + source: string + headers: unknown + }[] + assert.deepEqual(first, { source: '/**', headers: errorHeaders }) + for (const rule of exact) assert.doesNotMatch(rule.source, /[*?[\]{}]/, rule.source) + assert.deepEqual( + new Map(exact.map((rule) => [rule.source, rule.headers])), + new Map(Object.entries(graph.files).map(([path, physical]) => [physical, graph.headers[path]])), + ) + assert.equal(exact.length, Object.keys(graph.files).length) + assert.deepEqual(graph.headers['/404.html'], { + 'Content-Type': 'text/html; charset=utf-8', + ...errorHeaders, + }) + for (const [path, headers] of Object.entries(graph.headers)) { + const physical = graph.files[path], + body = readFileSync(join(out, 'public', physical)) + for (const name of [ + 'etag', + 'last-modified', + 'content-length', + 'content-encoding', + 'content-range', + ]) + assert.equal(new Headers(headers).has(name), false) + for (const [extension, decode] of [ + ['br', brotliDecompressSync], + ['gz', gunzipSync], + ] as const) { + const sidecar = join(out, 'public', `${physical}.${extension}`) + if (existsSync(sidecar)) assert.deepEqual(decode(readFileSync(sidecar)), body) + } + } + const google = graph.requestsByProfile['google/1'], + x = graph.requestsByProfile['x/1'], + github = graph.requestsByProfile['github/1'] + for (const [list, name] of [ + [google, 'oidc-google'], + [x, 'bearer-link'], + [github, 'bearer-link'], + ] as const) { + const keys = list.filter((r) => r.url.endsWith('/vk')) + assert.equal(keys.length, 1) + assert.ok(keys[0].url.endsWith(`/${name}/vk`)) + assert.equal(keys[0].bytes, 1888) + } + assert.deepEqual( + x.filter((r) => r.url.endsWith('/vk')), + github.filter((r) => r.url.endsWith('/vk')), + ) + const wasm = google.find((r) => r.url.endsWith('/barretenberg-threads.wasm')) + assert.ok(wasm) + assert.equal(wasm.mime, 'application/wasm') + for (const list of [x, github]) + assert.deepEqual( + list.filter((r) => r.url.endsWith('/barretenberg-threads.wasm')), + [wasm], + ) + for (const extension of ['br', 'gz']) + assert.ok(existsSync(join(out, 'public', `${wasm.url}.${extension}`))) + assert.ok(!google.some((r) => r.url.includes('tlsn'))) + assert.ok(x.some((r) => r.url.endsWith('/tlsn_wasm.js'))) + assert.ok(!google.some((r) => r.url.endsWith('/bearer_link.json'))) + assert.ok(!x.some((r) => r.url.endsWith('/oidc_google.json'))) + assert.deepEqual( + x.filter((r) => r.url.startsWith('https:')), + github.filter((r) => r.url.startsWith('https:')), + ) + for (const list of Object.values(graph.requestsByProfile)) { + assert.equal(new Set(list.map((r) => `${r.url}\n${r.range ?? ''}`)).size, list.length) + for (const request of list.filter((r) => r.url.startsWith('/'))) + assert.equal(readFileSync(join(out, 'public', request.url)).length, request.bytes) + } + assert.equal(existsSync(join(out, 'public/manifest.json')), false) +}) + +test('actual SWS exact-route HTTP policies [CSP-001] [CSP-018]', { + skip: !process.env.CEREMONY_SWS_URL, +}, async () => { + for (const [path, expected] of Object.entries(graph.headers)) { + const physical = graph.files[path] + // A document's route and its physical `.html` file answer alike; nothing declared redirects. + for (const request of new Set([path, physical])) { + const response = await fetch(process.env.CEREMONY_SWS_URL + request, { + headers: { 'Accept-Encoding': 'br' }, + }) + assert.equal(response.status, 200, request) + assert.equal(response.redirected, false, request) + for (const [key, value] of Object.entries(expected)) + assert.equal(response.headers.get(key), value, `${request} ${key}`) + assert.deepEqual( + Buffer.from(await response.arrayBuffer()), + readFileSync(join(out, 'public', physical)), + request, + ) + } + } +}) + +test('actual SWS answers the health probe and serves every 404 with the error policy [KIT-001A]', { + skip: !process.env.CEREMONY_SWS_URL, +}, async () => { + const url = process.env.CEREMONY_SWS_URL + const health = await fetch(`${url}/health`) + assert.equal(health.status, 200) + // Error responses are matched on the raw request path, where only the catch-all applies: every + // 404 carries exactly the error policy and no validator. `/` is the form a per-file + // rule must never be keyed on; `/` must not serve the base image's placeholder index. + const asset = Object.keys(graph.headers).find((p) => /^\/ccdp\/assets\/.*\.js$/.test(p))! + for (const path of [ + '/', + '/nope', + '/ccdp/v99/prover', + '/ccdp/assets/', + '/ccdp/assets/does/not/exist.js', + `${asset}/${basename(asset)}`, + '/ccdp/v1/prefetch.html/prefetch.html', + '/ccdp/v1/prefetch/prefetch.html', + '/404.html/404.html', + ]) { + const missing = await fetch(url + path) + assert.equal(missing.status, 404, path) + assert.equal(missing.redirected, false, path) + assert.match(missing.headers.get('content-type') ?? '', /^text\/html/, path) + for (const [name, value] of Object.entries(errorHeaders)) + assert.equal(missing.headers.get(name), value, `${path} ${name}`) + for (const name of ['etag', 'last-modified', 'expires', 'content-encoding']) + assert.equal(missing.headers.get(name), null, `${path} ${name}`) + } + // The one redirect SWS issues: a directory path to its slash form, which is the same 404. + const directory = await fetch(`${url}/ccdp/assets`, { redirect: 'manual' }) + assert.equal(directory.status, 308) + assert.equal(directory.headers.get('location'), '/ccdp/assets/') + assert.equal(directory.headers.get('cache-control'), errorHeaders['Cache-Control']) +}) + +test('aggregate Callback insertion preserves executable hashes and rejects malformed artifacts [KIT-009] [KIT-010] [CSP-007]', async () => { + const { prepareCallback } = await import('../e2e/callback.ts') + const path = '/ccdp/callback.html' + const html = readFileSync(join(out, 'public', path), 'utf8') + const headers = graph.headers[path] + const a = prepareCallback(html, headers, [ + ['https://app.test', 'https://ccdp.test'], + 'https://ccdp.test', + ]) + const b = prepareCallback(html, headers, [ + ['https://other.test', 'https://ccdp.test'], + 'https://ccdp.test', + { hostile: '$&' }, + ]) + assert.equal(a.headers['Content-Security-Policy'], b.headers['Content-Security-Policy']) + assert.ok(b.body.includes('\\u003c/script>')) + assert.ok(b.body.includes('$&')) + assert.equal(a.headers['Cache-Control'], 'no-store') + assert.equal(new Headers(a.headers).has('ETag'), false) + assert.equal(new Headers(a.headers).has('Content-Encoding'), false) + assert.equal(new Headers(a.headers).has('Access-Control-Allow-Origin'), false) + assert.equal(headers['Cache-Control'], 'no-cache') + for (const broken of [ + html.replace('__LIBID_CALLBACK_CONFIG__', ''), + `${html}__LIBID_CALLBACK_CONFIG__`, + `${html}`, + html.replace('type="module">', 'type="module">void 0;'), + ]) + assert.throws(() => + prepareCallback(broken, headers, [ + ['https://app.test', 'https://ccdp.test'], + 'https://ccdp.test', + ]), + ) + assert.throws(() => + prepareCallback(html, { ...headers, 'Content-Security-Policy': "script-src 'self'" }, [ + ['https://app.test'], + 'https://ccdp.test', + ]), + ) + assert.equal(Object.hasOwn(graph.headers, '/ccdp/v1/callback.js'), false) +}) + +test('CCDP contains no ledger implementation or build-time notary mapping [LIBID-ASSET-003]', () => { + const modules = Object.values(graph.graph).flatMap((node) => node.modules) + assert.ok(!modules.some((path) => /\/ledger\//.test(path))) + assert.equal(Object.hasOwn(graph, 'ledgerFixture'), false) + for (const [path, headers] of Object.entries(graph.headers)) { + const policy = headers['Content-Security-Policy'] ?? '' + // Prior immutable responses remain available for already-open documents. + if (!path.startsWith('/ccdp/assets/') || Object.hasOwn(graph.graph, path.slice(1))) + assert.ok(!policy.includes('notary.lib.id'), path) + if (path === '/ccdp/v1/prover' || path === '/ccdp/v1/prover/fallback') { + const sources = policy.split('connect-src ')[1].split(';')[0].trim().split(/\s+/) + for (const source of ["'self'", 'https:', 'wss:', 'ws://localhost:*', 'ws://127.0.0.1:*']) + assert.ok(sources.includes(source), path) + assert.ok( + !sources.some((source) => source.startsWith('http:')) && !sources.includes('ws:'), + path, + ) + } + } +}) + +test('native SWS negotiates representations, HEAD, conditional requests and ranges [LIBID-ASSET-026] [LIBID-ASSET-016] [KIT-001B]', { + skip: !process.env.CEREMONY_SWS_URL, +}, async () => { + const { request } = await import('node:http') + const raw = (path: string, headers: Record, method = 'GET') => + new Promise<{ status: number; headers: import('node:http').IncomingHttpHeaders; body: Buffer }>( + (resolve, reject) => { + const req = request(process.env.CEREMONY_SWS_URL + path, { headers, method }, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk) => chunks.push(chunk)) + res.on('end', () => + resolve({ status: res.statusCode!, headers: res.headers, body: Buffer.concat(chunks) }), + ) + res.on('error', reject) + }) + req.on('error', reject) + req.end() + }, + ) + for (const path of [ + '/ccdp/v1/prefetch', + '/ccdp/v1/prover', + '/ccdp/v1/prover/fallback', + '/ccdp/v1/worker.js', + graph.requestsByProfile['google/1'].find((r) => r.url.endsWith('/barretenberg-threads.wasm'))! + .url, + ...Object.keys(graph.headers) + .filter((p) => /\.(js|wasm|json)$/.test(p) && p.startsWith('/ccdp/assets/')) + .slice(0, 6), + ]) { + const original = readFileSync(join(out, 'public', graph.files[path])) + for (const encoding of ['identity', 'br', 'gzip']) { + const response = await raw(path, { 'Accept-Encoding': encoding }) + assert.equal(response.status, 200) + assert.equal(response.headers.location, undefined) + const sidecar = join(out, 'public', `${graph.files[path]}.${encoding === 'br' ? 'br' : 'gz'}`) + const compressed = encoding !== 'identity' && existsSync(sidecar) + assert.equal(response.headers['content-encoding'], compressed ? encoding : undefined) + if (response.headers['content-length'] !== undefined) + assert.equal(Number(response.headers['content-length']), response.body.length) + else assert.equal(response.headers['transfer-encoding'], 'chunked') + assert.deepEqual( + compressed + ? (encoding === 'br' ? brotliDecompressSync : gunzipSync)(response.body) + : response.body, + original, + ) + assert.ok(response.headers.etag) + assert.ok(response.headers['last-modified']) + if (compressed) assert.match(String(response.headers.vary), /Accept-Encoding/i) + const head = await raw(path, { 'Accept-Encoding': encoding }, 'HEAD') + assert.equal(head.status, 200) + assert.equal(head.body.length, 0) + assert.equal(head.headers['content-length'], response.headers['content-length']) + assert.equal(head.headers['content-encoding'], response.headers['content-encoding']) + const conditional = await raw(path, { + 'Accept-Encoding': encoding, + 'If-None-Match': response.headers.etag!, + }) + assert.equal(conditional.status, 304) + assert.equal(conditional.body.length, 0) + const range = await raw(path, { 'Accept-Encoding': encoding, Range: 'bytes=0-15' }) + assert.equal(range.status, 206) + if (range.headers['content-length'] !== undefined) + assert.equal(Number(range.headers['content-length']), 16) + assert.equal(range.body.length, 16) + assert.equal(range.headers['content-range'], `bytes 0-15/${response.body.length}`) + assert.deepEqual(range.body, response.body.subarray(0, 16)) + } + } +}) + +test('browser graph contains resolved locations only [LIBID-MOD-021]', () => { + for (const path of Object.keys(graph.graph)) { + const file = join(out, 'public', path) + if (!existsSync(file)) continue // Embedded protocol entries have no separate script resource. + const code = readFileSync(file, 'utf8') + assert.doesNotMatch( + code, + /libid-circuits-0\.3\.0|tlsn-wasm-0\.3\.0-rc\.1\.tar|npm:@noir|npm:@aztec/, + ) + } +}) diff --git a/ts/packages/ceremony/build/distribution.ts b/ts/packages/ceremony/build/distribution.ts new file mode 100644 index 00000000..50cd59fd --- /dev/null +++ b/ts/packages/ceremony/build/distribution.ts @@ -0,0 +1,226 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import type { Rollup } from 'vite' +import type { AssetRequest } from '../src/assets/index.js' +import { messages } from '../src/ccdp/ui-messages.ts' +import { safePath } from './archive.ts' +import type { ResolvedAssets } from './assets.ts' +import { assetHeaders, externalRequest, mediaType, resolveAssets } from './assets.ts' +import type { BundleNode } from './bundle.ts' +import { bundle } from './bundle.ts' +import type { ResponseProfile } from './profiles.ts' +import { responseHeaders } from './profiles.ts' +import { packageDir } from './release.ts' +import { errorHeaders, writeDistribution } from './sws.ts' + +export type DistributionMetadata = Pick & { + headers: Record> + graph: Record + files: Record +} + +const index = process.argv.indexOf('--out-dir'), + out = resolve(index < 0 ? join(packageDir, 'dist-artifacts') : process.argv[index + 1]) + +if (out === packageDir || !out.startsWith(`${resolve(packageDir, '../../..')}/`)) + throw new Error('Output must be a dedicated directory inside this worktree') + +const staging = `${out}.building` + +if (existsSync(staging)) throw new Error('Build staging directory already exists') + +mkdirSync(join(staging, 'public'), { recursive: true }) + +try { + const data = await resolveAssets(), + records = new Map }>(), + workerFiles = new Set() + const options = { + externalOrigins: [ + ...new Set( + Object.values(data.profiles) + .flat() + .filter((a) => a.isExternal === true) + .flatMap((a) => [a.source, ...(a.fallback ?? [])].map((u) => new URL(u).origin)), + ), + ], + } + const put = ( + path: string, + body: string | Uint8Array, + profile: ResponseProfile | Record, + headers: Record = {}, + ) => { + const bytes = Buffer.from(body), + old = records.get(path) + if (old && !old.bytes.equals(bytes)) throw new Error(`Conflicting output: ${path}`) + records.set(path, { + bytes, + headers: { + ...(typeof profile === 'string' ? responseHeaders(profile, options) : profile), + ...(profile === 'asset' ? { 'Content-Type': mediaType(path) } : {}), + ...headers, + }, + }) + } + for (const [path, record] of data.local) put(path, record.bytes, record.headers) + const emitted = await bundle('src/ccdp/documents/prover.ts', data, { invoke: 'startProver' }) + for (const path of emitted.workerFiles) workerFiles.add(path) + const graph = emitted.graph + const workerProfile = (file: string): ResponseProfile => { + const modules = graph.get(file)?.modules ?? [] + if (modules.some((m) => m.endsWith('/notary/session.worker.ts'))) return 'executionWorker' + return modules.some((m) => m.endsWith('?worker&url')) ? 'proofWorker' : 'leafWorker' + } + for (const item of emitted.output) { + if (item.type !== 'chunk' || !item.isEntry) + put( + `/${item.fileName}`, + item.type === 'chunk' ? item.code : item.source, + workerFiles.has(item.fileName) && item.fileName.endsWith('.js') + ? workerProfile(item.fileName) + : 'asset', + ) + } + const walk = (file: string, set = new Set()): Set => { + if (set.has(file)) return set + set.add(file) + for (const next of graph.get(file)?.dependencies ?? []) walk(next.replace(/^\//, ''), set) + return set + } + for (const [profile, assets] of Object.entries(data.profiles)) { + const platform = profile.split('/')[0], + entry = [...graph.entries()].find(([, v]) => + v.entry?.endsWith(`/platforms/${platform}/1/prover.ts`), + )?.[0] + if (!entry) throw new Error(`Missing emitted platform entry: ${profile}`) + const requests: AssetRequest[] = assets.map((a) => + a.isExternal + ? externalRequest(a) + : { + url: data.urls[`${a.mount}/${a.member ?? ''}`], + bytes: data.sizes[data.urls[`${a.mount}/${a.member ?? ''}`]], + mime: new Headers(records.get(data.urls[`${a.mount}/${a.member ?? ''}`])!.headers) + .get('Content-Type')! + .split(';')[0], + }, + ) + for (const file of walk(entry)) { + const record = records.get(`/${file}`) + if (!record) throw new Error(`Unindexed dependency: ${file}`) + requests.push({ + url: `/${file}`, + bytes: record.bytes.length, + mime: record.headers['Content-Type'].split(';')[0], + }) + } + data.requestsByProfile[profile] = [ + ...new Map(requests.map((r) => [`${r.url}\n${r.range ?? ''}`, r])).values(), + ] + } + data.allowedRequests = [ + ...new Map( + [ + ...Object.values(data.requestsByProfile).flat(), + ...Object.values(data.profiles) + .flat() + .filter((a) => a.isExternal === true) + .flatMap((a) => (a.fallback ?? []).map((url) => ({ ...externalRequest(a), url }))), + ].map((r) => [`${r.url}\n${r.range ?? ''}`, r]), + ).values(), + ] + const primary = emitted.output.find( + (o): o is Rollup.OutputChunk => o.type === 'chunk' && o.isEntry, + ) + if (!primary) throw new Error('Missing Prover entry') + const document = (path: string, code: string, profile: ResponseProfile) => { + const capture = `(()=>{const query=location.search,fragment=location.hash,path=location.pathname;history.replaceState(null,'',path);if(query||path!==${JSON.stringify(path)}||fragment.length>65536){document.getElementById('libid-root').textContent=${JSON.stringify(messages.returnToApplication(messages.unableToContinue))};return}Object.defineProperty(window,'__libidCeremonyInput',{value:fragment,configurable:true})})()` + const entry = code + const scripts = [capture, entry].map((s) => s.replace(/<\/script/gi, '<\\/script')) + put( + path, + `${messages.brand}
`, + profile, + responseHeaders(profile, { ...options, inline: scripts }), + ) + } + document('/ccdp/v1/prover', primary.code, 'prover') + document('/ccdp/v1/prover/fallback', primary.code, 'proverFallback') + const callback = await bundle('src/ccdp/documents/callback.ts', data, { + selfContained: true, + invoke: 'startCallback', + }) + for (const item of callback.output) { + if (item.type !== 'chunk' || !item.isEntry) throw new Error('Callback must be self-contained') + if (item.imports.length || item.dynamicImports.length || item.referencedFiles.length) + throw new Error('Callback must have no external dependencies') + const code = item.code.replace(/<\/script/gi, '<\\/script') + put( + '/ccdp/callback.html', + `${messages.brand}
`, + 'callback', + responseHeaders('callback', { ...options, inline: [code] }), + ) + } + const prefetch = await bundle('src/ccdp/documents/prefetch.ts', data, { invoke: 'startPrefetch' }) + for (const item of prefetch.output) { + if (item.type === 'chunk' && item.isEntry) { + document('/ccdp/v1/prefetch', item.code, 'prefetch') + put('/ccdp/v1/worker.js', item.code, 'worker') + } else put(`/${item.fileName}`, item.type === 'chunk' ? item.code : item.source, 'asset') + } + // The page every 404 serves; requested directly it declares the same error policy. + put( + '/404.html', + `${messages.notFoundTitle}

${messages.notFound}

`, + { 'Content-Type': 'text/html; charset=utf-8', ...errorHeaders }, + ) + // Retain old immutable assets and their effective policy through the compatibility window. + const previousGraph = join(out, 'distribution-graph.json') + if (existsSync(previousGraph)) { + const previous: DistributionMetadata = JSON.parse(readFileSync(previousGraph, 'utf8')) + for (const [path, headers] of Object.entries(previous.headers)) { + if (!path.startsWith('/ccdp/assets/')) continue + safePath(path.slice(1)) + assetHeaders(path, headers) + const bytes = readFileSync(join(out, 'public', path)) + const current = records.get(path) + if (current) { + if ( + !current.bytes.equals(bytes) || + JSON.stringify([...new Headers(current.headers)].sort()) !== + JSON.stringify([...new Headers(headers)].sort()) + ) + throw new Error(`Immutable response changed: ${path}`) + } else records.set(path, { bytes, headers }) + } + } + const files = writeDistribution(staging, records) + // Qualification metadata belongs to this output, outside public/ and the deployment image. + writeFileSync( + join(staging, 'distribution-graph.json'), + JSON.stringify({ + files, + requestsByProfile: data.requestsByProfile, + allowedRequests: data.allowedRequests, + headers: Object.fromEntries([...records].map(([p, r]) => [p, r.headers])), + graph: Object.fromEntries(graph), + } satisfies DistributionMetadata), + ) + if (existsSync(out)) { + const previous = `${out}.previous` + if (existsSync(previous)) throw new Error('Previous output already exists') + renameSync(out, previous) + try { + renameSync(staging, out) + } catch (error) { + renameSync(previous, out) + throw error + } + rmSync(previous, { recursive: true }) + } else renameSync(staging, out) + console.log(`Built ${records.size} public resources in ${out}`) +} catch (error) { + rmSync(staging, { recursive: true, force: true }) + throw error +} diff --git a/ts/packages/ceremony/build/loaders.test.ts b/ts/packages/ceremony/build/loaders.test.ts new file mode 100644 index 00000000..905e377f --- /dev/null +++ b/ts/packages/ceremony/build/loaders.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { test } from 'node:test' +import { pathToFileURL } from 'node:url' +import { gunzipSync } from 'node:zlib' +import type { DistributionMetadata } from './distribution.ts' +import { packageDir } from './release.ts' + +const require = createRequire(new URL('../package.json', import.meta.url)) + +const out = process.env.CEREMONY_ARTIFACT_DIR ?? join(packageDir, 'dist-artifacts') + +const graph: DistributionMetadata = JSON.parse( + readFileSync(join(out, 'distribution-graph.json'), 'utf8'), +) + +const requests = graph.requestsByProfile['google/1'] + +const originalFetch = globalThis.fetch + +const observations: { url: string; range?: string; method: string; cache: RequestCache }[] = [] + +let failPrimary = false + +const select = (name: string) => { + const request = requests.find((r) => r.url.endsWith(`/${name}`)) + assert.ok(request, `Missing ${name}`) + return request +} + +const external = requests.filter((r) => r.url.startsWith('https:')) + +// These observing stubs never contact external hosts. They test the dependency +// loaders, not proving: only the standalone browser qualification uses real CRS. +test('real dependency loaders obey emitted URLs and native CRS ranges [LIBID-ASSET-018]', async () => { + globalThis.fetch = async (input, init) => { + const url = String(input), + range = new Headers(init?.headers).get('range') ?? undefined + const method = init?.method ?? 'GET', + cache = init?.cache ?? 'default' + observations.push({ url, range, method, cache }) + assert.equal(method, 'GET') + assert.equal(cache, url.startsWith('https:') ? 'force-cache' : 'default') + const spec = + requests.find((r) => r.url === url && r.range === range) ?? + external.find( + (r) => + new URL(r.url).pathname === new URL(url).pathname && + r.range === range && + new URL(url).origin === 'https://crs.aztec-labs.com', + ) + assert.ok(spec, `undeclared dependency request: ${url} ${range}`) + if (failPrimary && url.startsWith('https://crs.aztec-cdn.foundation')) + throw new Error('Primary intentionally blocked') + const body = url.startsWith('/') + ? readFileSync(join(out, 'public', url)) + : new Uint8Array(spec.bytes!) + return new Response(body, { + status: range ? 206 : 200, + headers: { 'Content-Type': spec.mime ?? 'application/octet-stream' }, + }) + } + try { + for (const [pkg, file, wasm] of [ + ['@noir-lang/acvm_js', 'acvm_js.js', 'acvm_js_bg.wasm'], + ['@noir-lang/noirc_abi', 'noirc_abi_wasm.js', 'noirc_abi_wasm_bg.wasm'], + ]) { + const module: typeof import('@noir-lang/acvm_js') | typeof import('@noir-lang/noirc_abi') = + await import(pathToFileURL(resolve(dirname(require.resolve(pkg)), '../web', file)).href) + await module.default({ module_or_path: select(wasm).url }) + } + const bb = resolve(dirname(require.resolve('@aztec/bb.js')), '../browser') + const { fetchCode } = await import( + pathToFileURL(join(bb, 'barretenberg_wasm/fetch_code/browser/index.js')).href + ) + const wasm = select('barretenberg-threads.wasm').url + // The emitted body is already decoded; bb must not need its JS gzip branch. + assert.deepEqual( + readFileSync(join(out, 'public', wasm)), + gunzipSync(readFileSync(join(bb, '../node/barretenberg_wasm/barretenberg-threads.wasm.gz'))), + ) + assert.equal( + WebAssembly.validate(await fetchCode(true, wasm.replace('-threads.wasm', '.wasm'))), + true, + ) + const { NetCrs, NetGrumpkinCrs } = await import(pathToFileURL(join(bb, 'crs/net_crs.js')).href) + for (const fallback of [false, true]) { + failPrimary = fallback + const start = observations.length + await new NetCrs(2 ** 18).init() + await new NetGrumpkinCrs(2 ** 16).init() + const actual = observations.slice(start) + for (const spec of external) { + assert.ok(actual.some((r) => r.url === spec.url && r.range === spec.range)) + if (fallback) { + assert.ok( + actual.some( + (r) => + r.url === spec.url.replace('crs.aztec-cdn.foundation', 'crs.aztec-labs.com') && + r.range === spec.range, + ), + ) + assert.ok( + actual.findIndex((r) => r.url === spec.url) < + actual.findIndex( + (r) => r.url === spec.url.replace('crs.aztec-cdn.foundation', 'crs.aztec-labs.com'), + ), + ) + } + } + } + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/ts/packages/ceremony/build/notary.test.ts b/ts/packages/ceremony/build/notary.test.ts new file mode 100644 index 00000000..20812732 --- /dev/null +++ b/ts/packages/ceremony/build/notary.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { executionWorker } from '../src/ccdp/headers.ts' +import { responseHeaders } from './profiles.ts' + +test('fixed response policies admit runtime notaries without remote code permission [LIBID-ASSET-003] [CSP-003/011]', () => { + for (const profile of ['prover', 'proverFallback', 'executionWorker'] as const) { + const policy = responseHeaders(profile, {})['Content-Security-Policy'] + const directives = new Map( + policy.split(';').map((d) => { + const [name, ...sources] = d.trim().split(/\s+/) + return [name, sources] + }), + ) + assert.ok(directives.get('connect-src')!.includes('wss:')) + assert.ok(directives.get('connect-src')!.includes('https:')) + assert.ok(!policy.includes('notary.lib.id')) + for (const name of ['script-src', 'worker-src']) { + assert.ok(!directives.get(name)!.includes('https:')) + assert.ok(!directives.get(name)!.includes('*')) + } + } + for (const profile of ['prefetch', 'worker', 'proofWorker', 'leafWorker'] as const) + assert.ok(!responseHeaders(profile, {})['Content-Security-Policy'].includes('wss:')) +}) + +test('all asset-fetching contexts explicitly admit their own origin [CSP-003] [CSP-018]', () => { + const policies = [ + ...( + [ + 'prefetch', + 'prover', + 'proverFallback', + 'worker', + 'executionWorker', + 'proofWorker', + 'leafWorker', + ] as const + ).map((profile) => responseHeaders(profile, {})), + executionWorker, + ] + for (const headers of policies) { + const sources = headers['Content-Security-Policy'] + .split('connect-src ')[1] + .split(';')[0] + .trim() + .split(/\s+/) + assert.ok(sources.includes("'self'")) + assert.ok(!sources.some((source) => source.startsWith('http:')) && !sources.includes('ws:')) + assert.ok(!headers['Content-Security-Policy'].includes('upgrade-insecure-requests')) + } +}) diff --git a/ts/packages/ceremony/build/popup.ts b/ts/packages/ceremony/build/popup.ts new file mode 100644 index 00000000..8b798b8b --- /dev/null +++ b/ts/packages/ceremony/build/popup.ts @@ -0,0 +1,23 @@ +import type { Plugin } from 'vite' + +// Code-owned integration point for the optional carrier supplied by the application too. +// A released adapter may provide `fallback`; its implementation belongs outside ceremony. +export const popupFallback: { module?: string; connectSources: string[] } = { + module: undefined, + connectSources: [], +} + +export function popupPlugin(): Plugin { + return { + name: 'ceremony-popup-fallback', + resolveId(id) { + if (id === 'virtual:ceremony-popup-fallback') return `\0${id}` + }, + load(id) { + if (id === '\0virtual:ceremony-popup-fallback') + return popupFallback.module + ? `export {fallback} from ${JSON.stringify(popupFallback.module)}` + : 'export const fallback=undefined' + }, + } +} diff --git a/ts/packages/ceremony/build/profiles.ts b/ts/packages/ceremony/build/profiles.ts new file mode 100644 index 00000000..4b7491f5 --- /dev/null +++ b/ts/packages/ceremony/build/profiles.ts @@ -0,0 +1,82 @@ +export type ResponseProfile = + | 'prefetch' + | 'prover' + | 'proverFallback' + | 'callback' + | 'worker' + | 'executionWorker' + | 'proofWorker' + | 'leafWorker' + | 'asset' + +import { createHash } from 'node:crypto' +import * as shared from '../src/ccdp/headers.ts' +import { popupFallback } from './popup.ts' + +export const scriptHash = (code: string) => + `'sha256-${createHash('sha256').update(code).digest('base64')}'` + +const base = shared.csp.base + +export function responseHeaders( + profile: ResponseProfile, + { + inline = [], + externalOrigins = [], + }: { + inline?: string[] + externalOrigins?: string[] + }, +): Record { + const headers: Record = { + ...(['callback', 'prefetch', 'prover', 'proverFallback'].includes(profile) + ? shared.document + : shared.javascript), + ...(profile === 'asset' || ['executionWorker', 'proofWorker', 'leafWorker'].includes(profile) + ? shared.immutable + : { 'X-Content-Type-Options': 'nosniff' }), + 'Cache-Control': ['asset', 'executionWorker', 'proofWorker', 'leafWorker'].includes(profile) + ? 'public, max-age=31536000, immutable' + : 'no-cache', + 'Content-Type': ['callback', 'prefetch', 'prover', 'proverFallback'].includes(profile) + ? 'text/html; charset=utf-8' + : 'text/javascript; charset=utf-8', + } + if (profile === 'callback') + return { + ...headers, + 'Content-Security-Policy': `${base}; script-src ${inline.map(scriptHash).join(' ')}; style-src 'unsafe-inline'`, + 'Cross-Origin-Opener-Policy': 'unsafe-none', + 'Referrer-Policy': 'no-referrer', + } + headers['Cross-Origin-Resource-Policy'] = 'same-origin' + if (profile === 'asset') return headers + const execution = [ + 'prover', + 'proverFallback', + 'executionWorker', + 'proofWorker', + 'leafWorker', + ].includes(profile) + const connects = ['proofWorker', 'leafWorker'].includes(profile) + ? `${shared.csp.fetch} blob:` + : execution + ? `${shared.csp.fetch} ${shared.csp.websocket}` + : `'self' ${externalOrigins.join(' ')}` + headers['Content-Security-Policy'] = + `${base}; script-src 'self' ${inline.map(scriptHash).join(' ')}${execution ? " 'wasm-unsafe-eval'" : ''}; worker-src ${profile === 'leafWorker' ? "'none'" : `'self'${execution ? ' blob:' : ''}`}; connect-src ${connects} ${popupFallback.connectSources.join(' ')}${profile === 'executionWorker' ? ' blob:' : ''}${['prefetch', 'prover', 'proverFallback'].includes(profile) ? "; style-src 'unsafe-inline'" : ''}` + if (profile === 'worker') { + headers['Service-Worker-Allowed'] = '/' + return headers + } + if (['executionWorker', 'proofWorker', 'leafWorker'].includes(profile)) { + headers['Cross-Origin-Embedder-Policy'] = 'require-corp' + return headers + } + headers['Referrer-Policy'] = 'no-referrer' + headers['Cross-Origin-Opener-Policy'] = + profile === 'proverFallback' ? 'same-origin' : 'unsafe-none' + if (profile === 'prover') Object.assign(headers, shared.dip) + if (profile === 'proverFallback') Object.assign(headers, shared.isolated) + return headers +} diff --git a/ts/packages/ceremony/build/release.ts b/ts/packages/ceremony/build/release.ts new file mode 100644 index 00000000..1273b199 --- /dev/null +++ b/ts/packages/ceremony/build/release.ts @@ -0,0 +1,22 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const packageDir = fileURLToPath(new URL('../', import.meta.url)) + +export const cache = join(packageDir, '.cache') + +/** Internal cache/bundler identity, not a source integrity requirement. */ +export const hash = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex') + +export async function download(url: string): Promise { + const path = join(cache, 'downloads', encodeURIComponent(url)) + if (existsSync(path)) return readFileSync(path) + const response = await fetch(url) + if (!response.ok) throw new Error(`Release download failed: ${url}`) + const bytes = Buffer.from(await response.arrayBuffer()) + mkdirSync(join(cache, 'downloads'), { recursive: true }) + writeFileSync(path, bytes) + return bytes +} diff --git a/ts/packages/ceremony/build/sws.test.ts b/ts/packages/ceremony/build/sws.test.ts new file mode 100644 index 00000000..0cf151e8 --- /dev/null +++ b/ts/packages/ceremony/build/sws.test.ts @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { test } from 'node:test' +import { setTimeout } from 'node:timers/promises' +import { parse, stringify, type TomlTable } from 'smol-toml' +import { document } from '../src/ccdp/headers.ts' +import { cache } from './release.ts' +import { errorHeaders, writeDistribution } from './sws.ts' + +// The native tests below start their own SWS on this port and the next one. +const testPort = Number(process.env.CEREMONY_SWS_TEST_PORT ?? 4687) + +/** Point an emitted `sws.toml` at its own output on a loopback port. */ +function localize(dir: string, port: number, edit?: (config: TomlTable) => void) { + const path = join(dir, 'sws.toml') + const config = parse(readFileSync(path, 'utf8')) + Object.assign(config.general as object, { + host: '127.0.0.1', + port, + root: join(dir, 'public'), + page404: join(dir, 'public/404.html'), + }) + edit?.(config) + writeFileSync(path, stringify(config)) +} + +/** Start the pinned binary on an emitted output and wait for its health probe. */ +async function serve(dir: string, port: number) { + // A `config.toml` in the working directory would win over `--config-file`. + const child = spawn(process.env.CEREMONY_SWS_BINARY!, ['--config-file', join(dir, 'sws.toml')], { + cwd: dir, + stdio: 'ignore', + }) + let failure: Error | undefined + child.once('error', (error) => { + failure = error + }) + const ended = once(child, 'exit').catch(() => undefined) + const stop = async () => { + child.kill() + await ended + } + const url = `http://127.0.0.1:${port}` + for (let i = 0; i < 100; i++) { + if (failure) throw failure + if (child.exitCode !== null) throw new Error(`SWS exited with ${child.exitCode}`) + try { + if ((await fetch(`${url}/health`)).status === 200) return { url, stop } + } catch { + await setTimeout(50) + } + } + await stop() + throw new Error(`SWS did not answer ${url}/health`) +} + +test('sidecars cannot overwrite archive members or executable resources [LIBID-ASSET-024]', () => { + for (const extension of ['br', 'gz', 'zst']) + assert.throws( + () => + writeDistribution( + '/unused', + new Map([ + ['/ccdp/assets/a.js', { bytes: Buffer.from('same'.repeat(100)), headers: {} }], + [`/ccdp/assets/a.js.${extension}`, { bytes: Buffer.from('different'), headers: {} }], + ]), + ), + /sidecar/, + ) +}) + +test('rebuild removes obsolete compression sidecars [LIBID-ASSET-023]', () => { + mkdirSync(cache, { recursive: true }) + const dir = mkdtempSync(join(cache, 'sws-sidecars-')) + const publish = (body: string) => + writeDistribution( + dir, + new Map([['/index.html', { bytes: Buffer.from(body), headers: { ...document } }]]), + ) + try { + publish('

compressible

'.repeat(100)) + for (const extension of ['br', 'gz']) + assert.ok(existsSync(join(dir, `public/index.html.${extension}`))) + publish('short') + assert.equal(readFileSync(join(dir, 'public/index.html'), 'utf8'), 'short') + for (const extension of ['br', 'gz']) + assert.ok(!existsSync(join(dir, `public/index.html.${extension}`))) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('emitted header rules: the error policy catch-all, then one exact rule per file; health probe on', () => { + mkdirSync(cache, { recursive: true }) + const dir = mkdtempSync(join(cache, 'sws-rules-')) + try { + const headers = { 'Cache-Control': 'public, max-age=31536000, immutable' } + writeDistribution( + dir, + new Map([ + ['/ccdp/assets/a.js', { bytes: Buffer.from('a'), headers }], + ['/ccdp/v1/prefetch', { bytes: Buffer.from('

p

'), headers: { ...document } }], + ]), + ) + const config = parse(readFileSync(join(dir, 'sws.toml'), 'utf8')) + assert.equal((config.general as TomlTable).health, true) + assert.equal((config.general as TomlTable)['security-headers'], false) + // Plain-path matching of the rules below depends on the trailing-slash redirect staying on. + assert.equal((config.general as TomlTable)['redirect-trailing-slash'], true) + assert.deepEqual((config.advanced as TomlTable).rewrites, [ + { source: '/ccdp/v1/prefetch', destination: '/ccdp/v1/prefetch.html' }, + ]) + // The catch-all first, so every later exact rule overwrites it on its own file. Exact + // physical paths only: no route (rewritten before matching) and no appended-name form + // (`/ccdp/assets/a.js/a.js`), which is the raw path of the 404 beneath the file. + assert.deepEqual((config.advanced as TomlTable).headers, [ + { source: '/**', headers: errorHeaders }, + { source: '/ccdp/assets/a.js', headers }, + { source: '/ccdp/v1/prefetch.html', headers: document }, + ]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('native SWS invalidates same-length rebuilt protocol bodies [LIBID-ASSET-027]', { + skip: !process.env.CEREMONY_SWS_BINARY, +}, async () => { + mkdirSync(cache, { recursive: true }) + const dir = mkdtempSync(join(cache, 'sws-test-')) + const publish = (body: string) => { + writeDistribution( + dir, + new Map([ + ['/ccdp/v1/prefetch', { bytes: Buffer.from(body), headers: { ...document } }], + ['/404.html', { bytes: Buffer.from('Not found'), headers: { ...document } }], + ]), + ) + localize(dir, testPort) + } + publish('

first

') + const server = await serve(dir, testPort) + try { + const url = `${server.url}/ccdp/v1/prefetch` + const response = await fetch(url) + assert.equal(response.status, 200) + const etag = response.headers.get('etag')! + assert.ok(etag.startsWith('W/')) + assert.equal(await response.text(), '

first

') + publish('

other

') + const changed = await fetch(url, { headers: { 'If-None-Match': etag } }) + assert.equal(changed.status, 200) + assert.notEqual(changed.headers.get('etag'), etag) + assert.equal(await changed.text(), '

other

') + const warm = await fetch(url, { headers: { 'If-None-Match': changed.headers.get('etag')! } }) + assert.equal(warm.status, 304) + } finally { + await server.stop() + rmSync(dir, { recursive: true, force: true }) + } +}) + +// Canary: pins how the pinned SWS matches `[[advanced.headers]]` sources (see +// sws.ts). A failure on a newer SWS means the matching changed; revisit sws.ts +// and docs/distribution.md before updating the assertions. +test('native SWS header-rule matching canary: plain path after rewrites, raw path and catch-all on errors [KIT-001A]', { + skip: !process.env.CEREMONY_SWS_BINARY, +}, async () => { + mkdirSync(cache, { recursive: true }) + const dir = mkdtempSync(join(cache, 'sws-canary-')) + const port = testPort + 1 + const asset = { 'Cache-Control': 'public, max-age=31536000, immutable', 'X-Served': 'applied' } + writeDistribution( + dir, + new Map([ + ['/ccdp/assets/served.js', { bytes: Buffer.from('export {}'), headers: asset }], + ['/ccdp/v1/prefetch', { bytes: Buffer.from('

prefetch

'), headers: { ...document } }], + ['/404.html', { bytes: Buffer.from('Not found'), headers: { ...errorHeaders } }], + ]), + ) + localize(dir, port, (config) => { + const advanced = config.advanced as TomlTable + // Probes after the emitted rules, each keyed on a form the emitted rules must not use. + advanced.headers = [ + ...(advanced.headers as TomlTable[]), + // The appended-name form: never a resolved file, only the raw path of the 404 beneath it. + { source: '/ccdp/assets/served.js/served.js', headers: { 'X-Appended': 'applied' } }, + // The requested route: rewritten before matching. + { source: '/ccdp/v1/prefetch', headers: { 'X-Requested': 'applied' } }, + // On an error, every matching rule applies in config order; later ones overwrite. + { source: '/ccdp/assets/missing.js', headers: { 'Cache-Control': 'max-age=1' } }, + ] + }) + const server = await serve(dir, port) + try { + const served = await fetch(`${server.url}/ccdp/assets/served.js`) + assert.equal(served.status, 200) + assert.equal(served.headers.get('x-served'), 'applied') + assert.equal(served.headers.get('x-appended'), null) + // The exact rule overwrites the catch-all's names it declares; the others keep the catch-all's value. + assert.equal(served.headers.get('cache-control'), asset['Cache-Control']) + assert.equal( + served.headers.get('content-security-policy'), + errorHeaders['Content-Security-Policy'], + ) + for (const path of ['/ccdp/v1/prefetch', '/ccdp/v1/prefetch.html']) { + const page = await fetch(server.url + path) + assert.equal(page.status, 200, path) + assert.equal(page.redirected, false, path) + assert.equal(page.headers.get('cache-control'), document['Cache-Control'], path) + assert.equal(page.headers.get('x-requested'), null, path) + } + const beneath = await fetch(`${server.url}/ccdp/assets/served.js/served.js`) + assert.equal(beneath.status, 404) + assert.equal(beneath.headers.get('x-appended'), 'applied') + assert.equal(beneath.headers.get('x-served'), null) + assert.equal(beneath.headers.get('cache-control'), errorHeaders['Cache-Control']) + const missing = await fetch(`${server.url}/ccdp/assets/missing.js`) + assert.equal(missing.status, 404) + assert.equal(missing.headers.get('cache-control'), 'max-age=1') + // Nothing else names an unknown path: only the catch-all applies. + const unknown = await fetch(`${server.url}/nope`) + assert.equal(unknown.status, 404) + for (const [name, value] of Object.entries(errorHeaders)) + assert.equal(unknown.headers.get(name), value, name) + assert.equal(unknown.headers.get('x-served'), null) + // The health probe answers before any header rule. + const health = await fetch(`${server.url}/health`) + assert.equal(health.status, 200) + assert.equal(health.headers.get('cache-control'), null) + } finally { + await server.stop() + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/ts/packages/ceremony/build/sws.ts b/ts/packages/ceremony/build/sws.ts new file mode 100644 index 00000000..8a5a6a24 --- /dev/null +++ b/ts/packages/ceremony/build/sws.ts @@ -0,0 +1,97 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { brotliCompressSync, constants, gzipSync } from 'node:zlib' +import { stringify } from 'smol-toml' +import { safePath } from './archive.ts' + +/** + * Policy of every response that resolved no file: 404s (unknown routes, + * missing assets, `/ccdp/assets/`, `/`) and the trailing-slash + * redirect of a directory path. Absent cache headers would leave a 404 + * heuristically cacheable (RFC 9110 §15.5.5), so the policy is explicit. + * + * SWS 3.0.0-beta.1 (`src/custom_headers.rs`) matches `[[advanced.headers]]` + * sources against the request path after `advanced.rewrites`, so one exact + * rule per physical file (`/ccdp/v1/prefetch.html`) covers its route and its + * direct `.html` request. `/` is appended before matching + * only for a directory-index request (`/dir/`, or any resolved file with + * `redirect-trailing-slash = false`); this distribution serves no directory + * index and keeps the redirect on, so that form is never emitted: keyed on + * `/`, it equals the raw path of the 404 beneath the file. A + * response that resolved no file is matched on the raw request path, and + * every matching rule applies in config order, later rules overwriting. + * Hence the catch-all `/**` carrying this policy comes first: an exact rule + * overwrites the names it declares on its own file, nothing else inherits a + * cacheable policy, and a 200 keeps the catch-all's value for a name its + * declaration omits (the CSP on a plain asset, inert outside documents and + * workers, which all declare their own). The canary in `sws.test.ts` pins + * this matching against the real binary; when it fails on a newer SWS, + * revisit this file and docs/distribution.md. + */ +export const errorHeaders = { + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + 'Cross-Origin-Resource-Policy': 'same-origin', + 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'", +} as const + +/** Emit static files and native SWS configuration; no response metadata overrides. */ +export function writeDistribution( + out: string, + records: ReadonlyMap }>, +) { + const files: Record = {} + for (const path of records.keys()) { + if (!path.startsWith('/')) throw new Error('Invalid public path') + safePath(path.slice(1)) + if (records.has(`${path}.br`) || records.has(`${path}.gz`) || records.has(`${path}.zst`)) + throw new Error(`Resource conflicts with negotiated sidecar: ${path}`) + } + const rewrites: { source: string; destination: string }[] = [] + const rules: { source: string; headers: Record }[] = [ + { source: '/**', headers: { ...errorHeaders } }, + ] + for (const [path, { bytes, headers }] of records) { + const physical = /^\/ccdp\/v[1-9][0-9]*\/(prefetch|prover|prover\/fallback)$/.test(path) + ? `${path.replace(/\/fallback$/, '-fallback')}.html` + : path + files[path] = physical + if (physical !== path) rewrites.push({ source: path, destination: physical }) + const target = join(out, 'public', physical) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, bytes) + const compressed = brotliCompressSync(bytes, { + params: { [constants.BROTLI_PARAM_QUALITY]: 6 }, + }) + // Rebuilds must not leave a previous body available through content negotiation. + if (compressed.length < bytes.length) writeFileSync(`${target}.br`, compressed) + else rmSync(`${target}.br`, { force: true }) + const gzip = gzipSync(bytes, { level: 6 }) + if (gzip.length < bytes.length) writeFileSync(`${target}.gz`, gzip) + else rmSync(`${target}.gz`, { force: true }) + rules.push({ source: physical, headers }) + } + const config = { + general: { + host: '::', + // Leave the port to deployment CLI/env; SWS file values take precedence. + root: '/home/sws/public', + page404: '/home/sws/public/404.html', + 'cache-control-headers': false, + etag: true, + compression: false, + 'compression-static': true, + // Would add HSTS and framing policy to every response; HSTS belongs to the ingress. + 'security-headers': false, + 'directory-listing': false, + // The header rules above assume it: off, every resolved file is matched with its name appended. + 'redirect-trailing-slash': true, + // `GET /health` answers 200 for readiness and liveness probes. + health: true, + 'text-charset': false, + }, + advanced: { rewrites, headers: rules }, + } + writeFileSync(join(out, 'sws.toml'), stringify(config)) + return files +} diff --git a/ts/packages/ceremony/ccdp.Dockerfile b/ts/packages/ceremony/ccdp.Dockerfile new file mode 100644 index 00000000..32985e15 --- /dev/null +++ b/ts/packages/ceremony/ccdp.Dockerfile @@ -0,0 +1,9 @@ +# SWS 3.0.0-beta.1, multi-platform image digest. Build context is dist-artifacts/. +FROM ghcr.io/static-web-server/static-web-server@sha256:4e804280b5b5b1be4563d4a9e0f3a0ea38e7887967d0cc00bc8c07030f529b3f +# The base image ships a placeholder public/index.html; replace the served tree, never merge into it. +RUN rm -rf /home/sws/public +COPY --chown=sws:sws public/ /home/sws/public/ +COPY --chown=sws:sws sws.toml /etc/sws.toml +# Retention state for the next build, outside the served root; never a public resource. +COPY --chown=sws:sws distribution-graph.json /home/sws/distribution-graph.json +ENV SERVER_CONFIG_FILE=/etc/sws.toml diff --git a/ts/packages/ceremony/docs/architecture.md b/ts/packages/ceremony/docs/architecture.md new file mode 100644 index 00000000..58024de3 --- /dev/null +++ b/ts/packages/ceremony/docs/architecture.md @@ -0,0 +1,100 @@ +# Architecture + +Ceremony obtains identity evidence for an application-owned operation. The caller +supplies a popup connection and keeps control of its lifetime. Prover extracts +identity and builds evidence; Client checks the result structure and assembles +`OAuthProof`. Neither performs final cryptographic verification in the browser. + +## Ownership + +| Owner | Responsibility | +|---|---| +| [ccdp/client](../src/ccdp/client/ceremony.ts) | Fetch/freeze Bridge config, derive authorization inputs, run one ceremony, validate and assemble its result. | +| [ccdp/index](../src/ccdp/index.ts), [navigation](../src/ccdp/navigation.ts) | Browser-free message companions and route/fragment codecs. | +| [ccdp/documents](../src/ccdp/documents/) | Callback, Prefetch/Worker and Prover entrypoints; native package-owned UI. | +| [platforms](../src/platforms/index.ts) | Client-safe catalog; each platform/version owns URL construction, validators, assets, events and its execution pipeline. | +| [barretenberg](../src/barretenberg/engine.ts) | Dedicated Noir/bb.js proof worker, circuits and input adapters. | +| [notary](../src/notary/session.ts) | TLSNotary sessions, HTTP/transcript helpers, canonical decoding and evidence correlation. | +| [assets](../src/assets/index.ts) | Resource declarations and resolution, root Worker registration, byte caches and pending fetches. | +| [build](../build/distribution.ts) | Compile the dependency graph and emit static files and response policies. | +| [events](../src/events.ts), [errors](../src/errors.ts) | Shared operation feed, stage projection and bounded failure text. | + +The two public entrypoints are `@libid/ceremony` (discovery, result types and +`CeremonyError`) and `@libid/ceremony/ccdp/client` (the client and subscriptions, +plus the root exports). Codecs, document startup, execution and build helpers +are private. See the [client guide](client.md) for application use. + +## Document lifecycle + +1. **Prefetch**, on the CCDP origin, authenticates the connection, activates the + canonical root Worker and dispatches the selected assets. Its completion + event permits Client to navigate to the provider; downloads may continue. +2. **Callback**, on the Bridge origin, captures and clears the OAuth return + before other work. Its self-contained HTML selects bundled CCDP code and + validates the Bridge's inserted deployment data. It authenticates Application, + reports the authorization return, and navigates privately to Prover. +3. **Prover**, on the CCDP origin, accepts only Callback's authenticated + `connection.peerOrigin`, forwarded in the private fragment. That origin is + never inferred from OAuth fields or allowlist order. After popup connection + readiness, isolation checks and root-worker claim, Prover requests inputs + through `prover.started`, runs the selected pipeline and sends one outcome. + +The [CCDP specification](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md) +owns the five messages, routes and permitted transitions. Message companions +check exact shape and bounds; the receiving Client/document enforces state and +cardinality. Readiness processing does not depend on event subscriptions. +Unknown extension events cannot authorize a transition or complete a ceremony. + +Popup owns window creation, native-anchor fallback, authentication, navigation, +isolation replacement, port continuity and closure. Ceremony's root Worker +composes popup's keeper with asset fetching; it adds no handshake or transport. +Callback installs no Worker. Application, documents and Worker must use compatible +popup transport versions, including the authenticated-origin handoff. + +Proving stays in the foreground popup. A shared abort signal tears down reachable +workers and private state after delivery, denial, failure or connection loss. +There is no persistent proof checkpoint, application iframe prover, Job store, +wallet operation or transaction submission inside this package. + +## Import boundaries + +The client-safe catalog imports URL builders, proof validators and event +metadata. It never imports platform execution. Prover lazily imports execution +leaves; those leaves do not import the catalog selecting them. + +Shared integrations declare resources once in `*.assets.ts`; platform/version +leaves compose those handles. Prefetch imports only this data-only catalog. +The compiler adds actual chunks and nested-worker edges to each selected set. +Execution resolves the same handles. Fetching scripts as bytes before OAuth +never initializes WASM, proof backends or TLSNotary sessions. + +[Platform pipelines](pipelines.md) compose the independent proving and notary +modules. Early transcripts and commitment openings permit overlap, but proof +delivery joins every required final attestation and correlation. + +## Versioning and compatibility + +Client intersects the local platform catalog with Bridge-advertised versions. +An explicit version selects a compatible member; omission chooses the greatest +common version. Each run freezes its selection. Version semantics may differ in +disclosure behavior, so an application promising a specific behavior selects it +explicitly. + +Platform ceremony, CCDP, popup transport and Bridge API versions have separate +owners. Internal UI or asset changes need no platform ceremony version when the +proof semantics remain compatible. Only version 1 is implemented today; +[adding another version](pipelines.md#adding-a-platform) also requires changes +to the Prover dispatcher and distribution build. + +## Code and documentation conventions + +Keep cross-module rationale here, user contracts in the client/deployment guides, +and byte layouts, limits, ownership and ordering comments beside their code. +Link normative encodings instead of defining them again. Fixtures sit beside +owning tests as `.fixture.*`; package contents exclude tests and fixtures. + +Use workspace Biome formatting: two spaces, single quotes, no semicolons and +organized imports. Separate declarations and methods with a blank line. JSDoc +explains meaningful input, lifetime and failure constraints; internal comments +explain invariants rather than restating types. Run `pnpm -C ts lint` and +`pnpm -C ts fmt:check` for mechanical checks. diff --git a/ts/packages/ceremony/docs/assets.md b/ts/packages/ceremony/docs/assets.md new file mode 100644 index 00000000..2d073eca --- /dev/null +++ b/ts/packages/ceremony/docs/assets.md @@ -0,0 +1,59 @@ +# Asset prefetch and caching + +[Resource declarations](distribution.md#source-declarations) and compiler output +select each platform/version's exact request set. Prefetch warms those bytes +while the user authorizes. It does not initialize WASM, proof backends or TLSN +sessions, and no initialized runtime survives OAuth navigation. + +## Worker ownership + +[registration.ts](../src/assets/registration.ts) resolves the canonical root +registration, including when a stale nested registration uses the same script. +It retires that known nested registration only when its workers use the canonical +script URL; unrelated registrations remain untouched. It waits for the selected +worker to activate and never dispatches to an older active worker while an update +is installing or waiting. + +[prefetch.ts](../src/ccdp/documents/prefetch.ts) is a dual document/Worker entry. +The [Worker](../src/assets/worker.ts) combines popup's port keeper with asset +fetching. Install uses `skipWaiting`, activation uses `clients.claim`, and Prover +explicitly joins root-worker control before readiness. Failure to establish the +required registration/control is terminal. + +The private Worker message accepts a catalog profile, never caller-supplied URLs. +The dispatch acknowledgement means every selected request has a cache hit or a +fetch invocation. It does not wait for response bodies or persistence. Only after +that acknowledgement and popup authentication may Prefetch emit +`prefetch-dispatch.finished` and Client navigate to Authorization. + +## Delivery and lifetime + +[AssetCache.load](../src/assets/cache.ts) keeps one pending fetch per exact URL +and Range. Each caller gets an independently readable response. Its three promises +separate dispatch, validated response delivery and best-effort persistence; +Worker `waitUntil` and the pending entry remain alive through persistence. +A failed Cache Storage write cannot delay or invalidate a valid response. + +Ordinary cache hits validate headers and return the cached Response without +rereading or copying the body. Bodies were checked before insertion. Ranged CRS +hits reconstruct a 206 from stored prefix bytes because Cache Storage cannot +store 206 responses directly. Range and byte-count checks prevent a full-file +response or another prefix from masquerading as the requested resource. + +Fetches use `force-cache`, so Cache Storage misses or denial can still reuse the +browser HTTP cache. The Worker fetches without credentials and only intercepts +GETs in the build's exact URL/range allowlist. It does not cache OAuth, Bridge +configuration/token exchange, platform HTTP requests or ceremony HTML. Native +requests outside that allowlist remain untouched. + +Completed bodies use one shared immutable-asset cache namespace; there is no +per-release cache deletion. Repeated URLs remain reusable across platforms and +rebuilds. bb.js independently owns its processed-CRS IndexedDB cache; ceremony +caches the raw declared CRS responses and does not duplicate that processing. + +Navigation destroys the Prefetch document, not the Worker's pending requests. +If the Worker terminates, later requests reuse completed entries and fetch +missing ones normally. There is no durable completion marker or ceremony resume +state. Fetch, quota and eviction failures change latency, not the selected assets, +isolation or proof requirements. See [testing](testing.md) and the remaining +[cache fault matrix](qualification.md#remaining-qualification). diff --git a/ts/packages/ceremony/docs/client.md b/ts/packages/ceremony/docs/client.md new file mode 100644 index 00000000..a8959679 --- /dev/null +++ b/ts/packages/ceremony/docs/client.md @@ -0,0 +1,229 @@ +# Client guide + +Create one client per Bridge configuration lifetime. It fetches public +configuration once; create a new client to pick up a changed deployment. +The application must also know its intended CCDP origin to configure popup's +origin allowlist. + +## Launch a ceremony + +This example connects an existing anchor and status element. Call it during +application setup, then let the user click the anchor. Supply the application's +ledger, 32-byte operation-domain hash and opaque transaction bytes. + +```ts +import { CeremonyStage, createCCDPClient } from '@libid/ceremony/ccdp/client' +import type { LedgerId } from '@libid/ledger' +import { PopupConnection, PopupWindow } from '@libid/popup' + +async function bindGoogleAction( + anchor: HTMLAnchorElement, + status: HTMLElement, + ledger: LedgerId, + operationDomain: Uint8Array, + transactionData: Uint8Array, +) { + const bridgeOrigin = 'https://bridge.example' + const ccdpOrigin = 'https://proofs.example' + const client = await createCCDPClient({ oauthBridge: bridgeOrigin }) + + anchor.addEventListener('click', (event) => { + const id = crypto.randomUUID() + const target = `ceremony-${id}` + // Keep window creation synchronous with the user's activation. + const popup = PopupWindow.open(target) + const connection = PopupConnection.connect(popup, { + connectionId: id, + allowedPopupOrigins: [bridgeOrigin, ccdpOrigin], + }) + try { + const ceremony = client.new( + connection, id, 'google', ledger, operationDomain, transactionData, + ) + anchor.target = target + anchor.href = ceremony.launchUrl + if (popup.opened) event.preventDefault() // Otherwise let the real anchor launch. + const off = ceremony.onStage((update) => { + status.textContent = update.status === 'active' + ? CeremonyStage.message(update.stage, 'Google') + : update.message ?? update.status + }) + void ceremony.proveUserIdentity().then( + (result) => { + if (result.status === 'accepted') { + // Hand result.identity and result.oauthProof to your ledger adapter, + // together with the original operation inputs. + } + connection.close() // This application closes on acceptance or denial. + }, + (error: unknown) => { + status.textContent = error instanceof Error ? error.message : 'Ceremony failed.' + // Keep this application's failed popup available for inspection. + }, + ).finally(off) + } catch (error) { + event.preventDefault() + connection.close() + status.textContent = error instanceof Error ? error.message : 'Unable to start ceremony.' + } + }) +} +``` + +The [development app](../../../apps/dev/src/app.ts) shows platform buttons, +concurrent runs, independent Close controls and timing history. Each live run +needs its own target, connection and fresh lowercase UUIDv4. Use the same UUID +for popup's `connectionId` and `client.new`'s ceremony ID. Reserve no CCDP message +handlers yourself on that connection. + +`new(connection, id, platformId, ledger, operationDomain, transactionData, version?)` +is synchronous and snapshots its inputs before OAuth. It reads `ledger.hash()` +and `ledger.notaryAddress()` once, copies the hash and byte inputs, and derives +fresh authorization material. Invalid selection, ledger values or inputs fail +before OAuth. A client cannot reuse a live ID; a connection cannot run two +ceremonies simultaneously. Full signatures and lifecycle JSDoc live in +[ceremony.ts](../src/ccdp/client/ceremony.ts). + +`launchUrl` is the complete Prefetch URL for a native anchor. +`proveUserIdentity()` starts the run once and owns subsequent protocol navigation. +Raw OAuth returns stay inside Callback and Prover; the application receives +neither them nor bearer credentials or private witnesses. + +## Platform and version discovery + +- `client.enabledPlatforms`: frozen compatible platform IDs for this Bridge. +- `client.enabledVersions(platform)`: compatible versions in ascending order; + empty for a known disabled platform. +- `supportedPlatforms`, exported from either entrypoint: package capabilities + before fetching configuration. It does not establish Bridge availability. + +`PlatformId` is derived from the closed catalog (`google`, `x`, `github`). Display +names and icons belong to the application. Only ceremony version 1 currently +exists. An omitted version chooses the highest compatible one; pass the trailing +version argument explicitly when selecting a particular disclosure behavior. +Unsupported selections fail synchronously without starting OAuth. + +Client derives fixed `/auth/callback` from its configured Bridge origin; public +configuration contains no callback path or redirect URI. + +GitHub configuration must include `clientCredential`, a nonempty printable +ASCII public OAuth application credential without whitespace. Client freezes and +forwards it unchanged in `ProveIdentity`; Prover never refetches configuration. +The field is optional for other platforms and validated whenever present. There +is no per-ceremony credential override. + +## Ledger and notary inputs + +[`@libid/ledger`](../../ledger/README.md) owns the ledger interface. Production +ledger definitions are still deferred; the dev app uses an explicit synthetic +fixture. Ceremony includes no chain catalog, EVM adapter or ledger decoder. + +Client forwards the ledger's notary address for every platform. Google ignores +it; X and GitHub require it before notarized work. There is no client override, +environment lookup or automatic notary substitution. The address selects routing, +not a trusted signing key. Downstream verification establishes notary authority. + +Bridge, CCDP and notary origins must be canonical HTTPS origins, with HTTP +allowed on exactly `localhost` and `127.0.0.1` at arbitrary ports. Credentials, +paths, query strings and fragments are not origins. The HTTP exception does not +relax platform HTTPS or the Prover's isolation requirement. + +## Results and errors + +Popup transport failure reports `failed`, including a window that becomes +unavailable during consent without a recovery carrier. The error explains that +closure and provider isolation (COOP) cannot be distinguished. An available +fallback keeps the ceremony pending until reconnection, fallback failure, or +explicit application closure. This does not alter OAuth denial handling. + +Application ending its connection cannot stop a provider page after opener +severance. On return, Callback and Prover display their own connection failures +locally, stop the progress indicator and further work, and preserve the transport +error rather than replacing it with a generic closure message. The user can +return to Application and start a new ceremony. No successful report back to +Application is required; an undeliverable failure leaves only the sanitized local +diagnostic. This identifies a connection/setup failure, not which component +caused it. + +[CCDP UI messages](../src/ccdp/ui-messages.ts) groups stage labels, document UI text, +error-page text, and translations of popup error codes. Popup returns programmatic +errors; ceremony translates them before display or forwarding. Unexpected +exceptions retain their bounded opaque text for debugging. + +`proveUserIdentity()` resolves either `{ status: 'denied' }` or an accepted result +containing separate `identity` and `oauthProof` values: + +- `identity`: exact platform ID, OAuth client ID, user ID and user name. The name + is Google's signed email, X's username or GitHub's login, without normalization. +- `oauthProof`: selected `platformCeremonyVersion`, fresh `authorizationNonce` + and the platform-specific `proof`. + +Google's proof contains `identityProof`, `tokenExpiresAt` and +`signingKeyModulus`. X/GitHub contain `bearerLinkProof`, `tokenAttestation` and +`identityAttestation`. A `NotaryAttestation` preserves original `attestedData` +and `signature` bytes with the Prover's complete `decoded` convenience view. +Use `OAuthProof<'google'>['proof']`, for example, to name a payload type without +importing private modules. A literal platform argument infers its result type; +a dynamic `PlatformId` produces the corresponding union. + +**Accepted means structurally accepted, not cryptographically verified.** Client +checks the selected platform/version, exact shapes, bounds and OAuth client ID. +It does not repeat evidence parsing, authenticate decoded views, verify notary +signatures or verify ZK proofs. The ledger adapter must preserve signed bytes +and combine the result with the original operation inputs; convenience views +are not authoritative ledger evidence. + +Technical failure and connection loss reject with `CeremonyError`, carrying +`event` (operation context), bounded opaque `message`, and `status`: +`'failed'` for technical failures or `'closed'` for a reported connection closure. +Closure emits a neutral interruption through both subscriptions and rejects the +pending operation; it is not OAuth denial. Popup detection remains best-effort, +including after opener severance. Closure after an accepted result or denial +cannot overwrite that terminal outcome. Display text with +`textContent`; do not interpret it as a stable error code or export it as +telemetry. A caught dependency message is not guaranteed free of sensitive data. +An OAuth denial resolves normally; closing a consent page without a valid denial +return does not imply denial. + +## Events and presentation + +Subscribe before starting; subscriptions do not replay past observations. +`onEvent` combines local and received operation occurrences into one timeline. +Active events have `event`, optional `phase`, `timestamp` and optional +`instrumentation`. Client derives exactly one terminal lifecycle update before +the promise settles. Status is `active | completed | denied | failed | closed`. +Only accepted proof delivery produces `prover.finished` with `completed` status; +early outcomes do not fabricate a finished operation. + +`onStage` provides a sequential UI projection, including terminal status and +failure text, so a simple UI needs only this subscription: + +| Stage | Trigger | +|---|---| +| `preparation` | Prefetch dispatch starts. | +| `authorization` | Provider navigation starts. | +| `proof-preparation` | Authorization return, or Prover readiness if that observation was lost. | +| `notarization` | Token fetch or token attestation starts; Google skips it. | +| `zk-proving` | ZK generation starts. | + +Use `CeremonyStage.message(stage, platformName)` for package wording. Stages +never move backwards and do not represent exclusive execution intervals or +percentages. ZK generation may finish while attestations remain pending. +[Measurements](metrics.md) explains occurrence timestamps and timing limits. +Observers can throw or unsubscribe without disrupting protocol processing. + +## Closure and retries + +Ceremony never closes the supplied connection, including after success or denial. +The example chooses automatic closure; an application may instead continue its +own flow in the popup. Late CCDP traffic becomes inert after settlement. + +To stop a live run, call `connection.close()`. This causes a failed lifecycle +update and `CeremonyError`, not an OAuth denial, cancel message or `CancelError`. +An application wanting a separate cancellation label records its own intent. +Closing the popup cannot recall a request already dispatched to the Bridge. + +A Ceremony is one-shot. Loss, reload or retry requires fresh OAuth and a new +ceremony ID; there is no resume API. Discard subscriptions when the consuming +view is removed. Optional opener-independent fallback requires matching popup +adapters in the application and [distribution build](distribution.md#bridge-and-popup-integration). diff --git a/ts/packages/ceremony/docs/distribution.md b/ts/packages/ceremony/docs/distribution.md new file mode 100644 index 00000000..7db1aa9f --- /dev/null +++ b/ts/packages/ceremony/docs/distribution.md @@ -0,0 +1,290 @@ +# Build and deployment + +The build emits a static CCDP artifact; Static Web Server (SWS) serves it. +Resource and response requirements belong to the +[Distribution specification](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp-distribution.md). +The host needs no ceremony server, request-time compilation or asset downloads. + +## Build and serve + +From the repository root, with Node 24+, pnpm and Docker: + +```sh +pnpm -C ts install --frozen-lockfile +pnpm -C ts --filter '@libid/ceremony...' build +pnpm -C ts --filter @libid/ceremony build:ccdp-artifacts + +docker build -f ts/packages/ceremony/ccdp.Dockerfile \ + -t libid-ccdp ts/packages/ceremony/dist-artifacts +docker run --rm -p 127.0.0.1:8080:8787 libid-ccdp +``` + +The build scripts run directly with Node's TypeScript stripping; +`typecheck:build` checks them separately. The default output is +`packages/ceremony/dist-artifacts` within `ts`. An optional `--out-dir` selects +another dedicated directory inside the checkout. Release downloads are cached +under the ceremony package's `.cache/downloads/`. + +```text +dist-artifacts/ +├── public/ +│ ├── ccdp/callback.html +│ ├── ccdp/v1/prefetch.html +│ ├── ccdp/v1/prover.html +│ ├── ccdp/v1/prover-fallback.html +│ ├── ccdp/v1/worker.js +│ ├── ccdp/assets/... +│ └── 404.html +├── sws.toml +└── distribution-graph.json +``` + +`distribution-graph.json` is private build/test metadata and an input for retaining +previous immutable resources. It is not a runtime manifest or public resource. +The [container recipe](../ccdp.Dockerfile) replaces the base image's served +tree (it ships a placeholder `index.html`) with `public/`, copies `sws.toml`, +and places `distribution-graph.json` at `/home/sws/` outside the served root so +the next build can read retention state back out of a published image (see +[Publication and upgrades](#publication-and-upgrades)). +Exact internal rewrites serve the document routes without `.html`; direct +navigation to their physical `.html` files does not execute a ceremony. +Unknown routes return 404 with no SPA fallback and an explicit error policy +(`Cache-Control: no-store` and the inert `404.html` headers, see +[Native server behavior](#native-server-behavior)): absent cache headers would +leave a 404 heuristically cacheable. + +## Source declarations + +Declare a dependency once in its owner's `*.assets.ts`. The internal +[assets API](../src/assets/index.ts) supplies three declaration forms: + +```ts +import * as assets from '../assets/index.js' + +const release = assets.archive( + 'https://github.com/libid-org/notary/releases/download/v0.3.0-rc.3/tlsn-wasm-0.3.0-rc.3.tar.gz', + 'tlsn/v0.3.0-rc.3-csp2', +) +const module = release.member('tlsn_wasm.js', { + ...assets.headers.immutable, + ...assets.headers.javascript, +}) +const spawn = release.member('snippets/web-spawn-*/js/spawn.js', { + ...assets.headers.immutable, + ...assets.headers.executionWorker, +}) +const acvm = assets.file( + 'npm:@noir-lang/acvm_js/web/acvm_js_bg.wasm', + 'noir/1.0.0-beta.25/acvm_js_bg.wasm', + { ...assets.headers.immutable, ...assets.headers.wasm }, +) +const crs = assets.external('https://crs.aztec-cdn.foundation/g1_compressed.dat', { + range: 'bytes=0-8388607', + fallback: ['https://crs.aztec-labs.com/g1_compressed.dat'], +}) + +// Execution resolves the same handle synchronously; this does not fetch. +const moduleUrl = assets.resolve(module) +const crsUrl = assets.resolve(crs) // The original external URL. +``` + +The real declarations live in [notary.assets.ts](../src/notary/notary.assets.ts) +and [barretenberg.assets.ts](../src/barretenberg/barretenberg.assets.ts). + +An archive source is an HTTPS URL or local path; relative paths resolve from the +ceremony package. The build reads it once and publishes only declared members beneath +`/ccdp/assets//`, retaining their relative directories. Declare any companion +files needed by archive-relative imports as members too. A member selector +must match exactly one file; `*` matches within a directory component, never +across `/`, and the final filename is exact. Traversal, links, duplicates, +conflicting bodies/policies and sidecar collisions fail the build. + +Mounted files become servable, but only selected profile dependencies are +prefetched. Standalone files accept installed `npm:` paths or declared sources; +installed package integrity comes from the workspace lockfile. Developers own +release URLs and immutable mounts. There is no handwritten asset checksum list +or browser hashing step. + +External declarations retain their URL, range, optional exact byte count and +fallback URLs. They emit no local body or response headers. They contribute to +prefetch metadata, the fetch allowlist and generated CSP. Native bb.js chooses +its fallback on failure; Prefetch does not speculatively download both mirrors. +The [pinned browser loader limitation](proving.md#dependency-asset-resolution) +means current CRS resources must remain external. + +Each platform/version composes shared handles and its circuit/key in its own +asset leaf. [platforms.assets.ts](../src/platforms/platforms.assets.ts) collects +those sets. The compiler adds the selected execution chunks and nested-worker +edges, so their filenames are not declared again. Prefetch consumes metadata; +it never imports execution to discover dependencies. + +## Headers and compression + +[headers.ts](../src/ccdp/headers.ts) holds plain reusable policy records: +`immutable`, `javascript`, `wasm`, `json`, `document`, `executionWorker`, `dip` +and `isolated`. Resource declarations compose them with object spread and may +add explicit headers. [profiles.ts](../build/profiles.ts) applies document and +worker policies using compiler-produced script hashes and external origins. +No Markdown table or deployment template is another header source. + +Declarations own MIME, caching, CSP and isolation policy. SWS owns ETags, +Last-Modified, lengths, encoding negotiation and range metadata. Handwritten +representation metadata and policies weakening required resource headers are +rejected. A whole CSP value is replaced, not implicitly concatenated. + +The build normalizes gzip-packed WASM to decoded `.wasm` bodies. It emits Brotli +and gzip sidecars only when smaller. SWS uses native precompressed serving; +request-time compression is disabled. Browsers fetch the unencoded resource URL, +and Cache Storage retains decoded bodies. Gzip also serves browsers that do not +advertise Brotli, including WebKit on local HTTP. + +SWS's ETags depend on file metadata. Changed bytes at stable protocol URLs must +also change file metadata, even when their length is unchanged. Do not normalize +all releases to one fixed timestamp. The native-server regression in +[testing](testing.md#distribution-checks) checks this behavior. + +### Native server behavior + +The pinned SWS 3.0.0-beta.1 has behavior that the generated configuration and +the tests account for: + +- **Header rule matching.** `[[advanced.headers]]` sources are globs matched + against the request path after internal rewrites, so one exact rule per + physical file (`/ccdp/v1/prefetch.html`) covers its route and its direct + `.html` request. SWS appends `/` before matching only for + a directory-index request (`/dir/`, or any resolved file when + `redirect-trailing-slash = false`). The distribution serves no directory index + and keeps the redirect on, so [sws.ts](../build/sws.ts) never emits that form: + keyed on `/`, it equals the raw path of the 404 beneath the file + and made that 404 immutable. A response that resolved no file (every 404, and + the `308` a directory path such as `/ccdp/assets` gets to `/ccdp/assets/`, + which returns 404) is matched on the raw request path, and every matching rule + applies in config order, later rules overwriting. The first emitted rule is therefore the + catch-all `/**` carrying the error policy, `Cache-Control: no-store`, + `X-Content-Type-Options: nosniff`, `Cross-Origin-Resource-Policy: same-origin` + and `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`, + which `/404.html` itself declares. Each exact rule after it overwrites the + names it declares on its own file; a 200 keeps the catch-all's value for a + name its declaration omits (the CSP on a plain asset, inert outside documents + and workers, which all declare their own). The + [canary test](testing.md#distribution-checks) pins this matching against the + real binary; when it fails on a newer SWS, revisit `sws.ts` and this section. +- **`./config.toml` precedence.** A `config.toml` in the working directory is read + instead of the `--config-file`/`SERVER_CONFIG_FILE` path. Run local binaries from + a directory without one; the image's working directory, `/home/sws`, has none. +- **Unknown keys.** Unrecognized TOML options are ignored, not rejected, so a + misspelled option does not fail startup. The tests check the emitted values. +- **`security-headers` stays off.** It would add HSTS + (`max-age=63072000; includeSubDomains; preload`), `X-Frame-Options` and a + `frame-ancestors 'self'` CSP to every response, overriding declared policy. + HSTS belongs to the ingress on the CCDP origin. +- **HEAD responses carry no `Content-Length`.** GET responses do; harmless. +- **Health and port.** `health = true` answers `GET /health` with 200 and no custom + headers, and the image inherits `EXPOSE 8787`. + +## Bridge and popup integration + +Run the image behind transparent HTTPS ingress on a dedicated cookie-free CCDP +origin. Preserve paths, response headers, validators and compression negotiation. +The server may use plain HTTP internally. Browser HTTP is allowed only for the +supported loopback origins; production platform requests still use HTTPS. + +The container listens on 8787 and answers `GET /health` with 200; use that path +for readiness and liveness probes. Terminate TLS and set HSTS at the ingress; +the server emits none. The container writes nothing, so run it read-only with +all capabilities dropped, as CI does. + +Configure the independently deployed Bridge with the CCDP origin and admitted +application origins. It fetches `/ccdp/callback.html`, inserts deployment JSON +into its non-executable slot, and serves the complete configured document with +matching executable hashes. Callback owns clearing and bundled CCDP selection; +the Bridge injects no code and needs no per-version entry-script table. +The runnable reference configuration is in +[the dev app](../../../apps/dev/README.md), not this package. + +Client derives fixed `/auth/callback` from the supplied Bridge origin and +freezes the redirect URI once; public configuration carries no callback path. X and GitHub use the supplied notary +origin's Proxy WebSocket for both token and identity sessions. Prover performs no +Bridge fetch; its HTTPS fetch sources serve proving assets, while WSS (or the +exact loopback WS exception) serves notarization. Bridge owns Callback refresh. + +Optional opener-independent fallback is supplied through +[build/popup.ts](../build/popup.ts): a module exporting `fallback` and its required +connect sources. Supply the matching adapter to the application's popup +connection. Ceremony includes no WebRTC implementation or signaling service. + +## Publication and upgrades + +The reusable [ccdp-image.yml](../../../../.github/workflows/ccdp-image.yml) +workflow builds the artifact and the `linux/amd64` image, runs the distribution +checks against the running container and the pinned native binary, and pushes +the image only when asked to. Two jobs in +[ci.yml](../../../../.github/workflows/ci.yml) call it. `ccdp-image` runs on +every pull request with a read-only token and builds and tests without +pushing. `ccdp-publish` runs on every push to `main`, is the only CI job that +holds the package write permission, and pushes +`ghcr.io/libid-org/ccdp:sha-` (the full 40-hex sha, never a +prefix) and `ghcr.io/libid-org/ccdp:main`, printing the pushed digest in the +run summary. Every image carries the built commit in its +`org.opencontainers.image.revision` label, with the source repository and the +version (`main` when published, else the sha tag) in the matching OCI labels. +The workflow can also be dispatched by hand for a dry run, which never pushes. + +Publishing a GitHub Release `v` runs +[release.yml](../../../../.github/workflows/release.yml), which publishes +`ghcr.io/libid-org/ccdp:` and, for a stable version (no `-` prerelease +suffix, the npm dist-tag rule), `ghcr.io/libid-org/ccdp:latest`. The release +promotes rather than rebuilds: it retags the `sha-` image of the +released commit registry-side, so the release image is byte-identical to the +image built and tested when that commit landed on `main` — the same digest, +which the job verifies for every new tag and prints next to the source digest +in the run summary. Before retagging, the job reads the image's revision label +and refuses to promote unless it names the released commit. Promotion needs no +retention seed because it publishes that exact image. A release never builds. +Without a `sha-` image (the commit never landed on `main`, or its `main` run +failed) the job fails and names the fix: merge to `main`, let `ccdp-publish` +run, then re-publish the release. Only images published from `main` enter the +`:main` retention history described next; an image built anywhere else would +be missing from it, and its assets could vanish from the next deployment. + +Before building, the workflow pulls the previously published `:main` image and +copies `/home/sws/public` and `/home/sws/distribution-graph.json` out of it into +the build output. The build then checks reused immutable URLs for identical +bytes and policies, retains the previous immutable assets, and replaces the +output only after success, so the compatibility window holds without a +persistent build directory. Only the registry's answer that the image does not +exist (a first publication) skips the seed, with a warning when publishing; any +other pull failure fails the run, so a publication never drops retained assets +silently and is re-run instead. Changing the bytes or policy of an already +published immutable URL fails the build by design; publish changed content +under a new mount. + +Local release builds follow the same rule: build into the existing accumulated +output and preserve the whole of it, including `distribution-graph.json`, +between builds. A fresh empty output cannot retain resources from a previous +deployment. + +Deploy the complete image pinned by digest (the one in the run summary), not by +a tag, which can move. Retention currently covers immutable assets, not an +automatic archive of every protocol version: only v1 is emitted. Adding or +retiring protocol versions needs explicit build support and a +compatibility-window plan. Application, Callback, Prover and root Worker must +remain compatible. + +Before an image goes to a deployment, run +[distribution and browser checks](testing.md), including the actual dependency +loaders and served headers. A compiler-only build does not qualify live CRS, +consent, production Bridge behavior or physical devices. +The current [qualification gaps](qualification.md) remain release gates; the +published image is continuous-integration output, and promoting it to a +deployment is a separate, deliberate step. + +## Build owners + +[distribution.ts](../build/distribution.ts) assembles and promotes the artifact; +[bundle.ts](../build/bundle.ts) records emitted dependencies; +[assets.ts](../build/assets.ts) resolves declarations; +[archive.ts](../build/archive.ts) parses archives without extracting to their paths; +[release.ts](../build/release.ts) caches downloads; +[circuits.ts](../build/circuits.ts) checks capacity; +[sws.ts](../build/sws.ts) writes files, sidecars and native server configuration. diff --git a/ts/packages/ceremony/docs/metrics.md b/ts/packages/ceremony/docs/metrics.md new file mode 100644 index 00000000..5e942576 --- /dev/null +++ b/ts/packages/ceremony/docs/metrics.md @@ -0,0 +1,88 @@ +# Events and measurements + +The [client subscriptions](client.md#events-and-presentation) and popup UI consume +one operation feed. Core event meanings and readiness consequences belong to +[CCDP](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md#event). +There is no separate metrics-record format or telemetry SDK in the documents. +Application owns export, sampling, consent and retention. Full resource accounting +and export are deferred; this guide preserves their required measurement rules. + +## Producers and presentation + +[events.ts](../src/events.ts) owns occurrences, subscriptions, terminal status and +monotonic stage projection. [Barretenberg events](../src/barretenberg/events.ts) +and each platform's `events.ts` own additional operations and separate progress +weights. Pipeline producers use `operation()` or explicit start/finish emission +around concurrent branches. Failed operations need not fabricate a finish. + +Popup UI uses that local feed without an Application roundtrip. Its native bar +counts selected completed operations once, including cache hits; nested parent +operations add no duplicate work. Weights are work estimates, not elapsed-time +percentages. [progress.ts](../src/ccdp/documents/progress.ts) owns accounting and +[ui.ts](../src/ccdp/documents/ui.ts) owns presentation and the bounded paint +opportunity before delivery. A full bar/local delivery cannot claim Client +acceptance. The application may close immediately on the accepted result. + +## Timing + +Events carry producer occurrence timestamps in epoch milliseconds, including +through workers and retrospective forwarding. Repeated overlapping extension +operations use `instrumentation.operationId` for pairing; core operations occur +once. A phase-less observation is not a span. `prover-fallback` is the only +phase-less core event. + +- Total duration: `prefetch-dispatch.started` to the terminal client update. +- Post-authorization waiting: `authorization.finished` to the terminal update. + Callback emits that boundary after capture/clearing and authentication; it + does not measure the instant the user clicked consent. +- Fallback interval: replacement navigation's `performance.timeOrigin`, reported + as `prover-fallback`, through `prover.started`. It excludes pre-navigation source + work and is not a counterfactual extra-cost measurement. +- Interrupted/missing intervals are unavailable, not zero. Never sum overlapping + preparation, witness and attestation spans as elapsed time. Stages describe + presentation, not an exclusive execution waterfall. + +### Prefetch breakdown + +`prefetch-dispatch.finished` carries four durations in `instrumentation.attributes`: + +- `document-startup-ms`: navigation start to Prefetch entry execution, including + document/module loading and evaluation; not the browser's `load` event. +- `connection-ms`: entry execution through authenticated popup readiness. +- `worker-ready-ms`: root Worker registration, activation and legacy-scope cleanup. +- `dispatch-ms`: request to Worker dispatch acknowledgement, including cache lookups + and any cached CRS body reads currently needed before acknowledgement. + +These consecutive intervals use the Prefetch document's monotonic clock. They +exclude Application work before navigation, and do not measure asset download +completion or proving initialization. The dev app displays them in the existing +Prefetch operation's collapsed details. They identify the wait's location without +claiming whether it came from disk, worker startup or network activity. + +## Resource accounting + +Distinguish a resource request, an actual network download and a single-flight +joiner. Several requests can share one retrieval; a cached response is not a +new download. Attribute earlier Service Worker downloads where they occurred, +without counting them again when Prover joins. Missing earlier observations +remain unavailable. + +A native fetch call may reuse HTTP cache, so counting calls does not establish +network traffic or transferred bytes. Use observed transfer data where the +browser and resource policy expose it. The current feed does not claim complete +coverage; [traceability](traceability.md) retains those gaps. + +## Export boundary + +Instrumentation contains bounded scalar attributes, not arbitrary diagnostic +payloads. Exclude credentials, identity data, callback parameters, proofs, +witnesses, attestations, transcripts and raw exceptions. Do not use URLs, origins, +user IDs or error text as metric labels. Export selected fields rather than +copying an entire event. + +A failed or closed lifecycle update includes bounded opaque display text and operation +context. Bounding text is not credential redaction; omit `message` and retained +local causes from telemetry. Undeliverable failure reporting uses a fixed local +diagnostic. Logger, UI and observer errors cannot create another protocol failure +or suppress readiness. Only Client acceptance of `IdentityProof` creates the +completed lifecycle update. diff --git a/ts/packages/ceremony/docs/notarization.md b/ts/packages/ceremony/docs/notarization.md new file mode 100644 index 00000000..924fa606 --- /dev/null +++ b/ts/packages/ceremony/docs/notarization.md @@ -0,0 +1,112 @@ +# Browser notarization + +The [notary module](../src/notary/) adapts the pinned TLSNotary WASM Proxy API. +Platform code owns exact requests, response parsing and disclosure selection; +the adapter owns sessions, transcript bounds, final-frame delivery and correlation. +The [platform specification](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/platform-ceremonies.md) +owns authoritative request/evidence rules. + +## Session lifecycle + +[Notarization](../src/notary/session.ts) owns one WASM runtime/thread pool per +ceremony. Each `prepare(url)` creates a separate TLS session and WebSocket. +Preparation needs a fixed HTTPS target but no bearer, so independent sessions +can prepare concurrently. + +| Operation | Available output | +|---|---| +| `prepare(url)` | One prepared session bound to that exact URL. | +| `session.send(request)` | Original sent/received transcript after one request. | +| `session.reveal(ranges)` | Private commitment openings and a pending `attestation` promise. | +| `await result.attestation` | Complete decoded and correlated final attestation. | + +Transcript parsing and witness construction can use early material while final +attestations remain pending. That material is provisional: delivery must join +all final attestations and the generated proof. A late failure discards the +speculative result. See [X/GitHub scheduling](pipelines.md). + +The supplied abort signal releases the shared worker, including idle prepared +sessions. Any session failure aborts sibling work. Successful sessions release +their own prover/channel; the platform's `finally` abort releases the shared +runtime. Types and exact method constraints stay beside the implementation. + +## Transport and bounds + +The adapter receives the client's frozen notary origin. HTTPS maps to WSS +`/notarize-proxy`; allowed loopback HTTP maps to WS. Platform requests remain +HTTPS. There is no session-creation HTTP request, polling endpoint, alternate +notary selection or browser notary-key lookup. + +[session.worker.ts](../src/notary/session.worker.ts) overlaps socket connection +with shared WASM initialization, then performs target-specific TLS setup. +After TLSNotary finishes, it reclaims the same channel for one length-prefixed +JSON attestation frame and requires EOF. [transport.ts](../src/notary/transport.ts) +owns frame bounds and exact decoding. The separate finalization deadline prevents +an unfinished frame/close from retaining a worker indefinitely; it does not +establish X's authorization-code deadline. + +The adapter admits at most 4 KiB sent and 32 KiB received transcript bytes before +exposing them to parsing/reveal. These are post-receive acceptance limits: the +pinned Proxy runtime does not enforce the supplied setup limits as reception +memory/network caps. Keep that limitation explicit when qualifying resource use. + +## Evidence handling + +[notarize.ts](../src/notary/notarize.ts) validates selected ranges, merges adjacent +reveals as TLSNotary does, and commits the complement. It correlates private +openings, transcript lengths, reveals and commitments with final attested bytes, +including the authority identifier for the prepared HTTPS host. +Missing coverage, wrong framing or correlation failure rejects completion. + +[decode.ts](../src/notary/decode.ts) reads the canonical signed serialization once. +It preserves full-width timestamps without lossy number conversion and returns +`NotaryAttestation { attestedData, signature, decoded }`. It never re-encodes signed +bytes. Its cross-language fixture and digest live beside the decoder tests. +The decoded view contains only signed record data, not private transcript bytes, +bearers, blinders or witnesses. + +Client checks the delivered view's structure, not its agreement with signed +bytes. Neither endpoint verifies notary signatures locally. The ledger verifier +must authenticate the original bytes and derive authoritative identity and +proof inputs from them; convenience views are not alternative evidence. + +## HTTP and platform policy + +[http.ts](../src/notary/http.ts) and [transcript.ts](../src/notary/transcript.ts) +handle shared byte framing and request checks. Platform selectors remain under +`platforms//1/transcript.ts`. JSON whitespace and header order do not establish +identity: selectors work from actual wire offsets, and numeric GitHub IDs are +preserved losslessly. Additional headers are admitted subject to the profile's +required fields and forbidden-header rules; duplicate required headers and +alternate Authorization framing reject. + +Both X and GitHub obtain token and identity through browser Proxy sessions. +GitHub's [token selector](../src/platforms/github/1/token.ts) checks the complete +request against the frozen canonical form before using the returned bearer. +GitHub's identity request uses the browser User-Agent. Exact header values and forbidden +names are owned by code and the specification, not copied here. + +The browser bundle release is pinned in [notary.assets.ts](../src/notary/notary.assets.ts). +Use a matched service/TLSN/MPZ set. Mocked concurrency cannot detect WASM runtime +deadlocks; [runtime browser tests](../e2e/runtime.spec.ts) use real sessions, while +[qualification](qualification.md) retains the live authenticated/device gaps. + +## Attestation measurements + +`token-attestation` and `identity-attestation` start after the response is fetched +and selected, when TLSNotary reveal begins. Their `finished` events carry numeric +`instrumentation.attributes`: transcript/committed byte counts, commitment count, +`openings-ms` from reveal dispatch until openings arrive, and `finalization-ms` +from openings until the final correlated attestation arrives. The former includes +TLSNotary proof work; the latter is the remaining completion wait. These are +parent-observed intervals including worker delivery, not isolated computation +timings. Fetching identity can overlap token attestation. + +`response-header-bytes` and `response-body-bytes` split the raw response at its +first CRLF/CRLF: headers include the status line and separator; body includes any +chunk framing. A missing boundary leaves those attributes absent. Only counts +are retained for instrumentation; transcript contents are never forwarded. + +The notary session owns both event occurrences, using the operation name supplied +by its platform. Failure leaves the operation unfinished. The dev history keeps +operation timings visible and collapses their attributes beneath each operation. diff --git a/ts/packages/ceremony/docs/pipelines.md b/ts/packages/ceremony/docs/pipelines.md new file mode 100644 index 00000000..d9e3fb49 --- /dev/null +++ b/ts/packages/ceremony/docs/pipelines.md @@ -0,0 +1,108 @@ +# Platform pipelines + +Each platform/version owns authorization URLs, accepted OAuth returns, proof and +identity validators, resource declarations, events and its execution pipeline. +Shared Client, message and progress code consult that metadata instead of branching +on provider names. The [normative profiles](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/platform-ceremonies.md) +own authorization encodings and proof statements. + +## Shared execution boundary + +The Prover document dispatches a lazy pipeline with +[ProverContext](../src/platforms/context.ts): the validated request, authenticated +ceremony ID, private OAuth capture, abort signal and event producer. The pipeline +returns separate identity/proof values, or null for valid OAuth denial. Technical +failures throw with operation context. Platform code has no popup connection. + +Validate the return, state and required inputs before credential use. OAuth +parsers ignore bounded provider metadata they do not need while rejecting +malformed encoding, duplicate fields, extra credentials, mixed outcomes and +wrong transport. GitHub also requires its exact issuer on success and error +returns; X does not inherit that requirement. + +Client forwards `notaryAddress` uniformly; Google ignores it, X/GitHub require it. +The URL definition declares PKCE use; Client derives `codeVerifier` or sends null, +and the pipeline enforces its requirement. Neither input selects an asset or a +replacement notary. A second platform must not require another shared-code branch. + +## Google + +[google/1/prover.ts](../src/platforms/google/1/prover.ts) selects the JWK matching +the captured token's `kid` and builds the released `oidc_google` witness. JWT/JWK +input preparation overlaps [proof-worker startup](proving.md#worker-lifecycle). +Google creates no TLSNotary session. + +The signed nonce is parsed canonically as the candidate authorization digest; +there is no separately supplied expected digest or browser signature-verification +step. The circuit checks the signed claims and binding. Downstream verification +must use the recomputed operation digest and trusted Google signing key. +Delivery includes the proof, expiry and modulus, beside the exact signed audience, +subject and email as identity fields. + +## X + +[x/1/prover.ts](../src/platforms/x/1/prover.ts) overlaps these dependencies: + +1. Start the proof engine and prepare token/identity sessions in one + ceremony-owned notary runtime. Each TLS session has its own channel. +2. Send the token request and parse its bearer. Start token reveal/finalization; + the prepared identity session can immediately send its request with the bearer. +3. Parse identity and reveal its selected transcript ranges. Once both sets of + private openings are available, build the `bearer_link` witness and prove + while final attestations continue. +4. Join the proof and both correlated final attestations before returning. + +Only identity HTTP depends on the token. Session setup and proof initialization +do not. Every provisional branch is observed immediately so a failure aborts +siblings rather than leaving work or a promise rejection behind. + +## GitHub + +[github/1/prover.ts](../src/platforms/github/1/prover.ts) uses the same browser +scheduling as X: two Proxy sessions in one notary runtime, with identity HTTP +waiting only for a usable bearer and its prepared session. `token-fetch` ends +before token attestation; proof generation can overlap both final attestations. +Any failure aborts sibling work and prevents delivery. + +[The token request](../src/platforms/github/1/token.ts) contains five canonical +form fields in order: `client_id`, `code`, `redirect_uri`, `code_verifier`, +`client_secret`. The last is GitHub's public application credential, frozen from +Bridge configuration and forwarded unchanged in `ProveIdentity`. The complete +request is revealed; the response bearer and both commitment openings remain +private. There is no Bridge token endpoint call or ordinary browser HTTP exchange. +The [public-client profile](https://github.com/libid-org/libid/blob/5e0e1f690369a7e4c5b61634342ae7fdb975795e/specs/platform-ceremonies.md) +owns this request layout; deployed verifiers must accept that layout. + +X and GitHub share the `bearer_link` circuit, but retain their own transcript +selectors and proof validators. Delivery includes proof and both attestations; +the shared identity is a convenience view, not an additional circuit output. + +## Adding a platform + +For another version-one platform: + +1. Add `platforms//1/` with `url.ts` (including PKCE choice), `types.ts` + (client ID, identity and proof validation), `events.ts` (core operations and + separate UI weights), `.assets.ts` and `prover.ts`. Reuse shared parsers, + notary sessions and circuit adapters only where their contracts fit. Keep + platform-specific selectors and fixtures beside their tests. +2. Register its lightweight definition in [the catalog](../src/platforms/index.ts). + It derives discovery, supported versions and result types. +3. Add the lazy import to [the Prover dispatcher](../src/ccdp/documents/prover.ts) + and resource set to [the asset catalog](../src/platforms/platforms.assets.ts). + Both are checked against the platform catalog. Add any new circuit to the + capacity list in the asset catalog. Emitted JavaScript dependencies come from + the compiler graph; do not copy those URLs into the resource set. +4. Have Bridge advertise the implemented version and any required public + token-exchange credential; set `requiresClientCredential` in the catalog. + A future Bridge service needs an explicit platform-profile contract. Add + configuration and presentation in the [dev app](../../../apps/dev/README.md). +5. Add canonical vectors, malformed-return/input cases, selected-asset/native-loader + checks, actual-popup browser flows and released-key-verified real proofs. + Map applicable [stable test IDs](test-plan.md) in [traceability](traceability.md). + Complete authenticated-service/device qualification before claiming support. + +These three registration points preserve Client/Prefetch import boundaries; +there is no mutable plugin registry. For a **second ceremony version**, also +extend the version-one-only Prover dispatcher and distribution runtime-entry +lookup. Client discovery alone does not implement another version. diff --git a/ts/packages/ceremony/docs/proving.md b/ts/packages/ceremony/docs/proving.md new file mode 100644 index 00000000..c4216a66 --- /dev/null +++ b/ts/packages/ceremony/docs/proving.md @@ -0,0 +1,89 @@ +# Noir and Barretenberg proving + +[src/barretenberg](../src/barretenberg/) owns the dedicated proof worker and +circuit adapters. [Platform pipelines](pipelines.md) prepare inputs and compose +it with notarization; the Prover page owns the browser connection and delivery. +The engine has no popup, ledger or transaction-submission dependency. + +## Worker lifecycle + +[ProofEngine](../src/barretenberg/engine.ts) boots once, accepts one input map, +returns one proof and destroys its worker. Platform owners call `destroy()` in +`finally` to cover abandoned or failed work. AbortSignals retire pending work; +late initialization cannot resurrect a settled engine. + +[engine.worker.ts](../src/barretenberg/engine.worker.ts) starts three independent +branches together: circuit/released-key loading, explicit ACVM/ABI WASM loading, +and Barretenberg initialization. Noir/input readiness permits witness execution +while bb continues preparing. Proof generation joins witness and backend readiness. +`zk-proof-preparation` therefore may overlap `zk-proof-generation`; these events +are not exclusive timing stages. + +The worker requires isolation, shared memory and at least two effective proof +threads. The request is capped at four and available hardware concurrency; +requesting threads alone is insufficient. There is no unisolated or silently +single-threaded proving path. + +The engine supplies each circuit's matching released verification key to +`circuitProve`, avoiding local key generation. Missing or empty keys fail. +The settings explicitly match bb.js's `verifierTarget: 'evm'` ZK-Honk/Keccak mode. +This is the released circuit's proof format, not a blockchain adapter or a +runtime choice based on ledger identity. Browser code does not verify final +proofs; [the Node harness](../e2e/verify.ts) verifies browser-generated proofs +against released keys and rejects altered public inputs. + +## Circuits + +| Owner | Use | +|---|---| +| [oidc_google](../src/barretenberg/circuits/oidc_google/) | Google's JWT witness and named semantic public inputs. | +| [bearer_link](../src/barretenberg/circuits/bearer_link/) | One private bearer opening token and identity commitments, shared by X/GitHub. | + +The circuit repository owns the relation and ABI. Owner asset declarations pin +compiled circuits and their keys together; input modules and adjacent vectors +encode that ABI. They do not define a second proof format. Google result values +are semantic fields; X/GitHub verifier inputs come from signed attestations. +The package's private Google public-input helper supports fixture verification, +not application-side proof verification. + +## Dependency asset resolution + +[barretenberg.assets.ts](../src/barretenberg/barretenberg.assets.ts) is the single +source for ACVM/ABI WASM, bb WASM and native CRS requests. Circuit and notary +assets compose independently. Prefetch and execution resolve the same handles. + +ACVM and ABI receive explicit absolute WASM URLs; bundled worker `import.meta.url` +cannot safely infer their original sibling paths. Noir reuses those initialized +module instances. The build emits decoded bb WASM and removes unused embedded +WASM copies through the compiler plugin. HTTP compression belongs to SWS. + +**The pinned bb.js browser CRS loader ignores `crsPath` as a URL override.** +The engine supplies the declared primary base, but actual native requests still +use Aztec's primary/fallback URLs. CRS stays external; changing declarations to +local resources alone would break prefetch/execution agreement. There is no +fetch interception patch hiding that limitation. + +Exact URL/range/fallback declarations live beside the dependency pin, not in a +second Markdown request table. `SRS_SIZE` and its browser-loader floor rationale +are documented there. [Capacity checks](../build/circuits.ts) inspect the released +circuits without downloading CRS; negative real-proof capacity qualification +remains a separate gate. + +## Upgrade checklist + +1. Change installed dependency pins, the corresponding asset mounts and matching + circuit/key release together. Preserve previously published immutable URLs. +2. Run [native-loader tests](../build/loaders.test.ts). They execute the installed + loaders and observe actual URLs, ranges, fallback, cache modes and explicit + ACVM/ABI initialization. Comparing two copied request lists is insufficient. +3. Rebuild and check emitted scripts, nested workers, WASM policies and compression. + Run the [browser suite](testing.md#browser-tests) from empty and warm caches, + blocking unlisted external asset hosts. A successful typecheck cannot establish + worker startup or dependency-loader compatibility. +4. Verify real browser-generated proofs against the matching released key. Repeat + live CDN/isolation and matched-notary concurrency qualification where affected. + Keep [remaining release gates](qualification.md#remaining-qualification) explicit. + +Fine-grained engine events are declared in +[barretenberg/events.ts](../src/barretenberg/events.ts). They share the +[operation feed](metrics.md); they do not add a second progress protocol. diff --git a/ts/packages/ceremony/docs/qualification.md b/ts/packages/ceremony/docs/qualification.md new file mode 100644 index 00000000..b0ea84d8 --- /dev/null +++ b/ts/packages/ceremony/docs/qualification.md @@ -0,0 +1,98 @@ +# Qualification status + +**Release qualification is incomplete.** Runnable commands belong in +[Testing](testing.md); all 158 stable requirement IDs remain in the +[test index](test-plan.md) and [traceability](traceability.md). A partial row +retains its untested property even when related tests pass. + +## Pinned integration + +These are the component revisions used for the recorded evidence, not a second +configuration source. Change actual pins in the linked declarations/configuration. + +| Input | Evidence baseline / owner | +|---|---| +| Browser specification | PR #13, `374035cf922665507163b07cf81e98afeaf2188b`; [CCDP](https://github.com/libid-org/libid/blob/374035cf922665507163b07cf81e98afeaf2188b/specs/ccdp.md). | +| Circuits | v0.3.0, `91bc3446eeaa50ab2056d88dd9941374aa4fa34c`; [circuit declarations](../src/barretenberg/circuits/). | +| Noir / bb.js | 1.0.0-beta.25 / 5.2.0; [package.json](../package.json), explicit EVM proof settings in [engine.worker.ts](../src/barretenberg/engine.worker.ts). | +| Notary browser/runtime | v0.3.0-rc.3, `37e195035e6b11683b09233a8815ae703e3cc55f`; [declaration](../src/notary/notary.assets.ts), [test services](../e2e/compose.yaml). | +| TLSN / MPZ | `94aaaf33f3361d1218f9abb4c82b5c58a9199460` / `1dd2349d52aeea038d77fb0816f781c6b714fe77`, matched by the notary release. | +| Development Bridge | `ea8121f4e05c39a0b383ecb383e2625feb088c91` (PR #8), standalone HTTP Bridge without libid-rs or TLSNotary dependencies; [Compose pin](../../../apps/dev/compose.yaml). | +| SWS | 3.0.0-beta.1; exact image digest in [ccdp.Dockerfile](../ccdp.Dockerfile). | +| HTTP framing | Spec PR #31, `5afdf08`; [platform rules](https://github.com/libid-org/libid/blob/5afdf08/specs/platform-ceremonies.md). | + +The GitHub browser exchange follows PR #35 at +[`5e0e1f690369a7e4c5b61634342ae7fdb975795e`](https://github.com/libid-org/libid/blob/5e0e1f690369a7e4c5b61634342ae7fdb975795e/specs/platform-ceremonies.md). +It changes the token request's disclosure layout, not the bearer-link circuit. +The released circuit still bounds bearers to 128 bytes; the profile's broader +4096-byte limit is not evidence that this pinned circuit can accept them. + +## Contract alignment + +The client derives fixed `/auth/callback` from the supplied Bridge origin; the +public configuration contains no callback path or redirect URI. Bridge supplies +GitHub's public `clientCredential`, which Client freezes and forwards unchanged. +Prover exchanges GitHub tokens directly through the selected notary. Live consent +with this updated Bridge and matched PlatformVerifier acceptance still require +separate qualification. + +The current event/outcome catalog, authenticated-origin handoff and uniform notary +input forwarding are implemented. They are not pending migrations. Popup owns +transport compatibility; ceremony consumes `@libid/popup`. + +## Evidence obtained + +At implementation commit `79790d9` (2026-09-13), the complete package browser +command passed locally: **127 Playwright cases, no skips**, across Chromium, +Firefox, WebKit, HTTP/HTTPS document flows and mobile emulation. The separate +workspace Browser tests CI job runs that same command. The first +[hosted run](https://github.com/libid-org/libid/actions/runs/34779236470) passed 125 +cases and failed two Firefox cases: Google fixture proof delivery exceeded its +420-second wait, and the two-session notary probe timed out. Those failures remain +unresolved; the local pass is not evidence of CI stability. + +| Evidence | What it establishes / limit | +|---|---| +| Unit and type checks | Client lifecycle, exact codecs, canonical vectors, parsers, concurrency ordering and public types; no proof or live-service authority from mocks. | +| Distribution/native-loader/SWS checks | Emitted policies, compression/ranges, immutable retention and actual loader requests; live CDN availability is separate. Conditional native-server checks require the [explicit inputs](testing.md#distribution-checks). | +| Actual-popup browser flows | Private Callback handoff, exact Application origin, readiness, denial/failure, concurrency, root Worker control and progress across desktop engines and emulation. | +| Google and bearer-link fixture proofs | Actual isolated browser workers generate proofs verified in Node against released keys, including altered-public-input rejection. Controlled Google token/time/JWKS inputs do not establish live consent or JWKS CORS; WebKit intercepts fixture JWKS at the page boundary. | +| Real matched-notary runtime tests | One/two concurrent RC3 sessions alongside a separately verified fixture proof. The new two-endpoint GitHub probe passed in Chromium, Firefox and WebKit using deliberately invalid credentials. X/GitHub probes establish runtime/channel execution and authority correlation, not a successful token exchange or authenticated identity. Earlier RC3 probes also covered localhost and 127.0.0.1. | +| Development app checks | Independent concurrent rows, closure, timings, fallback display and immediate success/denial closure. These use intercepted responses. | +| Bridge integration checks | The pinned standalone Bridge image built successfully. Against the running container, public configuration/credential forwarding, origin admission, response headers, fixed Callback insertion, and removal of the old routes passed. Six Google/GitHub simulated-denial round trips passed through the actual Bridge and emitted CCDP in Chromium, Firefox and WebKit. Only provider returns were intercepted; these checks establish neither live consent nor proof verification. | +| Reported manual use | The developer reported successful manual runs with the updated Bridge and browser GitHub exchange. These observations do not replace a repeatable released-verifier/device qualification record. | + +An earlier intermittent WebKit multi-popup timeout passed unchanged retries; its +cause was not diagnosed. Later complete-suite success does not establish a fix +for that earlier observation. + +## Remaining qualification + +- Diagnose the hosted Firefox proof-delivery and concurrent-notary timeouts above. +- Real approval and denial for every platform against the selected deployed + services, including X/GitHub browser token/identity correlation and GitHub's + fully disclosed five-field token request, followed by released-verifier acceptance. +- Physical iOS/Android devices, Vanadium/JIT behavior, app-installed/absent handoff, + background suspension, memory pressure, eviction, public WSS/mobile networks + and primary DIP notarization. Emulation cannot establish these properties. +- Optional opener-independent carrier/signaling and real openerless returns. + Ceremony supplies the integration point, not a WebRTC implementation. +- **LIBID-BROWSER-010:** X's request-direction deadline has no observable issuance + anchor or request-direction-only completion contract in the pinned SDK. + A response-completion timeout would reject valid responses and cannot substitute. +- Released-verifier acceptance of the updated header/framing and JSON-whitespace + rules with real platform evidence. Matching Rust/browser parsers alone is insufficient. +- Production Bridge conditional/compressed Callback refresh, redirect rejection, + atomic last-good replacement and ingress log redaction. The browser harness does not implement that lifecycle. +- Live CRS primary/fallback availability, readable CORS and Range under both + isolation policies; complete cold/partial/warm/update/restart/quota fault coverage. +- Negative real-proof SRS-floor tests. Build-time gate/capacity checks do not + establish runtime capacity qualification. +- Production ledger definitions and Chain Profile vectors. Tests currently use + synthetic `LedgerId` fixtures and supply no ledger decoder in Prover. +- Complete request/download/joiner accounting, identity-credential-wait extension + and telemetry export. Missing measurements are not synthesized as zero. + +Use the [manual checkpoints](testing.md#manual-consent-and-device-checks) to collect +new evidence. Update this baseline and the affected traceability rows together; +keep individual run logs out of package documentation. diff --git a/ts/packages/ceremony/docs/test-plan.md b/ts/packages/ceremony/docs/test-plan.md new file mode 100644 index 00000000..0854b238 --- /dev/null +++ b/ts/packages/ceremony/docs/test-plan.md @@ -0,0 +1,237 @@ +# `@libid/ceremony` test plan + +This plan tests the `@libid/ceremony` package architecture in +[Architecture](architecture.md) and its browser protocol in +[CCDP](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md), its deployment in [Distribution](distribution.md), its +proof-generation subsystem in [Proving](proving.md), and the [OAuth bridge +contract](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/oauth-bridge.md). +Generic popup creation, connection, carrier, continuity, control, and local +diagnostic behavior is qualified by the +[popup package test plan](../../popup/TEST_PLAN.md). Rows here test only ceremony's +use of that package. +Normative proof and platform behavior come from the +[common ceremony rules](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ceremony-common.md) and +[identity-platform ceremonies](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/platform-ceremonies.md). + +Every row is one stable requirement and may require multiple positive and +negative cases. Tests **must cite its ID**. Existing IDs retain their names; +never renumber or reuse one. + +A row belongs here only when it exercises `@libid/ceremony`, an emitted browser +asset, or a server/browser contract declared by the package. Composition-owned Job +transitions, wallet preparation or confirmation, transaction submission, and +post-ceremony recovery belong to their respective composition test plans. + +Keep simulations labeled and separate from real-platform qualification. +GitHub qualification requires real browser Proxy token exchange and `/user` +notarization, both correlated attestations, and a generated proof accepted by +the matching released verifier. Timings or simulated success labels do not substitute for these +checks. + +## Package and asset isolation + +| ID | Assertion | +|---|---| +| LIBID-MOD-002 | The internal `ccdp/documents/prefetch` entrypoint supplies the entry code embedded by Prefetch and the module Service Worker served at `/ccdp/v{CCDPVersion}/worker.js`. Prefetch registers it with root scope; its Window branch dispatches the selected profile, while its Worker branch composes `@libid/popup/worker` continuity, immutable-asset Cache Storage single flights, and raw-CRS Cache Storage single flights; processed-CRS caching remains inside bb.js backend initialization. It remains compatible with live CCDP versions and passes unrelated requests through unchanged. Both Prover responses embed the same `ccdp/documents/prover` entrypoint and configure `PopupConnection.accept` with isolation fallback; ceremony code contains no carrier or isolation-selection implementation. These bundles contain no final-proof verifier, callback, React/UI framework, wallet key, connector, Registry submission, or wallet-policy code. | +| LIBID-MOD-003 | The CCDP Distribution builds `/ccdp/callback.html` from the `ccdp/documents/callback` entrypoint with every supported Callback implementation, complete dependencies, normal views, inline libID logo, and built-in styles. The OAuth Bridge inserts only deployment data and serves that complete document. No separate loader, logo, stylesheet, UI template, theme, renderer, stylesheet-hash configuration, or application customization input exists. The implementation contains no proving code, React/UI framework, wallet key, connector, Registry submission, or wallet-policy code. | +| LIBID-MOD-011 | `@libid/ceremony/ccdp/client` exact-validates one `CeremonyConfig` record, including its canonical CCDP origin, each nonempty duplicate-free `ceremonyVersions` list and any public `clientCredential` (required for GitHub), and fetches it once per client. CCDP [resources](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md#documents-and-routes) never fetch it. | +| LIBID-MOD-012 | The Application opens `/ccdp/v{CCDPVersion}/prefetch`, whose embedded entry code owns selected-profile fetching and the shared Worker. After OAuth, Callback navigates directly to the logical `/ccdp/v{CCDPVersion}/prover`; only after isolated connection readiness does Prover emit `Event(prover, started)` and accept proof input. The resources use one compatible package release, with no separate isolation-stage module or protocol step. | +| LIBID-MOD-013 | `@libid/ceremony` contains no OAuth Bridge handler or deployment credential. The GitHub leaf exchanges its code in a browser Proxy session using the public `clientCredential` supplied by configuration. All browser modules build with the Bridge implementation unavailable. | +| LIBID-MOD-014 | After `conn` and `ceremonyId`, `CCDPClient.new` takes the positional operation arguments enabled `platformId`, `ledgerId: LedgerId` from `@libid/ledger`, a 32-byte `operationDomain` hash, and bounded opaque `transactionData`, followed by optional `ceremonyVersion`. Missing or throwing required ledger methods, malformed hash lengths, and invalid notary addresses fail before OAuth. There is no input-object overload or parameter for raw `chainId`, network classification, separate notary URL, authorization nonce/digest, OAuth client, redirect, proof-domain, Registry, or finalizer. Strict TypeScript compilation rejects the former object form, missing arguments, and type-incompatible argument order. | +| LIBID-MOD-015 | For every successful `CCDPClient.new`, the client calls `ledgerId.hash()` once, validates/copies the exact 32-byte hash as `chainId`, and copies the operation domain and transaction data. The client also calls `ledgerId.notaryAddress()` once and validates/freezes its result for every platform, without branching on the platform. The client accepts an explicitly selected mutually supported `PlatformCeremonyVersion`, or defaults to the numerically greatest common version independent of list/object-key order, then draws a fresh cryptographically secure 32-byte nonce, and reproduces the normative authorization digest and code verifier using the retained Chain Profile hash. Later method replacement or buffer mutation changes neither digest nor address; subsequent work uses snapshots rather than the caller ledger object. A fixture changing only the notary address retains the ledger hash and, with the same nonce and operation inputs, the same digest. Explicit lower-version selection is retained in the prefetch fragment, authorization binding, Prover request and result; unavailable/malformed versions fail before ledger reads or run reservation. Hashes match the ledger package vectors without serialization or reconstruction. CCDP carries only the resolved notary address, not ledger data. | +| LIBID-MOD-016 | `@libid/ceremony/ccdp` builds without platform, browser, network, storage, Job, wallet, or UI code. All participants share the five message definitions and decoders and enforce ceremony order themselves. `Event` carries an operation name, optional phase, occurrence timestamp, and optional nested `instrumentation.operationId`/`instrumentation.attributes`; it contains no UI stage, label, progress percentage, or ceremony status. Generic connection and carrier behavior remains outside the ceremony package. | +| LIBID-MOD-017 | One application-scoped `CCDPClient` fetches and validates `CeremonyConfig`, excludes configured platforms missing from its closed implementation or sharing no ceremony version with it, and creates independent stateful ceremonies through `new(conn, ceremonyId, platformId, ledgerId, operationDomain, transactionData, ceremonyVersion?)`. Each Ceremony owns its connection handlers and one-shot state; the client keeps no ceremony-ID routing table. Calling `new` for an excluded platform fails before launch. No package-global singleton, carrier constructor or options, generic Host, mutable connection setter, Job-store dependency, Registry callback, or composition plugin exists. | +| LIBID-MOD-018 | A created `Ceremony` retains the caller-constructed `PopupConnection` and exposes only its fixed `launchUrl`, event subscription, argument-free `proveUserIdentity`; authorization is not exposed as a separate instance property. `launchUrl` is the frozen `${ccdpOrigin}/ccdp/v{CCDPVersion}/prefetch` URL with a closed ceremony-ID/platform-ID/platform-ceremony-version fragment. The caller owns the browsing-context target, while the popup package owns synchronous creation, native-anchor fallback, connection authentication, navigation, and closure. Ceremony exposes no public platform authorization URL, asset path, popup target or handle, carrier option, or mutable connection setter. | +| LIBID-MOD-019 | Strict TypeScript compilation preserves the catalog-derived platform/version proof type through generic Ceremony wrappers and `validateProofMessage`. A literal platform gives the corresponding `Identity` and platform proof in separate fields; unsupported platform/version keys and incompatible result pairs fail. Dynamic callers use a result guard for platform-specific fields; checking only nested `identity.platformId` does not narrow its sibling. The CCDP identity/proof message requires no platform catalog import. | +| LIBID-MOD-020 | Each `CCDPClient.enabledPlatforms` is an immutable intersection of `supportedPlatforms` and validated `CeremonyConfig` keys having at least one mutually supported ceremony version, in catalog order. `enabledVersions(platform)` returns the immutable package/Bridge version intersection in ascending order, empty for a known disabled platform and rejecting unknown platform IDs. Neither discovery API exposes OAuth clients, server configuration or display metadata; mutating returned values cannot change ceremony admission. | +| LIBID-MOD-021 | Inspect both the static import graph and emitted Prefetch/Worker bundles: platform asset metadata reaches no platform client/prover implementation, Noir/bb.js execution library, notarization runtime, filesystem, archive extractor, or glob matcher. The `assets` helper leaves only resolved metadata and browser-safe URL resolution in these bundles. Execution modules import lightweight asset declarations, never the reverse; tree-shaking alone cannot satisfy this boundary. Fetching emitted dependency scripts into the cache neither imports nor executes them, and `/prover` HTML/embedded entry code is fetched only by navigation. | +| LIBID-ASSET-001 | The CCDP artifact pipeline derives first-party filenames and dependency edges from compiler/bundler output and each non-imported proving resource from its single owner-declared logical role. One code-owned response-profile table supplies routes and fixed policy; emitted bytes and paths supply CSP hashes/sources and media-specific values. Rename an internal entry, chunk, Worker, or emitted WASM: the validated graph, references, static files, and generated `sws.toml` update without another filename list, generated-source scrape, or route edit. Immutable source downloads are reused. A missing referenced body, unindexed static dependency, malformed external pin, mutable asset path, missing or invalid declared policy, unrepresented file, or partial graph fails before output replacement. Intentional ordered header-rule overlap is not an error; effective served policy is qualified by KIT-001B. The graph has no separate serialization or browser-visible manifest. | +| LIBID-ASSET-002 | Callback, Prefetch, and Prover run their embedded entry code without a browser-visible manifest or second entry-script request. Callback's entire supported implementation set and dependencies are bundled; no dynamic import, bridge-relative chunk, or external entry script is needed. Wrong media type, blocked executable code, or malformed embedded configuration fails before protocol execution. | +| LIBID-ASSET-003 | The distribution build pins Noir and bb.js JavaScript, workers, WASM, native external CRS requests, notarization-client paths, and circuit paths, but no Notary Service addresses. CCDPClient uses `ledgerId.notaryAddress()` without profiles, environment lookup, or an override option. Test mainnet/testnet ledger definitions and a fixture with the same ledger hash but a local HTTPS notary. Missing, throwing, empty, nonstring, or noncanonical address results fail `new` before OAuth without default fallback. Interleave those ceremonies: live addresses remain frozen, and failure never switches domains. All use identical client/Distribution builds and shared asset caches. Google receives the same resolved address but ignores it and opens no notary connection. Applications cannot replace proving dependencies; JavaScript, workers, and local assets remain immutable CCDP resources while CRS retains its declared Aztec URLs. | +| LIBID-ASSET-004 | A `PlatformCeremonyVersion` owns one platform's authorization-digest construction, OAuth grammar, proof construction, proof type and validator, and final `OAuthProof` assembly; the ceremony client validates the explicitly selected version against local support and platform configuration, or defaults to their numerically greatest common version, and freezes it in the live Ceremony, `ProveIdentity`, and accepted `OAuthProof`. `platforms/authorization` supplies shared digest and PKCE helpers but has no independent version or mandatory policy: each platform-version slice owns whether and how it uses them. Chain, Registry, and contract-verifier versions do not affect that selection, and distinct Consumers may accept the same output. There is no independent proof-version axis. | +| LIBID-ASSET-005 | Changing one platform ceremony version does not version another platform or `CCDPVersion`; the application may select an enabled version explicitly, and `CeremonyConfig` constrains selection to its advertised set. The chosen version may represent different features or disclosure behavior; omitting selection uses the highest common version. | +| LIBID-ASSET-006 | Every live Prefetch, Callback and Prover page retains its received code and configuration; the client dispatches the ceremony's exact ceremony version and never resolves `latest` during a ceremony. | +| LIBID-ASSET-007 | Every live `Ceremony` and `ProveIdentity` pin the exact public `PlatformCeremonyVersion`; client and prover artifacts retain every version they still accept during their compatibility window and never resolve `latest` after creation. Callback owns URL capture/clearing, bundled CCDP selection, deployment-input validation, connection acceptance, and its CCDP behavior. An unsupported version cannot be consumed and requires a fresh ceremony. | +| LIBID-ASSET-008 | The OAuth Bridge advertises only platform/version pairs present in its configured CCDP Distribution; no shared deployment system is required. Prefetch and Prover use one code-pinned asset tree: X and GitHub share the notarization-client and circuit paths, while every selected profile fetches only its own set. No runtime or browser asset-source configuration or catalog response exists, and launch does not runtime-hash assets. | +| LIBID-ASSET-009 | A real same-origin worker graph passes with every nested bb.js/TLSNotary worker and no cross-origin worker bridge or unisolated fallback. | +| LIBID-ASSET-010 | Rotate `CeremonyConfig` while a ceremony is live: the browser continues with the exact client ID, public token-exchange credential, redirect URI, CCDP origin and ceremony version frozen at creation. A fresh `CCDPClient` uses current configuration. CCDP resources perform no mid-ceremony configuration fetch. | +| LIBID-ASSET-011 | Every profile resolves the same code-pinned ACVM, Noir ABI, bb.js prover assets, and launch-wide 2^18-point BN254 SRS plus fixed Grumpkin data. X and GitHub additionally select the same code-pinned notarization client and `bearer-link` circuit; Google selects its code-pinned `oidc_google` circuit. The first platform starts the fixed SRS flight set, and every later platform reuses it without a size upgrade or refetch. Only missing profile-specific assets are fetched. | +| LIBID-ASSET-012 | A cleared `/ccdp/v1/prefetch#ceremonyId=&platformId=&ceremonyVersion=` document runs the CCDP v1 Prefetch implementation, whose Window branch and Worker fetch only the exact selected ordinary-artifact profile and fetch the pinned raw CRS bodies without initializing WASM/proof backends, TLSNotary sessions, or custom CRS preprocessing. Importing bb.js alone fetches no CRS. The fragment cannot supply a CCDP version, document role, asset path, or SRS size. Profiles for other enabled platforms remain unfetched; a later `ProveIdentity` selects the same profile and cannot widen it. | +| LIBID-ASSET-013 | Circuit-release conformance records the pinned EVM circuit sizes and actual SRS floors: `bearer_link` 42,006 fails at its mathematical 2^16 ceiling because bb.js 5.2 requires a positive multiple of its 4 MiB compressed-input chunk and succeeds at 2^17; `oidc_google` 179,443 succeeds at 2^18 and fails at 2^17. The checked-in launch-wide SRS constant is 2^18 and every profile proves under it. Browser user agent and deployment data cannot alter it. A changed circuit or bb.js requirement exceeding that constant fails the build. Splitting the fetch size later requires performance evidence, not a platform ceremony version change while proof semantics remain identical. | +| LIBID-ASSET-014 | Stage and promote a CCDP Distribution with one unchanged proving asset and one changed WASM body. The promoted distribution is graph-complete; the unchanged asset retains its URL and warm-cache reuse, the changed bytes use a new URL, and both old and new URLs remain retrievable through the compatibility window. Prefetch waits for the newest installing or waiting Worker to become active rather than dispatching to the stale active Worker, and activation does not proactively purge reusable immutable Cache Storage or bb.js CRS entries. | +| LIBID-ASSET-015 | Versioned Prefetch and Prover paths select one CCDP implementation. OAuth `state` selects the matching implementation already embedded in the aggregate Callback document, without another request. The build's single supported-version selection generates Callback dispatch and versioned resources. Exercise two bundled CCDP versions sharing the same unversioned input list: both work without changing the Bridge binary or deployment configuration. A compatible optional trailing input is defaulted when absent by the newer implementation and ignored by the older one; neither changes the required origin fields. The Bridge inserts existing validated deployment values without enumerating CCDP versions, reading input declarations, parsing OAuth state, or performing browser dispatch. After the compatibility window, rebuild without one implementation and verify that it shows only the local unsupported-version screen, while the retained version still works. The removed version constructs no connection, loads no retired-only transport, emits no abort or denial, and never falls forward. Fragments and CCDP messages do not repeat selection. The OAuth Bridge API namespace, `PlatformCeremonyVersion`, and popup `ConnectionVersion` remain independent. | +| LIBID-ASSET-016 | The artifact build uses standard Brotli and gzip tooling to emit `.br` and `.gz` sidecars for each locally served protocol resource or proving asset, each only when smaller and decoding byte-for-byte to the original. Native SWS handling negotiates those representations under the original URL, media type, security policy, and cache policy with the matching Content-Encoding and Vary: Accept-Encoding; without an accepted sidecar, it serves the original. HTTP-only gzip acceptance, HTML, JavaScript, WASM, circuit, and incompressible local fixtures cover negotiation and uncompressed fallback. External CRS is not emitted or compressed by this build. Custom negotiation middleware, runtime compression, zstd, and caller-selected compression paths are absent. | +| LIBID-ASSET-017 | Actual pinned Noir/ACVM, bb.js, and TLSNotary initialization uses emitted JavaScript/workers/WASM/circuits plus the declared Aztec CRS hosts; unlisted external asset hosts are blocked. Qualify live CDN URLs, response lengths, and readable CORS under both Prover isolation responses, with empty caches and forced primary failure. Repeat after selected-profile prefetch with no network refetch of the same cached URL/range, then with partial caches, concurrent profiles, and a stopped/restarted Service Worker. | +| LIBID-ASSET-018 | Run the installed bb.js browser loaders with an observing fetch stub: actual URL, method, Range, cache mode, and primary/fallback sequence must match the reviewed request set in [Proving](proving.md). A changed, added, or removed request fails until prefetch, CSP, and size accounting are reviewed together; copying constants or automatically updating expectations is insufficient. The bundled JavaScript uses supported `wasmPath` for the emitted threaded WASM; no CDN JavaScript import, duplicate default-WASM download, CRS URL substitution, or stripped Range occurs. For bb WASM, the emitted `.wasm` body equals the decoded pinned package artifact, the actual loader requests that URL, and prefetch records its decoded length and WASM MIME. | +| LIBID-ASSET-019 | Concurrent prefetch/execution joiners each receive a readable complete response, not a consumed shared body. Local assets with failed status, redirects, wrong MIME, or decoded-length mismatch never populate the cache; external CRS follows LIBID-ASSET-021. Failed flights are removed for a subsequent cold fetch. Canceling one joiner stops its wait without canceling another's shared asset download. | +| LIBID-ASSET-020 | With both `/` and stale `/ccdp/v1/` Service Worker registrations present, even with the identical script URL, Prefetch and both Prover responses resolve the canonical root registration. Same-origin isolated navigation claims through that root, including a participating destination outside the nested scope. Exercise Chromium, Firefox, and WebKit; no longest-scope or script-URL-only selection passes. | +| LIBID-ASSET-021 | Exercise raw CRS prefetch and actual loader reads with `206` prefixes, `200` G2, primary failure, unexposed Content-Range, and absent MIME/cache headers. The range-aware cache retains validated bytes without passing a `206` to Cache.put; reconstructing the response preserves the requested prefix. Different ranges/full-file requests never share a prefix entry. Ignored ranges, wrong exposed ranges, truncated bodies, opaque responses, and unlisted destinations are not cache hits. Concurrent joiners and later workers reuse the same URL/range without another download. | +| LIBID-ASSET-022 | Resource resolution supplies both `wasmPath` and `crsPath` to every bb.js backend initialization and drives the same prefetch requests. `assets.external(url, { range })` retains the original HTTPS URL and request Range; `assets.resolve()` synchronously returns that URL unchanged, without a fetch. Ordinary artifact generation makes no external-resource request and emits no body, route, or response profile for it; generated fetch CSP admits its origin. Prefetch and execution preserve the declared Range, or perform the declared full-resource request when omitted. Current unpatched bb.js accepts only the native external CRS declaration; a custom-base loader probe must pass before distributed CRS is allowed. With the patch, an explicit base routes G1/G2/Grumpkin consistently without hidden CDN fallback, and source-separated raw/processed caches prevent a prior source from bypassing that selection. | +| LIBID-ASSET-023 | Platform/version asset sets reference shared owner declarations rather than copying their source, mode, or request parameters. The lightweight catalog covers exactly the supported platform/version pairs. A shared distributed resource referenced by multiple profiles is emitted once while each profile retains it; changing its owner-defined source updates build resolution, prefetch, and actual loader locations together. Only the selected set is fetched, and code controls location/mode without a deployment-override API or browser manifest. | +| LIBID-ASSET-024 | Build `assets.archive(source, mount)` from HTTPS, package-relative, and absolute local archive sources. Only files declared with `member()` are served beneath `/ccdp/assets//`, with their archive-relative paths intact; undeclared members are absent from a fresh output. Runtime companions must be declared too so archive-relative imports still work. A shared archive is materialized once, but only selected-profile dependencies are prefetched. Traversal, absolute member paths, links, duplicate entries, and conflicting public-path declarations fail before output replacement. | +| LIBID-ASSET-025 | `member('snippets/web-spawn-*/js/spawn.js', headers)` resolves exactly one full archive path at build time, with `*` confined to a directory component. Zero and multiple matches fail. `assets.resolve(member)` returns the actual absolute mounted URL under the executing CCDP origin, independent of the bundle's directory, never the glob or archive-source URL. Changing the matched directory in a newly pinned release updates serving, prefetch, and loader references together without a handwritten filename map or runtime lookup. | +| LIBID-ASSET-026 | Resource declarations and protocol profiles compose shared `assets.headers` groups and explicit policy fields. Declared archive members receive immutable/MIME defaults plus their declared headers; worker members receive their declared execution policy. No declaration supplies ETag, Last-Modified, Content-Length, Content-Encoding, or Content-Range; these and conflicting case-insensitive header names are rejected. The pinned SWS v3 binary, not a fake HTTP server, supplies correct metadata for original/Brotli/gzip, HEAD, conditional, and range responses without fixed path-header overrides or custom HTTP handling. Exercise identity, Brotli and gzip representations; all sidecars decode to the same canonical body and retain its policy. Gzip fallback covers browsers that do not advertise Brotli over localhost HTTP. | +| LIBID-ASSET-027 | Rebuild a stable protocol URL with different bytes of the same length and assemble the next distribution image. Its file metadata and native SWS ETag change; a request carrying the previous validator returns the new body rather than a stale 304. Timestamp normalization or container assembly that preserves the old validator fails qualification. No custom ETag algorithm is introduced. | + +## Platform proving + +| ID | Assertion | +|---|---| +| LIBID-PROVER-001 | Every enabled profile maps in closed code to one circuit release artifact and compatible Noir/ACVM and bb.js versions, while the package supplies the same 2^18-point BN254 SRS and fixed Grumpkin data to all of them. Generation supplies the matching released verification key through `circuitProve` with the exact bb.js 5.2.0 `verifierTarget: 'evm'` settings (ZK-Honk/Keccak). The same selected key is prefetched and loaded; missing/empty keys fail instead of triggering recomputation. A real browser proof for every profile verifies against its matching released verifier artifact/key; a roundtrip through a verifier sharing the generation defaults is insufficient. Changing circuit bytes, ABI, public-input order, proof system, or bb.js compatibility without the required platform ceremony version update fails; changing the shared SRS fetch policy alone does not require one while proof semantics remain identical. The selected closed platform/version profile fixes these dependencies. Callers may select an enabled ceremony version but cannot supply different circuit bytes, keys, proof modes or asset URLs; callback data, user agents and Ledger Verifier contracts cannot override them. | +| LIBID-PROVER-002 | Google v1 emits its defined spans, strictly parses the ID Token/JWK, rejects a noncanonical or non-32-byte nonce, matches the audience to the frozen client, builds the witness, and delivers `GoogleProofV1` containing `identityProof`, expiry, and modulus beside shared `identity` from exact signed `aud`/`sub`/`email`. No nested identity copy, attestation, or flattened inputs are delivered. The circuit verifies RS256 and binds the canonical nonce to its candidate digest input. Neither browser endpoint separately compares it to the expected authorization digest; Client checks structure without extracting identity or verifying the proof. An otherwise valid token for another digest can produce a browser result but its proof fails ledger verification with the recomputed digest; a valid proof under an untrusted modulus fails the trusted-key check. The pure ledger adapter takes the digest, separate identity, and platform proof to pack the exact 56 verifier inputs; ordering, length, packing, and one-byte mutations are vector-tested. | +| LIBID-PROVER-003 | X v1 emits its defined spans, starts token/identity setup concurrently, sends the identity request only after parsing the token-response bearer, validates the bounded printable bearer, commits it with independent 16-byte blinders, reveals only the prescribed ranges, and proves `bearer-link`. It delivers `bearerLinkProof`, token and identity attestations with complete decoded views, and `identity` extracted from the attested client identifier, `id`, and `username`. No flattened public-input array is delivered; commitments reconstructed from the original attestations verify the proof. | +| LIBID-PROVER-004 | GitHub v1 overlaps browser token/identity session setup and proof-backend initialization. Exercise both setup/token completion orders; `/user` HTTP waits for a usable bearer and prepared session, not final token attestation. Token and identity requests use one runtime and the same supplied notary. The canonical five-field token body reveals the complete request in one range; duplicates, reordered/extra fields, noncanonical encodings and changed frozen bindings reject. Proof generation may overlap both final attestations; delivery waits for all correlated outputs. Setup, transcript, final attestation or proving failure aborts sibling work, preserves operation context and discards provisional results. Closure retires pending and prepared sessions. Neither Prover nor Client verifies notary signatures locally: distinguish structural/correlation rejection from a well-shaped forgery that requires trusted-notary verification downstream. | +| LIBID-PROVER-005 | The X and GitHub profiles point to the same immutable `bearer-link` circuit path and resolve the same CCDP-origin notarization-client module/WASM pair and code-pinned prover toolchain. Cross-platform prefetch reuses those cache entries; platform-specific OAuth, token exchange, transcript construction, parsing, and progress remain in their respective closed modules and cannot alter the circuit ABI. | +| LIBID-PROVER-006 | The selected notary release supplies sibling `tlsn_wasm.js` and `tlsn_wasm_bg.wasm` files built from the same notary release, including `set_progress_callback`, with the worker bootstrap embedded in the module. X and GitHub use that pair as independent immutable responses; Google neither fetches nor initializes it. A missing release member fails before a notarized request. | +| LIBID-PROVER-007 | Every applicable successful core operation emits one `started` and one `finished`; inapplicable operations are absent and interrupted work invents no finish. Test allowed overlap between setup, token/identity work, backend preparation, ZK generation, and final attestation work. Dependencies hold without globally sorting operations. Repeated concurrent extension operations pair by instrumentation-only operation ID. Event timestamps survive forwarding unchanged and disclose no URLs, credentials, identity, witnesses, proofs, or raw errors. | +| LIBID-PROVER-008 | Only X/GitHub token and identity requests call the internal notarization adapter. Callers prepare a code-owned HTTPS request URL with the ceremony's resolved canonical HTTPS `notaryAddress` and an AbortSignal, then supply the matching exact request and deterministic reveal selector; the adapter enforces 4-KiB sent/32-KiB received acceptance ceilings, derives the selected notary's fixed Proxy WebSocket, and accepts one bounded final attestation frame after TLSNotary releases the channel. Test actual returned transcript lengths at each ceiling and one byte over, with the SDK permitting oversized transcripts: over-limit data rejects before `send` resolves, platform parsing, or reveal, without truncation. Merely asserting supplied SDK limits is insufficient; post-receive checks do not demonstrate receive-time memory or network caps. It also rejects malformed framing, truncation, overflow, duplicate output, and redirects. Only the required bearer/opening/attestation survives each call. | +| LIBID-PROVER-009 | Before the final attestation promise resolves, every raw TLSNotary commitment and opening maps exactly once by direction, range, and value to the read-only decoding of the original signed attested-data bytes. For each X and GitHub token/identity pair, the uniquely framed bearer commitments equal `SHA256(accessToken || bearerOpening)` and their matching TLSNotary hashes, and the two openings are independently generated exact 16-byte values. Wrong direction, shifted range, changed hash, changed opening, duplicate or missing commitment, malformed layout, or correlation against re-encoded rather than original bytes fails. | +| LIBID-PROVER-010 | The read-only decoder reproduces every field and the pinned digest of the upstream `libid-rs` fixture referenced by NOTARIZATION.md. Its complete output is retained as `NotaryAttestation.decoded` with no second parse or new reveals. `createdAt` preserves u64 values, including zero, values above the safe-number range, and u64 maximum, as canonical decimal strings; projection validation rejects noncanonical spelling and overflow. Valid one-byte mutations change the field/digest; malformed mutations, truncation, trailing or reordered bytes, oversized counts, overflow, invalid ranges, and alternate encodings fail. Received signed bytes are never re-encoded. | +| LIBID-PROVER-011 | Progress calculation is an implementation-owned projection over the shared event stream, not a wire field or protocol authority. Test serial and overlapping operation orders, cache hits, and repeated extension spans: displayed progress stays finite and monotonic, never double-counts work or claims completion while required proof/evidence work remains. A failed or incomplete span invents no progress; local delivery of `IdentityProof` does not assert Application acceptance. | +| LIBID-PROVER-012 | Delay runtime import and asset prefetch independently: they start concurrently. After both finish, platform input preparation and proof-worker startup overlap. Barretenberg initialization, circuit/key loading and ACVM/ABI WASM initialization start independently with explicit emitted URLs. Readiness for inputs waits for circuit/key and ACVM/ABI; Noir reuses the initialized modules. Witness execution requires those resources and private inputs, and may overlap backend initialization. Proof generation waits for both witness and backend. Exercise both backend/resource and backend/witness completion orders. | +| LIBID-PROVER-013 | With fake notarization sessions, both X setups begin before the token response is available. Identity HTTP waits for the parsed bearer but not token reveal/final attestation. Reveal material and both openings permit witness/proof execution before either final attestation resolves; proof delivery waits for both attestation correlations and the generated proof. | +| LIBID-PROVER-014 | Complete speculative X proof generation, then fail either final attestation's framing or commitment correlation: no proof is delivered and provisional outputs are discarded. Cancellation or setup/input/backend failure releases sibling workers and sessions, including sessions prepared but never sent. Resource/backend initialization failure reports promptly without waiting for pending siblings; a backend that finishes before or after a sibling failure is destroyed exactly once. Cover witness failure while backend initialization is pending and backend failure while witness execution is pending. Cancellation terminates their owning worker; late resource, witness or proof completion cannot revive a failed run or deliver a result. | +| LIBID-PROVER-015 | Request at most four proof threads, capped by hardware concurrency. Check isolation and shared memory inside the actual proof worker and report effective thread count. Missing capabilities, failed worker startup, or silent single-thread execution fails explicitly rather than appearing as a successful multithreaded run. TLSNotary pool sizes are measured separately and never inherit the proof thread cap implicitly. | +| LIBID-PROVER-016 | Pre-OAuth prefetch starts only raw asset/CRS fetches and shares pending work across concurrent requests. Terminate its worker with a partially populated cache: Prover reuses completed bytes and fetches only missing resources. A retained processed-CRS entry is reused by native bb.js initialization; no custom pre-OAuth CRS processing or initialized backend/session survives OAuth. | +| LIBID-PROVER-017 | Session preparation derives its TLS target from the code-owned URL before any HTTP input is sent. `send` with a different URL fails before sending; matching requests may supply headers/body later, enabling parallel identity-session setup without its bearer. | +| LIBID-PROVER-018 | Abort before preparation opens nothing. Abort during setup, idle preparation, send, reveal, or final-attestation retrieval rejects pending work, closes the socket, releases the ceremony-owned worker and session buffers, and prevents later sends. A completed session releases its own prover and channel without terminating a sibling. Ceremony cleanup releases the shared WASM worker; any session failure aborts the whole runtime and abandons no prepared session. X initializes WASM once for both sessions, with independent transcripts and reply channels; shared initialization must not serialize setup. Each session opens its WebSocket alongside shared runtime initialization; setup waits for both in either completion order and requires a still-open socket. Runtime failure closes a pending socket; connection failure rejects without waiting for the runtime, and late initialization cannot start setup. | +| LIBID-PROVER-019 | Release qualification uses the actual browser TLSNotary WASM and matched Notary Service: X token and identity setups run concurrently with real proof-backend initialization, identity sends only after its bearer arrives, both final attestations correlate, and the generated proof passes the matching released verifier. Exercise Chromium, Firefox, WebKit, and real mobile browsers with declared thread pools; mocked orchestration or isolated single-session success does not qualify concurrency or exclude runtime deadlock. | +| LIBID-PROVER-020 | Delay X's bearer independently of identity-session setup. The extension operation `identity-credential-wait` starts after setup and finishes when the bearer is usable; an already-available bearer still emits one started/finished pair without a delay. Its interval is separate from setup/request latency and no HTTP session-creation span is emitted by the WebSocket-only pipeline. | +| LIBID-PROVER-021 | Roundtrip each platform delivery through supported popup carriers with its shared identity, bytes, arrays, and decoded decimal timestamps intact. Hidden bearers, openings, and witnesses are absent. For X/GitHub, changing a structurally valid convenience `identity` or `decoded` view does not change ledger serialization or authoritative verification; original attested-data/signature bytes remain the only attestation inputs. Google identity fields remain verifier inputs and are not omitted. | + +## Ceremony client and Ceremony Cross-Document Protocol (CCDP) + +| ID | Assertion | +|---|---| +| LIBID-OAUTH-001 | Derive fixed `/auth/callback` from the supplied Bridge origin and reject public redirect/path fields, including retired `callbackPath`. The application-scoped client fetches and exact-validates OAuth-bridge-owned `CeremonyConfig` once; unknown record fields, missing or wrongly typed values, noncanonical, empty, or out-of-range client, redirect, CCDP origin, known-platform, or ceremony-version values, and duplicate ceremony-version entries fail before navigation. `allowedAppOrigins` and `allowedOrigins` are rejected as unknown public fields. An unknown platform entry is ignored; an unknown but valid ceremony version is ignored when another advertised version is locally supported; a platform with no local implementation or common version is unavailable. CCDP [resources](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md#documents-and-routes) do not fetch configuration. | +| LIBID-OAUTH-002 | The frontend configuration request follows the Bridge's exact-origin admission rule, including the explicit same-origin GET exception; cross-origin responses use matching noncredentialed CORS. One request-invariant CCDP Distribution serves independently operated OAuth Bridges without registration. Prefetch accepts `allowedApplicationOrigins: '*'` while authenticating its exact Application peer; Application authenticates `ccdpOrigin`. Prover restricts admission to the authenticated Application origin carried from Callback. Callback authenticates Application against its containing Bridge's deployment allowlist and ceremony ID before handing the return privately to the configured Prover. Another Bridge's allowlist, request headers, or payload origin cannot grant admission. | +| LIBID-OAUTH-003 | Per-ceremony operation arguments are ledger identity, platform ID, exact 32-byte operation-domain hash, transaction data, and an optional enabled ceremony version. They cannot substitute OAuth Bridge, CCDP origin, client, redirect, authorization nonce/digest, asset path, or proof destination. The notary address comes solely from the supplied `LedgerId`; neither `createCCDPClient` nor the ceremony methods accept a separate `notaryAddress` option or argument. Strict TypeScript compilation rejects the removed constructor option. | +| LIBID-OAUTH-005 | Callback bounds and copies both URL components and clears query/fragment before parsing, storage, rendering, errors, or any connection, including malformed and oversized input. Its entry code is embedded; clearing does not await a script request. | +| LIBID-OAUTH-006 | Callback extracts the routing state without classifying the return. Prover's selected platform/version leaf exact-validates the captured query/fragment against its authenticated ceremony ID, CCDP version, requested profile, and client configuration. Wrong, duplicated, missing, stale, mismatched, or replayed state, mixed/wrong transport, malformed success, and malformed denial produce no exchange or proof and use `CeremonyFailed` when connected. Valid acceptance proceeds once; valid denial uses `UserDenied`. All platforms ignore bounded, syntactically valid provider metadata without a field allowlist, including Google `version_info` and newly added names. Empty metadata and equivalent form encodings are allowed; metadata never changes the outcome, state, credential, or required issuer. Duplicate/malformed fields, mixed outcomes, unexpected credentials, and wrong transport remain rejected. | +| LIBID-OAUTH-007 | The selected platform ceremony rejects the wrong transport, a nonempty disallowed transport, mixed transport, malformed encoding, duplicate authoritative fields, mixed success/error, wrong state/client/redirect/platform, and a Google ID Token at or after its signed `exp` before proving. Mutable X/GitHub proof lifetimes are tested at the Platform Verifier, not enforced by the browser. | +| LIBID-OAUTH-010 | Before credential-bearing network use, the active top-level prover requires `crossOriginIsolated`, `SharedArrayBuffer`, and its required prover workers. Missing isolation, popup-connection continuity, shared memory, or worker support clears inputs and aborts rather than selecting an iframe, auxiliary popup, single-threaded path, or weaker prover. | +| LIBID-OAUTH-011 | The authorization nonce and derived authorization data stay Application-side; only the code verifier crosses in `ProveIdentity`. Callback's raw OAuth capture crosses only its private Prover fragment and automatic isolation replacement, never an Application message, query, Worker record, signaling, telemetry, error, or public API. Every arrival captures then clears its URL before other work. The final proof may contain profile-required disclosed evidence; hiding transient return parameters does not hide such proof fields. | +| LIBID-OAUTH-012 | `IdentityProof` carries separate `identity` and opaque `proof` fields. The live platform/version selects `validateProofMessage`; accepted `IdentityResult` returns that identity beside `oauthProof`, whose only fields are `platformCeremonyVersion`, the retained `authorizationNonce`, and the narrowed platform `proof`. Platform ID stays in Identity; caller-supplied operation domain and transaction data are absent. Combining the accepted result with the original operation inputs still reproduces the exact ledger submission and authorization digest. Literal and generic platform types preserve inference; new platform slices do not change CCDP. Neither prover nor client verifies the final proof. | +| LIBID-OAUTH-013 | After `ProveIdentity`, Prover returns identity and proof unchanged through the bound popup connection. The selected platform/version validator enforces their exact shapes and bounds; nested identity copies in platform proofs, malformed, oversized, cross-platform, and cross-version values fail. Interruption before accepted `IdentityResult` requires fresh OAuth. | +| LIBID-OAUTH-014 | The live Ceremony accepts delivery only on its supplied authenticated popup connection, structurally validates the separate identity and selected platform/version proof, checks `identity.platformId` and `identity.oauthClientId` against the frozen selection, and wraps only the retained version and authorization nonce. It does not decode attestations, derive identity, repeat Prover evidence checks, or recompute the retained digest. Accepted means Prover-reported success and Client shape acceptance, not authenticated identity or Ledger Verifier acceptance; wrong origin, cancellation, or replay returns no accepted result. | +| LIBID-OAUTH-015 | Current profiles perform no Bridge token exchange, polling, result, progress, cancellation or retry request. X/GitHub use browser Proxy sessions with code-owned platform targets. Loss before result acceptance requires fresh OAuth; no server proof-recovery record exists. | +| LIBID-OAUTH-016 | The live Ceremony freezes the `CeremonyConfig`-selected client and redirect, the client-resolved notary address, the copied Chain Profile hash, and the nullable code verifier derived from the Authorization Digest and `authorizationNonce` by the normative PKCE construction. `ProveIdentity` carries the frozen client, redirect, verifier, and nullable notary address, but no ledger object, separate hash/classification, or authorization nonce. OAuth `state` remains exact `v.`; there is no separate OAuth-state or PKCE-randomness value. | +| LIBID-OAUTH-017 | Navigate Application from allowed origin A to allowed origin B during OAuth. B cannot inherit A's authenticated connection or ceremony. Callback must authenticate the bound Application before handing credentials to the configured Prover; the final Prover must exact-authenticate the origin carried from Callback before readiness, requests, or results, including fallback/replacement. A navigation of the retained Application window to another origin must fail. Origin-list membership alone grants no access to another ceremony. | +| LIBID-OAUTH-018 | After `prover.started` and one `ProveIdentity`, a valid ceremony-bound OAuth denial makes Prover send parameterless `UserDenied` in Phase 3 without entering Phase 4, before exchange, platform progress, or proving. Application resolves `{ status: 'denied' }` without receiving the raw return or accessing a Job. Malformed denial and technical failure use `CeremonyFailed` and reject; local cancellation wins over a racing denial. Denial has no acknowledgement and no automatic popup navigation or closure. | +| LIBID-OAUTH-019 | The live Ceremony accepts one `prover.started` and sends one `ProveIdentity`. Prover consumes its retained return once under that request; duplicate readiness, another request, replacement return, or a terminal race cannot restart validation or proving. Unknown ceremony, wrong version, and stale/recreated contexts do not return an accepted result. Application result checks and its own terminal state remain independent of Prover's classification. | +| LIBID-OAUTH-020 | An OAuth-platform callback whose local opener path is absent, severed, or rejected releases no OAuth bytes through it. Delivery may continue only through the optional popup fallback constructor supplied to both connection endpoints; when it is omitted or fails, the callback fails closed without delivery. A late local connection is inert. | +| LIBID-OAUTH-021 | `ProveIdentity` contains exactly type, platform ID/version, client ID, redirect URI, nullable code verifier, and nullable `notaryAddress`. The wire accepts a canonical notary origin or null for any platform; the selected platform requires an address only when it needs notarization. Google accepts a supplied address without opening a notary connection. The selected profile still requires null code verifier for Google or the exact 43-character verifier for X/GitHub. Missing fields, wrong-type, noncanonical, credential-bearing, path/query/fragment-bearing, and disallowed insecure addresses fail before exchange. Canonical HTTP loopback origins remain valid at arbitrary ports. Prover consumes the supplied address unchanged, without a ledger dependency or profile selection. No OAuth return, ledger object, separate hash or network classification, authorization nonce, digest, operation field, or app-origin field is accepted. Raw return data comes only from the captured Prover fragment. | +| LIBID-OAUTH-022 | The exact CCDP message set is `ProveIdentity`, `IdentityProof`, `UserDenied`, `CeremonyFailed`, and `Event`, with discriminators `prove-identity`, `identity-proof`, `user-denied`, `ceremony-failed`, and `event`. Reject unknown top-level fields, wrong types, legacy message discriminators including `cancel`, `denied`, and `abort`, and invalid directions/states. Optional `ProveIdentity.clientCredential` must be nonempty printable ASCII without whitespace whenever present; GitHub requires it before exchange. Client copies the frozen configuration value unchanged, while other profiles may omit it. `UserDenied` is fieldless apart from type and valid only Prover → Application before proving operations. `CeremonyFailed` carries event and opaque display message under [the documented error boundary](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md#ceremonyfailed), with no reason/code mapping. `IdentityProof` carries separate exact-shaped identity and opaque proof. `Event` validates its optional phase, finite nonnegative timestamp, and optional `instrumentation` record containing optional `operationId` and bounded scalar `attributes`; unknown valid extensions may be ignored but cannot drive the protocol. No OAuth return or repeated connection ID appears in a message. | +| LIBID-OAUTH-023 | Callback-origin persistence contains no action, credential, token-exchange attestation, transcript, witness, code verifier, prover input, generated `OAuthProof`, verified result, or proof-delivery record. | +| LIBID-OAUTH-024 | Missing, canceled, wrong-ID, wrong-origin, unsupported-ceremony-version, and replayed live deliveries fail without returning OAuthProof or reviving a Ceremony. | +| LIBID-OAUTH-025 | Destroy the application-side `Ceremony`, popup connection, isolated prover, or application document after proof generation but before `proveUserIdentity()` resolves. No proof can be recovered; a new ceremony requires fresh OAuth. | +| LIBID-OAUTH-026 | An initial `${ccdpOrigin}/ccdp/v1/prefetch#ceremonyId=&platformId=&ceremonyVersion=` fragment is cleared before subresources and runs the CCDP v1 Prefetch implementation. The top-level Prefetch accepts the popup connection, registers and activates `/ccdp/v1/worker.js` with `scope: '/'`, and reports `prefetch-dispatch.finished` after dispatching that profile's prefetch. Authenticated Worker registration/activation or dispatch failure sends `CeremonyFailed` instead of `prefetch-dispatch.finished`; failures before authentication report locally. Both stop before OAuth, while ordinary asset fetch failure after dispatch follows the cold path. The client accepts `prefetch-dispatch.finished` only through the supplied connection, then calls `PopupConnection.navigateAway(platformAuthorizationUrl)` without disclosing that URL to the Prefetch carrier. No download timeout is required. | +| LIBID-OAUTH-027 | `CCDPClient.new` receives exactly one caller-constructed `PopupConnection`; `proveUserIdentity()` receives no connection, popup, carrier, or launch option, opens no browsing context, runs the complete one-shot flow over that retained connection, and resolves with exactly one `IdentityResult`: accepted with separate unverified prover-extracted `identity` and `oauthProof`, or denied. The popup package exposes only decoded registered messages; ceremony participants still enforce CCDP order and state. | +| LIBID-OAUTH-028 | Application records authorization start when initiating navigation, not on an unobservable platform load. For both approval and denial, authenticated Callback emits `Event(authorization, finished)` before private Prover navigation, carrying no return data or classification. It awaits neither an acknowledgement nor Application scheduling. Prover still validates the return after `ProveIdentity`; receipt or loss of this observational event does not create another readiness gate. | +| LIBID-OAUTH-029 | A structurally accepted `IdentityProof` produces local `prover.finished` and exactly one completed lifecycle update before result settlement. Structurally invalid proof, CeremonyFailed, and local technical failure produce one failed update; valid denial produces one denied update. Reported connection closure rejects with `CeremonyError.status = closed` and emits one neutral `closed` update for remaining observers. Technical connection failures retain `status = failed` and their cause. Both paths clean up without overwriting earlier acceptance or denial. The status set is `active`, `completed`, `denied`, `failed`, and `closed`; no cancellation status is produced by the package. Incoming `Event(prover, finished)` is invalid. Late notifications/proofs cannot reactivate or replace an outcome; Application result acceptance adds no local cryptographic verification. | +| LIBID-OAUTH-030 | After authentication, Prefetch/Callback/Prover technical failures send bounded `CeremonyFailed.event` and opaque display `CeremonyFailed.message` under [the documented error boundary](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md#ceremonyfailed); an operation can fail before its start notification. No reason enum, code, or code-to-text equality is required. Before authentication, failure produces no wire message but is locally rendered/logged or sent to a local diagnostics sink using the fixed reporting diagnostic, never the caught exception text. Reporting/observer failure does not alter cleanup or the terminal outcome. | + +| LIBID-OAUTH-031 | The GitHub Prover requires exactly one query `iss`, form-decoded once and exactly equal to `https://github.com/login/oauth`, before success/denial/error classification or token exchange. Exercise literal and equivalent encoded values; reject missing/duplicate issuer, malformed encoding, foreign issuer, case changes, explicit default port, and trailing slash on success, access_denied, and other errors. Invalid issuer uses CeremonyFailed rather than denial, and correct issuer does not waive state validation. No runtime discovery or new CCDP/proof field is introduced; X has no GitHub issuer prerequisite. See normative TEST-PLAT-12A. | + +## OAuth Bridge and CCDP Distribution deployment + +| ID | Assertion | +|---|---| +| KIT-001 | The OAuth Bridge exposes stateless `GET /api/v1/ceremony/config`, fixed `GET /auth/callback`, and no additional service for the current profiles; a future profile may define one. The independent CCDP Distribution exposes the aggregate `GET /ccdp/callback.html` artifact for server-side retrieval, `GET /ccdp/v{CCDPVersion}/prefetch`, `GET /ccdp/v{CCDPVersion}/prover`, `GET /ccdp/v{CCDPVersion}/worker.js`, the distribution-defined Prover fallback response, and immutable proving assets. Neither exposes an OAuth preparation route or a separate Callback script route. | +| KIT-001A | `build:ccdp-artifacts` materializes the validated local graph as `public/` plus generated `sws.toml`; external entries contribute fetch locations and CSP but no downloaded bodies, local routes, or server proxy. The config disables directory listing, SPA fallback, and automatic security/cache policy; retains native ETags; maps every protocol resource and the immutable asset namespace to its generated response profile; and leaves no public manifest. The fixed container recipe pins a qualified SWS v3 image by digest and copies only those outputs. Contract tests run against the resulting image and cover exact routes, bodies, media types, security and cache headers, `Service-Worker-Allowed: /`, GET/HEAD behavior, redirect-free protocol and asset resources, inert 404s, optional same-origin directory slash redirects ending in those failures, and absence of request-time source resolution, archive handling, compilation, templating, or module execution. Local preview and browser integration tests use the same binary and generated configuration, not a TypeScript serving implementation. The same image runs on any container platform behind a transparent HTTPS ingress or CDN. | +| KIT-001B | Exact internal SWS rewrites serve Prefetch and both Prover routes from ordinary emitted HTML files with no Location header, additional browser request, or directory-index convention. Header rules use native ordered defaults and later resource-specific overrides, accounting for post-rewrite matching. Public URLs receive their exact declared policy; generic asset defaults cannot erase worker CSP or either Prover's isolation headers. Renaming emitted files updates both rewrites and header matches from the resource graph. Unknown routes have no catch-all rewrite, and versioned document bootstraps reject direct visits to physical HTML paths. | +| KIT-002 | Selecting GitHub requires a public `clientCredential` from Bridge configuration. Client freezes and forwards it unchanged; missing or invalid credentials fail before exchange. X/GitHub token and identity use browser Proxy sessions at the same client-supplied notary. | +| KIT-003 | Google, X and GitHub require no OAuth Bridge ceremony state or token-exchange route beyond public `CeremonyConfig` and complete Callback hosting; the CCDP Distribution remains independent and no browser-MPC bridge route exists. | +| KIT-004 | Deployment `allowedAppOrigins` is a nonempty, duplicate-free bridge-controlled list of canonical HTTPS origins (or explicit loopback HTTP) with no protocol maximum. A duplicate or invalid configured member fails deployment. The bridge derives `allowedOrigins` by adding the resolved `ccdpOrigin` exactly once: omission adds `https://lib.id`; an override adds only the replacement, leaving `https://lib.id` unadmitted unless explicitly listed. An already-listed CCDP origin introduces no duplicate or error. An explicit configuration-request Origin must match an effective member; an invalid, null, or unlisted Origin fails even with same-origin fetch metadata. An absent Origin is admitted only with `Sec-Fetch-Site: same-origin`, without requiring the Bridge origin in the allowlist. Callback navigation is not subject to this request-origin gate; its embedded allowlist governs Application authentication. Neither list is part of public `CeremonyConfig`. | +| KIT-005 | Only the application-scoped client fetches `CeremonyConfig`, using noncredentialed fetch and the Bridge's origin-admission rules. The effective `allowedOrigins` governs explicit configuration Origin admission and is embedded into the request-invariant Callback document. Admitted explicit origins receive exact CORS without credentials; the absent-Origin same-origin case receives no CORS header. Responses use `Cache-Control: no-store`, `Vary: Origin, Sec-Fetch-Site`, and `X-Content-Type-Options: nosniff`. The Bridge publishes and embeds its resolved canonical CCDP origin and uses it as the fixed Callback artifact source. CCDP browser resources perform no configuration request. Request `Origin`, `Referer`, query, fragment, and platform code cannot alter those deployment inputs. | +| KIT-006 | The registered redirect URI is canonical HTTPS on the configured OAuth Bridge origin with no credentials/query/fragment; explicit loopback is the only HTTP exception. Its path is fixed at `/auth/callback`, derived from the supplied Bridge origin. The complete document bounds and clears the raw query/fragment, extracts exactly one `v.` state, selects a bundled implementation, and validates its deployment inputs before entering it. Google fragment state is never interpreted server-side. There is no HTTP redirect, second document navigation, or browser Callback script request; execution remains on the Bridge origin. | +| KIT-007 | `ceremonyId` is a fresh lowercase UUIDv4 string serialized as the suffix of `v.` OAuth `state` and reused as the popup connection ID; it is never a proof nonce or chain authorization. Callback uses the version prefix only to select among its bundled implementations. `authorizationNonce` is a fresh 32-byte random value and also supplies the PKCE derivation input. Only the derived code verifier crosses the prover boundary, and the nonce is published only in the accepted `OAuthProof` after the token exchange completes. No separate PKCE-randomness value exists. | +| KIT-008 | Reusing a live ceremony ID, or supplying malformed, padded, derived, wrong-version, wrong-variant, or wrong-case values, fails without weakening ceremony state or popup-connection checks. | +| KIT-009 | For each active artifact/configuration pair, the complete OAuth Bridge Callback and the CCDP Distribution's resources are independently request-invariant; no request field changes CSP, embedded code, origin, platform, or document role. A cache refresh atomically replaces the prepared Callback HTML and matching response policy. | +| KIT-010 | The complete Callback artifact contains one empty mount point, exactly one non-executable deployment-data slot, and bundled executable code; there is no input-declaration block or version-keyed configuration. The Bridge rejects missing/duplicate markers and safely serializes the unversioned input list, including escaping `<`, without changing executable bytes. Its first two entries are the effective `allowedOrigins` and resolved `ccdpOrigin`; no operator-supplied duplicate values or version list is needed. Before rendering, storage, error reporting, or network use, Callback bounds/copies query and fragment, clears both, selects a bundled CCDP implementation, and validates/freezes the list and captured location. Missing data, non-array configuration, missing required entries, wrong types, duplicate or invalid allowed origins, an invalid CCDP origin, or its absence from the allowlist fail locally before connection setup. Unsupported or retired CCDP versions display package-owned failure UI after clearing, with no connection setup, CCDP message, or application-specific error renderer. No other implementation or code is fetched, and the Bridge performs no OAuth-state parsing or browser version dispatch. | +| KIT-011 | X/GitHub callback queries are absent from server/proxy logs, traces, analytics, and errors. Artifact fetches target only the configured CCDP origin's `/ccdp/callback.html` and never forward callback query, request headers, cookies, or credentials; redirects are rejected. Callback requests trigger no artifact fetch. Google fragments never reach either server. | +| KIT-012 | Callback performs no token exchange or server mutation. After capture/clearing, Application authentication and private handoff, only the isolated selected Prover validates the return under `ProveIdentity` and performs any profile-required browser exchange. | +| KIT-013 | **Retired by PR #13 / #35:** the Bridge GitHub token endpoint and its JSON/CORS/admission/egress contract were removed. Its endpoint-specific tests no longer qualify a supported flow. Browser request binding, canonical layout and notary correlation remain required under LIBID-PROVER-004 and LIBID-PROVER-009; no bearer-binding property is dropped. | +| KIT-014 | **Retired by PR #13 / #35:** Bridge token-route restart, duplicate request and response-loss state tests have no current endpoint. Browser interruption, disposal and fresh-OAuth behavior remain covered by LIBID-OAUTH-013 and LIBID-OAUTH-015. | +| KIT-015 | No API input, request, user control, retry, or browser probe selects browser-MPC transport; every X and GitHub ceremony uses the deployed Proxy transport. The client-selected notary origin changes only where that service is reached. The notary itself opens the code-pinned platform socket; no caller-supplied platform target, alternative socket route, or proxy mode is accepted. | +| KIT-016 | Two OAuth Bridges with disjoint configured application allowlists obtain the same unchanged Callback artifact from one CCDP Distribution without registering with it. Each serves it with its own trusted inputs and effective allowlist: both admit the shared CCDP origin, but neither admits the other's application origins. Distribution-hosted Prefetch, Prover, Worker, and proving assets remain shared. Prefetch and both Prover responses embed entry code; both Prover paths resolve the shared root-scope Worker; Callback installs no Worker. Replacing either deployment is an independent code-supply-chain trust choice. | +| KIT-017 | An unsupported CCDP implementation path, broad script/worker/frame source, connect source beyond the resource's declared HTTPS/WSS classes or pinned asset origins, request-derived CSP, or asset redirect is rejected as a production deployment. | +| KIT-018 | OAuth Bridge Callback pages and CCDP Distribution pages are each served from a dedicated cookie-free origin with no unrelated same-origin API in the reference deployments; their origins may differ. | +| KIT-020 | The ceremony requires no canonical orchestration service, preparation endpoint, status server, action ledger, or proof-recovery service. Its browser network surface is the OAuth Bridge's public `CeremonyConfig`, complete Callback, plus the independent CCDP Distribution's Prefetch, Prover, Worker, and proving assets. Server-side Callback artifact refresh carries no ceremony data. | +| KIT-021 | Publish a compatible Callback UI/code update and refresh the Bridge's artifact cache without rebuilding its binary or changing deployment inputs. New responses use the new bundled code and matching CSP hashes; live pages retain old code/configuration, and an older still-supported state selects its retained implementation in the new artifact. Conditional revalidation preserves an unchanged artifact; compressed source responses are decoded before data insertion and their transfer headers are not reused. Failed, malformed, or partial refresh retains the last valid result; without one, the callback route is inert/unavailable. OAuth requests never trigger refresh; no manual stylesheet/theme configuration is needed, and unapproved JavaScript stays blocked. | +| KIT-022 | Configuration GET admits an exact allowed Origin with exact noncredentialed CORS. With no Origin it admits only `Sec-Fetch-Site: same-origin` when the configured Bridge origin is allowed. Reject missing metadata, same-site/cross-site/none metadata without Origin, explicit null/unlisted/malformed Origin, and an unlisted Bridge origin; Referer cannot grant admission. | + +## Browser isolation and response policy + +| ID | Assertion | +|---|---| +| CSP-001 | The fixed `/auth/callback` path serves one complete top-level, non-isolated, no-store/no-referrer, non-frameable OAuth-return document and does not itself sever a surviving application opener. Its embedded code clears the return and selects its bundled CCDP implementation from OAuth state without an external script request. | +| CSP-002 | Prefetch is top-level, non-isolated, and non-frameable. Prover uses the distribution-defined DIP response and COOP/COEP fallback response; both are non-frameable, no-cache/no-referrer, and require isolation before protocol readiness. The Worker response sets `Service-Worker-Allowed: /`; Prefetch registers with `scope: '/'`; both Prover paths and same-origin composition destinations resolve that registration for popup continuity and proving caches. | +| CSP-003 | Every CCDP browser document uses `default-src 'none'`, `object-src 'none'`, `base-uri 'none'`, `form-action 'none'`, and no JavaScript `'unsafe-inline'`/`'unsafe-eval'`; an exact CSP hash permits only its deployment-generated inline code. Package-owned UI uses `style-src 'unsafe-inline'` without external stylesheet sources or customization inputs. WASM-compiling contexts use `'wasm-unsafe-eval'`, not JavaScript `'unsafe-eval'`. Prover permits HTTPS Bridge/CRS traffic and WSS notary traffic; TLSNotary workers also permit WSS where required. These network permissions do not admit remote scripts or workers. | +| CSP-004 | Route headers and asset paths are fixed deployment values; hostile query/fragment/origin strings cannot inject or expand CSP. | +| CSP-005 | Callback records query/fragment transport, clears both URL components, and makes no storage/network action while raw callback bytes remain in the URL. | +| CSP-006 | Prefetch accepts only an empty query and `#ceremonyId=&platformId=&ceremonyVersion=`. Both Prover responses accept only an empty query and `#ceremonyId=&applicationOrigin=&oauthQuery=&oauthFragment=`. Both OAuth fields must be present, even when empty. One outer decoding preserves each original component, including its delimiter, percent escapes, plus signs, Unicode encoding, duplicate inner fields, and query-versus-fragment provenance; the platform parser validates those contents later. Unknown outer fields, duplicates, wrong contexts, and malformed envelopes fail after URL clearing. | +| CSP-007 | In Chromium, Firefox, WebKit, Pixel emulation, and iPhone WebKit emulation, Callback, Prefetch, and Prover execute embedded entry code without another entry-script request. Callback also needs no dependency chunk or browser CORS grant from the Distribution. Exercise bridge-served Callback with external script requests blocked. Wrong context, wrong MIME, missing `nosniff`, blocked inline code, or isolation-policy incompatibility fails qualification; no blocked entry proceeds with a weaker policy. | +| CSP-008 | Every proving Worker implementation is sourced from an immutable CCDP-origin URL; any toolchain-internal local `blob:` worker loads only those same-origin bytes, and no cross-origin worker bridge exists. | +| CSP-009 | Spawner workers permit only required `blob:`/worker/connect sources; leaf workers use `worker-src 'none'`; every worker has its own CSP. | +| CSP-010 | Real nested bb.js and TLSNotary workers and WASM load only from immutable CCDP-origin locations selected by the build. | +| CSP-011 | The same byte-identical Prover responses support arbitrary allowed notary origins without Distribution reconfiguration. Prover performs no Bridge fetch. `connect-src` permits same-origin and HTTPS assets plus WSS notary traffic, with exact localhost/127.0.0.1 WS exceptions; it does not admit cross-origin loopback HTTP Bridge fetching. Qualify both isolation responses with X/GitHub token and identity sessions using the identical client-supplied address. No notary address is embedded in artifact bytes or CSP. Scripts and workers remain same-origin. A compromised Prover can use every admitted network class; CSP is not a selected-notary restriction. | +| CSP-013 | Prefetch sends `Event(prefetch-dispatch, finished)` after authenticated connection, Worker activation, and selected-profile dispatch; Application privately navigates to Authorization. Callback authenticates Application and navigates directly to Prover with structured private return parameters, sending no OAuth message. Prover retains them and emits `Event(prover, started)`; Application sends the frozen `ProveIdentity`; Prover alone classifies acceptance or denial. | +| CSP-014 | The Prover bootstrap captures and clears its fragment before imports, rendering, storage lookup, or error reporting, then provides the snapshot to popup construction. Its bare isolation fallback URL has no fragment override. Automatic replacement preserves all captured parameters despite clearing and subsequent caller mutation; the final document clears again. No snapshot enters Application controls/events, Worker records, signaling, or diagnostics. Only the final isolated Prover emits `prover.started` and handles the request. | +| CSP-015 | Prefetch, Prover, Worker, and the aggregate Callback artifact use exact MIME, `nosniff`, applicable isolation policy, `Cache-Control: no-cache`, and ETags for compatible updates. The Bridge's configured Callback response instead uses `no-store` while the bridge caches the source artifact. CCDP-origin implementation-private chunks, circuits, notarization assets, and toolchain WASM use long-lived immutable caching; one immutable URL is never reused for changed bytes or execution-relevant metadata. Aztec CRS headers are external and qualified separately. | +| CSP-016 | Every launch profile runs its exact multithreaded real-prover configuration in the promoted top-level prover. It checks isolation and shared memory before credential use; failure sends terminal `CeremonyFailed` when the port is available, clears inputs, and never selects an iframe, single-threaded, unisolated, or auxiliary-window fallback. Missing required workers or nested-worker startup behaves identically. | +| CSP-017 | Scripted and real-anchor launch both reach `${ccdpOrigin}/ccdp/v1/prefetch#ceremonyId=&platformId=&ceremonyVersion=`. After clearing, the top-level Prefetch accepts any valid browser-observed HTTPS Application origin while the Application exact-validates `ccdpOrigin`; it activates the version-matched Worker, requests only that profile, and sends `prefetch-dispatch.finished` without waiting for downloads. The Worker exact-validates the request and keeps fetch work alive with `event.waitUntil`. The Application then navigates away to the OAuth Platform without exposing that URL to the Prefetch peer. | +| CSP-018 | Prefetch, Prover, and Worker are same-origin and version-matched, with both Prover responses resolving the same Worker registration. The selected profile pins local immutable assets and the exact external Aztec CRS requests/SRS size. Every fetching context, including the Service Worker and proof workers, permits both Aztec hosts in connect-src without admitting remote scripts or workers. The final Prover joins existing flights/cache after isolation replacement. Unknown profiles, unlisted external URLs, opaque responses, invalid ranges, and input-supplied assets fail before caching; valid declared CDN CORS/range responses do not. | +| CSP-019 | Across cold, partial, warm, concurrent, cross-platform, reload, service-worker update/termination, quota/fetch-failure, and unsupported-worker cases, the complete selected module/chunk/nested-worker/WASM/circuit/CRS graph joins ordinary or CRS single flights and unselected profiles fetch nothing. Install uses `skipWaiting`, activation uses `clients.claim`, same-origin prefetch credentials match native module/worker requests, and requests outside the pinned prefetch set use native fetch. New documents reconnect to durable caches; artifact failure follows the same cold path, while registration or activation failure is terminal rather than weakening isolation or worker count. | +| CSP-020 | Under the generated distribution headers, real dedicated proof and TLSNotary workers compile their required WASM and spawn only their declared child workers. External workers carry their own execution CSP; blob workers inherit the compiling/spawning policy. The fetch/port-keeping Service Worker needs no WASM permission, and no context admits JavaScript string evaluation. | + +## Browser lifecycle and progress + +| ID | Assertion | +|---|---| +| LIBID-BROWSER-001 | `Ceremony.launchUrl` remains the frozen browser URL for Prefetch and its closed ceremony-ID/platform-ID/platform-version fragment. The caller uses it for a real anchor and supplies the popup connection to `CCDPClient.new`. For scripted navigation, `proveUserIdentity` supplies the bare target plus separate `URLSearchParams` to the popup API, never an embedded fragment string. Both paths reach the same location. | +| LIBID-BROWSER-002 | A rejected or closed supplied popup connection rejects before OAuth navigation, preserving failed versus closed status. The opener-independent-return test below covers OAuth-platform-severed callback behavior; generic popup launch and authentication cases remain in the popup package plan. | +| LIBID-BROWSER-003 | The ceremony reuses one caller-supplied logical popup connection through Prefetch, OAuth-platform Authorization, Callback and Prover. Each participating document registers only its permitted CCDP messages and none opens an iframe, second popup, or second Prover. | +| LIBID-BROWSER-004 | After Application authentication, Callback calls ordinary `PopupConnection.navigate` with the bare Prover URL and separate `URLSearchParams` containing the captured return. Its locally initiated navigation exposes no destination to Application. Prover supplies captured fragment input to popup construction, uses a bare isolation fallback URL, and awaits readiness. Ceremony implements no carrier selection, Worker record, handoff deadline, or browser-specific transition. | +| LIBID-BROWSER-005 | Ceremony uses `ceremonyId` as the popup connection ID; CCDP messages omit it. OAuth state is checked against the authenticated ID and version in Prover, not against another caller-supplied expected state. Application receives no raw OAuth return. Concurrent ceremonies cannot exchange fragments, selected profiles, requests, or results through another connection. | +| LIBID-BROWSER-006 | One Application event stream merges its local boundaries and received core/extension events without rewriting occurrence timestamps. High-level UI stages are a separate projection: preparation, authorization, proof-preparation, optional notarization, then zk-proving. Google skips notarization; early backend setup and late attestations never regress the displayed stage. Events know nothing about UI stages, and phase finish is not overall ceremony completion. | +| LIBID-BROWSER-007 | Google, X, and GitHub use the same authenticated `Event` path from visible Prover to Application. X and GitHub emit all six core proving operations, separating token-fetch from token-attestation; Google emits only the two ZK operations. Each platform may add its own events without a new message type. Completing ZK generation while an attestation is pending does not deliver a proof or report ceremony completion. | +| LIBID-BROWSER-008 | Only the exact required core readiness occurrences advance CCDP. Duplicate, missing, malformed, wrong-sender, or out-of-state readiness cannot be replaced by advisory events. Additional valid observations and retrospective timestamps do not alter protocol ordering or authorize, cancel, or complete a ceremony. Observer filtering/errors are inert after internal protocol processing. Connection loss provides no ceremony recovery. | +| LIBID-BROWSER-009 | Popup closure is neither success nor OAuth denial; cancellation intent belongs to the application that closes it. A lost live ceremony returns no OAuthProof and requires fresh authorization; popup observation mechanics belong to the popup package and downstream composition state is outside this package. | +| LIBID-BROWSER-010 | In cold and verified-warm profiles, the complete request direction of X's first notarized token session reaches X before the authorization-code deadline. Delaying completion of that request direction past the deadline abandons the ceremony; response receipt, the identity session, proving, and delivery may complete later. | +| LIBID-BROWSER-011 | Real-device tests cover each platform app installed/absent, authentication in app/browser, approval/denial, suspension, ignored close, and same-tab/openerless return. | +| LIBID-BROWSER-012 | Openerless return, including an OAuth-platform COOP policy which severs the retained opener, can continue only when both popup connection endpoints receive corresponding optional fallback constructors, without releasing callback parameters through window messaging. An omitted, mismatched, or failed fallback and a new-profile return fail closed without claiming cross-profile recovery. | +| LIBID-BROWSER-013 | Race caller-owned `connection.close()` against proof delivery, denial, and failure in both orders. Closure rejects the live ceremony, clears retained inputs and observers, and sends no CCDP cancellation message. No late readiness, event, or result revives the run; a terminal outcome that already won remains final. Closing before the first proof call preserves closed status rather than a repeated-call error. Actual-popup dev tests cover cancellation during preparation, authorization and proving, same-platform concurrency, native-anchor readiness, and per-run closure. There is no local-retire API for retaining the connection after cancellation. | +| LIBID-BROWSER-014 | Two concurrent ceremonies use distinct target names, `PopupWindow` instances, popup connections, and ceremony state; either ceremony's OAuth-platform/prover navigation or CCDP traffic cannot replace or complete the other. | +| LIBID-BROWSER-015 | After authenticating Application, Callback navigates the existing popup privately to Prover. Successful DIP needs no replacement; unsupported DIP uses the same-origin popup-managed fallback with the entire captured fragment preserved. Both paths have one popup, one logical connection, one `Event(prover, started)`, and at most one proof execution. Only the replacement path reports `prover-fallback`. An unisolated fallback fails without readiness, proof work, or another replacement. | +| LIBID-BROWSER-016 | Proving stays in the foreground ceremony popup after its top-level browsing context becomes the isolated prover. No persistent prover runs under the original app. On mobile, background or suspend the application tab while only the popup is visible: active proving continues there, application-side delivery may wait for scheduling, and resumed delivery preserves order and outcome without selecting another carrier. | +| LIBID-BROWSER-017 | Every package-rendered Callback and active-Prover view contains the inline libID logo. During active proving the top-level Prover shows one accessible milestone-progress bar and package-owned status label; Callback renders only fixed transition or failure views. | +| LIBID-BROWSER-018 | Closing consent without a returned OAuth error is not OAuth denial; a valid ceremony-bound OAuth-platform denial resolves as `{ status: 'denied' }` through `UserDenied`. Application cancellation uses the supplied connection to close the popup; it opens no auxiliary context and sends no CCDP stop request. Composition-owned navigation or closure ends the current popup document; a dispatched stateless server request may still finish, but its response cannot revive the canceled local run. | +| LIBID-BROWSER-019 | Real-device qualification separately covers iOS memory pressure, suspension/eviction, browser chrome, popup blocking defaults, native OAuth-platform handoff, and background scheduling; Playwright WebKit/iPhone emulation cannot satisfy these gates. | +| LIBID-BROWSER-020 | **Restart authorization** never opens a clean callback or proof-recovery context; when the live ceremony is gone the caller creates a fresh `Ceremony` and uses its initial real-anchor/synchronous-open launch with fresh OAuth. | +| LIBID-BROWSER-021 | Reload destroys every live in-memory Ceremony. No callback, progress record, stale popup handle, or prior ceremony ID is presented as resumable or can recover proof delivery. | +| LIBID-BROWSER-022 | Destroy and recreate the application-side `Ceremony`, popup connection, Callback, or active Prover at applicable points in the flow. No advisory progress, stale connection, or recreated context resumes OAuth, proving, or proof delivery. The deliberate popup-managed isolation replacement before `prover.started` is connection setup, not ceremony resumption. | +| LIBID-BROWSER-024 | The visible Prover consumes its own shared event stream to update an accessible bar and high-level stage label without an Application roundtrip. Fine-grained overlapping events do not replace the label with every operation name or regress stages; percentages/labels are not taken from wire fields. Delay or suspend Application: local UI still advances, but local `IdentityProof` delivery never claims Application acceptance. UI/observer exceptions cannot suppress protocol events or proof delivery. | +| LIBID-BROWSER-025 | After 15 seconds of active proving, fake-timer and real Vanadium tests show a nonblocking **Still proving** notice with optional per-site JavaScript-JIT guidance. The notice does not diagnose the cause, require JIT, user-agent sniff, reload, cancel, emit CCDP or `CeremonyEvent`, alter a timeout, or affect proof authority. Terminal cleanup removes the timer. | +| LIBID-BROWSER-028 | With an opener-independent fallback supplied, Callback authenticates Application and privately navigates to Prover without sending OAuth parameters through the connection or signaling. Prover accepts the logical connection with isolation established and sends one `prover.started`; Application sends the profile/configuration request; Prover classifies the retained return. CCDP adds no fallback-specific handoff message or signaling format. | +| LIBID-BROWSER-029 | Diagnostics count three simultaneous uncached requests as one network retrieval and two single-flight joiners. Warm-cache reads add no download; earlier Service Worker transfers are not counted again on Prover join. Missing earlier-worker timing is unavailable rather than zero, and overlapping span durations are not summed as elapsed time. | +| LIBID-BROWSER-030 | Force isolation replacement and delay destination load, connection readiness, and Application delivery independently. The connected replacement emits one phase-less `prover-fallback` with its own `performance.timeOrigin`, then `prover.started` with the readiness occurrence time. Their difference measures replacement navigation through readiness, excludes source work, and is unchanged by forwarding delay. No timestamp storage, extra handshake, or pre-authentication event is needed. The direct isolated path emits no fallback event; failed connection setup invents neither prover readiness nor a completed interval. | + +### Loopback HTTP coverage + +Existing IDs remain unchanged. `LIBID-OAUTH-021` and `POPUP-CONNECTION-009` boundary +tests admit explicit canonical localhost HTTP and reject public HTTP, lookalikes, +credentials and noncanonical spellings. `CSP-003` distribution checks admit only +explicit loopback HTTP/WS sources. The `*-http` browser projects repeat the existing +browser cases against the same artifact without TLS; HTTPS projects remain. +These runs do not replace real OAuth, physical-device or proof-verification gates. + + +Connection-loss regression for `TEST-CCDP-08` / `LIBID-BROWSER-005`: a synthetic +provider activates COOP without a recovery carrier, Application terminates, and +consent still returns to the actual emitted Callback. Callback clears the return +URL, displays the local connection failure, removes its progress indicator and +never proceeds to Prover. Unit tests cover both readiness/closure settlement +orders in Callback and transport failure before/after Prover readiness, including +an unavailable Application reporting channel. This is transport qualification, +not a real OAuth or generated-proof result. diff --git a/ts/packages/ceremony/docs/testing.md b/ts/packages/ceremony/docs/testing.md new file mode 100644 index 00000000..3988d50b --- /dev/null +++ b/ts/packages/ceremony/docs/testing.md @@ -0,0 +1,126 @@ +# Testing + +Use Node 24+, pnpm and the frozen workspace lockfile. All commands below run +from the repository root. [Qualification](qualification.md) records evidence and +release gaps; [traceability](traceability.md) maps the stable +[test requirements](test-plan.md) to assertions and remaining properties. + +## Unit and type checks + +```sh +pnpm -C ts install --frozen-lockfile +pnpm -C ts --filter '@libid/ceremony...' build +pnpm -C ts --filter @libid/ceremony typecheck +pnpm -C ts --filter @libid/ceremony typecheck:e2e +pnpm -C ts --filter @libid/ceremony test +``` + +Unit tests sit beside their source owners. Canonical fixtures cover authorization, +JWT/circuit inputs and signed attestation decoding. Worker and pipeline mocks +exercise failures and scheduling; they do not establish real proving or runtime +concurrency. Workspace CI runs build, unit tests, lint and formatting separately +from the browser job. + +## Distribution checks + +```sh +pnpm -C ts --filter @libid/ceremony build:ccdp-artifacts +pnpm -C ts --filter @libid/ceremony test:distribution +``` + +These Node tests exercise archive handling, emitted resources and header rules, +circuit capacity and the installed dependency loaders. **HTTP and native-binary +checks are conditional**: a default run skips them unless their service/binary +inputs are supplied. A green default run is not the complete distribution +qualification. + +To include served-response checks (exact routes and policies, negotiation, +uncacheable 404s and the `/health` probe), [build and run the emitted SWS image](distribution.md#build-and-serve) +on port 8080, then run: + +```sh +CEREMONY_SWS_URL=http://127.0.0.1:8080 \ + pnpm -C ts --filter @libid/ceremony test:distribution +``` + +For another output directory, set `CEREMONY_ARTIFACT_DIR` to its absolute path and +point SWS at that same artifact. To include the same-length ETag regression and the +[header-matching canary](distribution.md#native-server-behavior), also set +`CEREMONY_SWS_BINARY` to a locally runnable `static-web-server` from the +[pinned release](https://github.com/static-web-server/static-web-server/releases/tag/v3.0.0-beta.1). +Those tests start their own servers on `CEREMONY_SWS_TEST_PORT` (default 4687) and +the next port; set `CEREMONY_SWS_TEST_PORT=4988` when the dev notary already +occupies 4687. These inputs are test-only. The workspace **CCDP image** CI job runs +all of them against the freshly built image and the pinned binary. + +## Browser tests + +With Docker Compose running: + +```sh +pnpm -C ts --filter @libid/ceremony exec playwright install --with-deps chromium firefox webkit +pnpm -C ts --filter @libid/ceremony test:e2e +``` + +The command builds qualification artifacts and runtime fixtures. Playwright owns +startup, readiness and teardown for pinned SWS/notary containers and the browser +harness. The workspace **Browser tests** CI job runs the same command alongside +the popup and dev-app suites. No OAuth credentials are required. Release downloads and real unauthenticated requests to +X need network access; unavailable services fail rather than silently skip. + +The suite uses actual popup connections across HTTP and HTTPS origins in +Chromium, Firefox, WebKit and mobile emulation. Test ports 4980/4986/4987 and +4781–4783/4881–4883 are separate from the dev app. Concurrent suite invocations +fail on occupied ports instead of reusing or replacing another run's services. +HTTPS tests use harness certificates and test-runner trust settings; the manual +development app uses loopback HTTP without certificate setup. + +| Suite | Coverage | +|---|---| +| [flow.spec.ts](../e2e/flow.spec.ts) | Document/connection lifecycle, emitted policies, selected assets, caching, UI and controlled Google proof generation. | +| [admission.spec.ts](../e2e/admission.spec.ts) | Harness origin admission and CORS; not production Bridge egress or refresh. | +| [runtime.spec.ts](../e2e/runtime.spec.ts) | Real bearer-link fixture proof and one/two matched-notary sessions alongside a separate real proof. | +| [verify.ts](../e2e/verify.ts) | Released-key verification of generated proofs and rejection of altered public inputs. | + +The harness proxies real SWS responses and inserts deployment data into emitted +Callback HTML. It does not reproduce the production Bridge's refresh lifecycle. +The runtime probes use unauthenticated requests; their separate fixture proof is +not bound to their attestations. Real consent, authenticated evidence and physical +devices remain distinct gates. Traces, video and screenshots are disabled. + +For focused iteration, select a project or case through Playwright, for example: + +```sh +pnpm -C ts --filter @libid/ceremony test:e2e --project=firefox +``` + +The [dev app's own tests](../../../apps/dev/README.md#checks) cover frontend behavior +with intercepted responses. They are a separate command and do not replace the +ceremony browser suite. + +## Manual consent and device checks + +Start the [shared dev app](../../../apps/dev/README.md) with `pnpm -C ts dev`. +Use real registrations pointing to its exact callback URI. Complete consent +manually; automated fixtures do not replace these checkpoints. + +1. For every platform, try approval and denial, signed-in/out state, cold and + warm caches, and native provider apps installed/absent where applicable. +2. Run concurrent ceremonies. Close an active popup during preparation, + authorization and proving; verify the other run and any completed result + remain independent. Success and denial close automatically in the dev app; + failed popups remain available for inspection. +3. On physical devices, background the application while Prover remains visible, + then exercise suspension/resume and memory pressure. Check openerless/native + app handoff only with the corresponding popup fallback adapter installed. +4. Record component revisions, browser/device versions, nonsecret outcome, + effective proof-thread information where observed, and total/post-authorization + timings. Missing measurements are unavailable. Verify produced evidence against + the matching released verifier before recording cryptographic qualification; + the dev app's synthetic ledger and success label do not establish this. + +Do not bypass CAPTCHA, MFA or consent. Keep callback URLs, credentials, identity +values, transcripts, openings, witnesses and live proofs out of shared logs and +telemetry. DevTools can inspect a failed popup locally; publish only the relevant +sanitized error and component versions. Update the affected traceability rows +when a new qualification result is established. diff --git a/ts/packages/ceremony/docs/traceability.md b/ts/packages/ceremony/docs/traceability.md new file mode 100644 index 00000000..b7ec231a --- /dev/null +++ b/ts/packages/ceremony/docs/traceability.md @@ -0,0 +1,177 @@ +# Requirement traceability + +Index: all 158 stable IDs from [Test plan](test-plan.md). No requirement is deleted or silently marked complete. “Automated” names a runnable assertion; “Partial” preserves the remaining compound property. External and blocked rows are release gates, not passing skipped tests. Full resource accounting and export remain deferred. See [Qualification](qualification.md) for exact evidence and dependencies. + +| ID | Coverage | Evidence / property still outstanding | +|---|---|---| +| LIBID-MOD-002 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-MOD-003 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-MOD-011 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-MOD-012 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-MOD-013 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-MOD-014 | Partial | [Client tests](../src/ccdp/client/ceremony.test.ts) reject missing/throwing hash or address methods, malformed hash lengths and noncanonical notary origins before OAuth. All platforms, including Google, read the address uniformly. Compile-only checks reject the former object form, missing positional arguments and incompatible argument order. | +| LIBID-MOD-015 | Partial | [Client tests](../src/ccdp/client/ceremony.test.ts) snapshot hash/address methods once for every platform, reject invalid inputs before OAuth, and retain the original digest after buffer mutation. [Ledger fixture checks](../../ledger/src/index.test.ts) cover independent hash bytes and local routing without changing the hash; actual-popup tests exercise the shared fixtures. Real definitions and Chain Profile vectors are deferred. | +| LIBID-MOD-016 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-MOD-017 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-MOD-018 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-MOD-019 | Partial | [Type and profile checks](../src/platforms/types.test.ts) cover separate identity/proof correlation, literal platform inference, unsupported versions and a dynamic result guard; [Client compile checks](../src/ccdp/client/ceremony.test.ts) cover the public creation/result API. Complete import-boundary mutation coverage remains. | +| LIBID-MOD-020 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-MOD-021 | Partial | [Public API/catalog/client](../src/ccdp/client/index.ts), [typechecked application](../e2e/app.ts), and [emitted graph checks](../build/distribution.test.ts); dedicated complete type/import-boundary mutation coverage remains. | +| LIBID-ASSET-001 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-002 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-003 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-004 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-005 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-006 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-007 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-008 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-009 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-010 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-011 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-012 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-013 | Partial | [Real circuit statistics and negative build gates](../build/circuits.test.ts) match the released gate counts and fixed capacity. Real proofs use 2^18; negative browser-proof SRS-floor execution remains. | +| LIBID-ASSET-014 | Partial | [Build retention](../build/distribution.ts) and actual retained-response HTTP checks; no full changed-WASM/live-old-document promotion fixture yet. | +| LIBID-ASSET-015 | Partial | [Callback units](../src/ccdp/documents/callback.test.ts) cover closed dispatch, the shared deeply frozen input list, ignored optional trailing inputs, and malformed or unsupported versions. [Browser flows](../e2e/flow.spec.ts) exercise query/fragment rejection; multi-version retention needs a second supported implementation. | +| LIBID-ASSET-016 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-017 | Partial | Actual loaders and real Google distribution proofs; forced native CDN failure, partial caches, stopped/restarted SW and real TLSN under both response policies remain. HTTP-cache fallback after Cache Storage eviction is covered by the persistent-profile browser regression in all three engines. | +| LIBID-ASSET-018 | Automated | [Real installed loader probe](../build/loaders.test.ts); URLs, methods, cache modes, ranges and primary/fallback ordering are observed with external network blocked; emitted bb WASM equals the decoded installed artifact. Browser cache scenarios remain ASSET-017. | +| LIBID-ASSET-019 | Partial | [Cache tests](../src/assets/cache.test.ts): readable clones, delivery before persistence, joins through write completion, failure eviction, quota fallback. Exhaustive joiner cancellation/browser failure combinations remain. | +| LIBID-ASSET-020 | Automated | [Actual-popup nested-scope regression](../e2e/flow.spec.ts): root selection, legacy migration, pending join, and continuation outside nested scope in three engines. | +| LIBID-ASSET-021 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-022 | Partial | [Build/loader checks](../build/distribution.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-023 | Partial | [Build/loader checks](../build/distribution.test.ts), [rebuilt sidecar cleanup](../build/sws.test.ts), [native-loader probe](../build/loaders.test.ts), [cache units](../src/assets/cache.test.ts), and browser flows cover the main path. The full row-specific rename/update/cache/fault matrix remains. | +| LIBID-ASSET-024 | Partial | [Archive safety, selectors and member-only publication](../build/archive.test.ts), [sidecar collision](../build/sws.test.ts), and real mounted RC/circuit artifacts; broader source/collision mutation matrix remains. | +| LIBID-ASSET-025 | Automated | [Archive selector checks](../build/archive.test.ts), [runtime resolver](../src/assets/index.test.ts), and [real nested TLSN workers](../e2e/flow.spec.ts). | +| LIBID-ASSET-026 | Automated | [Header ownership](../build/archive.test.ts), [actual SWS identity/Brotli/gzip negotiation, HEAD, conditional and range checks](../build/distribution.test.ts). | +| LIBID-ASSET-027 | Partial | [Same-length rebuild with pinned SWS binary](../build/sws.test.ts); repeating the update through two complete container assemblies remains a deployment qualification step. | +| LIBID-PROVER-001 | Partial | [Engine worker tests](../src/barretenberg/engine.worker.test.ts) cover released-key supply, explicit ZK/Keccak settings, missing/empty key failures and proof encoding; distribution tests bind each profile to its key. [Released-key verifier](../e2e/verify.ts), called by the normal [flow](../e2e/flow.spec.ts) and [runtime](../e2e/runtime.spec.ts) tests, checks actual Google and bearer-link browser proofs with explicit mode and mutation rejection. Authenticated X/GitHub evidence qualification remains incomplete; see the [current evidence and hosted Firefox failures](qualification.md#evidence-obtained). | +| LIBID-PROVER-002 | Partial | [Engine qualification](../e2e/smoke.ts), [Google vectors](../src/barretenberg/circuits/oidc_google/inputs.test.ts), and notary/GitHub units cover pure contracts. Full real-platform session and fault qualification remains. | +| LIBID-PROVER-003 | Partial | [Engine qualification](../e2e/smoke.ts), [Google vectors](../src/barretenberg/circuits/oidc_google/inputs.test.ts), and notary/GitHub units cover pure contracts. Full real-platform session and fault qualification remains. | +| LIBID-PROVER-004 | Partial | [GitHub pipeline tests](../src/platforms/github/1/prover.test.ts) cover overlapping browser setup, bearer availability before identity HTTP, pending token openings, late-attestation failure, closure and sibling failure. [Token tests](../src/platforms/github/1/token.test.ts) check the canonical full-request layout and frozen inputs; shared correlation tests check authority and openings. Real consent, both completion orders under the real SDK and matched PlatformVerifier acceptance remain qualification gates. | +| LIBID-PROVER-005 | Partial | [Engine qualification](../e2e/smoke.ts), [Google vectors](../src/barretenberg/circuits/oidc_google/inputs.test.ts), and notary/GitHub units cover pure contracts. Full real-platform session and fault qualification remains. | +| LIBID-PROVER-006 | Partial | [Engine qualification](../e2e/smoke.ts), [Google vectors](../src/barretenberg/circuits/oidc_google/inputs.test.ts), and notary/GitHub units cover pure contracts. Full real-platform session and fault qualification remains. | +| LIBID-PROVER-007 | Partial | [Operation tests](../src/events.test.ts), [engine tests](../src/barretenberg/engine.test.ts) and worker tests cover timestamp forwarding, overlap, input/backend readiness and interrupted spans. Platform producer semantics are exercised by focused pipeline tests; real matched-notary overlap remains a qualification gate. | +| LIBID-PROVER-008 | Partial | [Actual adapter worker tests](../src/notary/session.worker.test.ts) check actual returned ceilings/overflow and EOF timeout; [frame cases](../src/notary/transport.test.ts) cover malformed wire values. Real SDK session qualification remains. | +| LIBID-PROVER-009 | Automated | [Correlation tests](../src/notary/notarize.test.ts) mutate authority/directions/ranges/hashes/openings and check native adjacent-range coalescing. [GitHub token tests](../src/platforms/github/1/token.test.ts) require complete request disclosure and exact form bytes; [transcript tests](../src/notary/transcript.test.ts) cover head framing and identity field order. Signed source bytes are never re-encoded. | +| LIBID-PROVER-010 | Partial | [Pinned full decoder fixture](../src/notary/decode.test.ts), u64/bounds/mutations; broader popup projection mutation checks remain. | +| LIBID-PROVER-011 | Automated | [Progress tests](../src/ccdp/documents/progress.test.ts), [Prover delivery tests](../src/ccdp/documents/prover.test.ts) and [browser UI](../e2e/flow.spec.ts) cover per-platform weights, duplicate/parent suppression, late attestations and separate local delivery completion. The bar advances independently of stage labels and claims no Application acceptance. | +| LIBID-PROVER-012 | Partial | [Engine worker tests](../src/barretenberg/engine.worker.test.ts) hold circuit/key, ACVM, ABI, backend and witness independently; cover readiness for inputs and the joined proof gate in both completion orders. Real Google and bearer proofs pass in Chromium, Firefox and WebKit; a held real bb WASM request confirms witness execution starts before backend readiness and the resulting proof verifies against the released key. The full runtime-import/prefetch/input overlap matrix remains incomplete. | +| LIBID-PROVER-013 | Partial | [Engine qualification](../e2e/smoke.ts), [Google vectors](../src/barretenberg/circuits/oidc_google/inputs.test.ts), and notary/GitHub units cover pure contracts. Full real-platform session and fault qualification remains. | +| LIBID-PROVER-014 | Partial | [Engine worker tests](../src/barretenberg/engine.worker.test.ts) cover fail-fast initialization/witness errors, single backend cleanup and suppressed late delivery; [Engine owner tests](../src/barretenberg/engine.test.ts) cover cancellation during overlap and failure before input dispatch; [Engine qualification](../e2e/smoke.ts), [Google vectors](../src/barretenberg/circuits/oidc_google/inputs.test.ts), and notary/GitHub units cover pure contracts. Full real-platform session and fault qualification remains. | +| LIBID-PROVER-015 | Partial | Real worker reports effective proof threads/shared memory; separate TLSN pool measurements and low-capability device cases remain. | +| LIBID-PROVER-016 | Partial | [Engine qualification](../e2e/smoke.ts), [Google vectors](../src/barretenberg/circuits/oidc_google/inputs.test.ts), and notary/GitHub units cover pure contracts. Full real-platform session and fault qualification remains. | +| LIBID-PROVER-017 | Partial | [Engine qualification](../e2e/smoke.ts), [Google vectors](../src/barretenberg/circuits/oidc_google/inputs.test.ts), and notary/GitHub units cover pure contracts. Full real-platform session and fault qualification remains. | +| LIBID-PROVER-018 | Partial | [Session tests](../src/notary/session.test.ts) cover pre-abort, shared-worker ownership, independent reply routing, completed-session cleanup, and cancellation/failure with pending preparation and attestations. [Worker tests](../src/notary/session.worker.test.ts) cover one initialization, overlapping socket/runtime startup in both completion orders, early failure cleanup, a socket closed before runtime readiness, and concurrent setup with separate transcripts. Full real-platform fault qualification remains. | +| LIBID-PROVER-019 | Partial | The [runtime tests](../e2e/runtime.spec.ts) run one/two unauthenticated X requests and both GitHub endpoints with deliberately invalid credentials through the matched RC3 notary and one shared browser WASM runtime, alongside a separately generated and released-key-verified fixture proof. The tests are part of the normal Playwright command and the workspace Browser tests CI job. This does not qualify authenticated X/GitHub token/identity sessions, their paired bearer commitments, matching PlatformVerifier acceptance or real mobile behavior. Shared-runtime consent-flow timing remains pending. | +| LIBID-PROVER-020 | Deferred | The `identity-credential-wait` extension is not yet emitted. Core token/identity operations do not isolate session-ready-to-bearer wait; that measurement remains unavailable while full metrics collection is deferred. | +| LIBID-PROVER-021 | Partial | [Engine qualification](../e2e/smoke.ts), [Google vectors](../src/barretenberg/circuits/oidc_google/inputs.test.ts), and notary/GitHub units cover pure contracts. Full real-platform session and fault qualification remains. | +| LIBID-OAUTH-001 | Partial | [Configuration tests](../src/ccdp/client/ceremony.test.ts) derive fixed `/auth/callback` from the canonical Bridge origin and reject supplied `callbackPath` and `redirectUri` fields. [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-002 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. Callback-to-Prover exact Application-origin continuity is covered by the [handoff checks](#exact-origin-handoff), including a changed origin in the same opener window. | +| LIBID-OAUTH-003 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-005 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-006 | Partial | [Google return units](../src/platforms/google/1/oauth.test.ts) and [X/GitHub return units](../src/platforms/codeReturn.test.ts) cover new metadata on success, denial and error without changing evidence or outcome. The [actual-popup Google fixture proof](../e2e/flow.spec.ts) carries `version_info` and unknown metadata through Callback and Prover and verifies the generated proof independently. Client/CCDP tests cover core boundaries; complete provider state and replay coverage remains. | +| LIBID-OAUTH-007 | Partial | [Google return units](../src/platforms/google/1/oauth.test.ts) and [X/GitHub return units](../src/platforms/codeReturn.test.ts) retain duplicate, malformed/oversized, mixed-outcome, extra-credential, issuer and wrong-transport rejection alongside ignored metadata. Client/CCDP units and actual-popup flows cover core boundaries; complete row-specific provider/runtime coverage remains. | +| LIBID-OAUTH-010 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-011 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-012 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-013 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-014 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-015 | Partial | [GitHub pipeline tests](../src/platforms/github/1/prover.test.ts) exercise two browser sessions with no HTTP fetch to Bridge. Closure and late failure discard provisional work; authenticated browser interruption and fresh-consent qualification remain. | +| LIBID-OAUTH-016 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-017 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. Callback-to-Prover exact Application-origin continuity is covered by the [handoff checks](#exact-origin-handoff), including a changed origin in the same opener window. | +| LIBID-OAUTH-018 | Partial | [GitHub return parsing](../src/platforms/codeReturn.test.ts) and [Prover tests](../src/platforms/github/1/prover.test.ts) cover issuer-bound denial and provider error details before exchange. Actual-popup Google denial is browser-tested; full live-provider denial/replay matrix remains. The current one-way `UserDenied` message is covered by Client and document tests. | +| LIBID-OAUTH-019 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-020 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-021 | Partial | [CCDP tests](../src/ccdp/index.test.ts) accept null or a canonical notary origin for any platform, validate nullable verifier syntax and reject ledger/hash/flag fields. X/GitHub pipeline tests reject missing notary addresses and PKCE verifiers before network work. [Prover tests](../src/ccdp/documents/prover.test.ts) pass the validated address to platform work without ledger decoding. Browser tests use the actual popup carrier and explicit shared ledger fixture. | +| LIBID-OAUTH-022 | Partial | [Wire codecs](../src/ccdp/index.test.ts), [client state tests](../src/ccdp/client/ceremony.test.ts), and [error tests](../src/errors.test.ts) cover the five exact records, legacy/unknown fields, core phases, bounded extensions, operation context and opaque text. Error bounds are not a credential-redaction guarantee; see [CeremonyFailed](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md#ceremonyfailed). Codecs reject retired `cancel`, `denied`, and `abort` records; Callback and Prover register no cancellation handler. Nested instrumentation shape and bounds are covered. | +| LIBID-OAUTH-023 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-024 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-025 | Partial | [Client/CCDP units](../src/ccdp/client/ceremony.test.ts), [wire codecs](../src/ccdp/index.test.ts), GitHub request validation and actual-popup flows cover core boundaries. Complete row-specific provider grammar/adversarial/runtime coverage remains. | +| LIBID-OAUTH-026 | Partial | [Actual-popup authenticated worker failure](../e2e/flow.spec.ts) aborts before OAuth; exhaustive activation/dispatch faults remain. | +| LIBID-OAUTH-027 | Partial | [Client tests](../src/ccdp/client/ceremony.test.ts) and actual-popup browser suite; generic fallback carrier roundtrips remain external. | +| LIBID-OAUTH-028 | Partial | [Client state tests](../src/ccdp/client/ceremony.test.ts), [Callback tests](../src/ccdp/documents/callback.test.ts), [Prover tests](../src/ccdp/documents/prover.test.ts) and [error tests](../src/errors.test.ts) cover event readiness, terminal outcomes and reporting. The full browser fault/delay matrix remains. | +| LIBID-OAUTH-029 | Partial | [Client state tests](../src/ccdp/client/ceremony.test.ts), [Callback tests](../src/ccdp/documents/callback.test.ts), [Prover tests](../src/ccdp/documents/prover.test.ts) and [error tests](../src/errors.test.ts) cover event readiness, terminal outcomes and reporting. The full browser fault/delay matrix remains. The status API has no cancellation outcome; client closure/race tests and the dev app cover connection termination and app-owned cancellation intent. | +| LIBID-OAUTH-030 | Partial | [Client state tests](../src/ccdp/client/ceremony.test.ts), [Callback tests](../src/ccdp/documents/callback.test.ts), [Prover tests](../src/ccdp/documents/prover.test.ts) and [error tests](../src/errors.test.ts) cover event readiness, terminal outcomes and reporting. The full browser fault/delay matrix remains. | +| LIBID-OAUTH-031 | Partial | [Return parser tests](../src/platforms/codeReturn.test.ts) and [GitHub pipeline tests](../src/platforms/github/1/prover.test.ts) cover issuer validation and rejection before token exchange. The full success/denial/error spelling matrix and live platform return remain qualification work. | +| KIT-001 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| KIT-001A | Partial | Built exact pinned Dockerfile and tested emitted responses through its image, including uncacheable 404s and the `/health` probe ([distribution tests](../build/distribution.test.ts)); the workspace **CCDP image** CI job repeats this on every change. Exhaustive forbidden route/method and deployment-ingress checks remain. | +| KIT-001B | Partial | [Generated rewrites/ordered policies against actual SWS](../build/distribution.test.ts), [public route browser checks](../e2e/flow.spec.ts); full rename mutation matrix remains. | +| KIT-002 | Partial | [Config/client tests](../src/ccdp/client/ceremony.test.ts) cover credential validation, freezing and unchanged forwarding. [Dev browser tests](../../../apps/dev/src/app.spec.ts) exercise Bridge-supplied credentials and reject missing/invalid values. The updated Bridge advertises the configured credential in the [recorded integration checks](qualification.md#evidence-obtained); live consent and released-verifier acceptance remain separate. | +| KIT-003 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| KIT-004 | External / partial | [Harness origin admission](../e2e/admission.spec.ts) admits configured app/CCDP origins and absent Origin only with exact same-origin fetch metadata, rejects unlisted/null Origin regardless of metadata, and checks CORS without exposing allowlists. The updated Bridge also passed these [local integration checks](qualification.md#evidence-obtained). Production Bridge deployment validation, effective-list derivation and full ingress edge cases remain external qualification. | +| KIT-005 | External / partial | [Harness origin admission](../e2e/admission.spec.ts) admits configured app/CCDP origins and absent Origin only with exact same-origin fetch metadata, rejects unlisted/null Origin regardless of metadata, and checks CORS without exposing allowlists. The updated Bridge also passed these [local integration checks](qualification.md#evidence-obtained). Production Bridge deployment validation, effective-list derivation and full ingress edge cases remain external qualification. | +| KIT-006 | Partial | [Callback units](../src/ccdp/documents/callback.test.ts) and [browser flows](../e2e/flow.spec.ts) check clearing, bundled dispatch and private navigation without an external Callback script. Production Bridge ingress remains external. | +| KIT-007 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| KIT-008 | Partial | [Live ID/coercion tests](../src/ccdp/client/ceremony.test.ts) plus UUID boundary vectors; full popup carrier misuse matrix belongs to popup qualification. | +| KIT-009 | Partial | [Artifact tests](../build/distribution.test.ts) and [browser HTTP checks](../e2e/flow.spec.ts) check request-invariant bytes and hash-preserving configuration. Production atomic refresh remains external. | +| KIT-010 | Partial | [Callback units](../src/ccdp/documents/callback.test.ts), [artifact insertion tests](../build/distribution.test.ts), and [browser flows](../e2e/flow.spec.ts) cover clearing, required input-list validation, missing/duplicate markers, escaping, and local unsupported-version failure without connection setup. The updated Bridge passed local insertion and simulated-denial round trips through its actual Callback in all three desktop engines; production refresh and ingress qualification remain. | +| KIT-011 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| KIT-012 | Partial | [Callback tests](../src/ccdp/documents/callback.test.ts) cover private handoff; [GitHub pipeline tests](../src/platforms/github/1/prover.test.ts) validate issuer, denial and required credential before any exchange. Full live-provider return qualification remains. | +| KIT-013 | Retired | PR #13 / #35 removed the Bridge token endpoint. Browser equivalents are tracked by LIBID-PROVER-004 and LIBID-PROVER-009; endpoint-specific admission/CORS/egress tests no longer apply. | +| KIT-014 | Retired | PR #13 / #35 removed the Bridge token endpoint. Browser interruption, disposal and fresh-OAuth requirements remain in LIBID-OAUTH-013 and LIBID-OAUTH-015. | +| KIT-015 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| KIT-016 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| KIT-017 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| KIT-018 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| KIT-020 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| KIT-021 | External / partial | [Artifact tests](../build/distribution.test.ts) check executable-hash consistency and data-only substitution. Conditional/compressed refresh, last-good retention and a compatibility-window update require production Bridge qualification. | +| KIT-022 | External / partial | Bridge/deployment operator owns this contract. The independent HTTPS harness exercises a controlled example; production configuration, origin admission, request logging and service behavior still need deployment qualification. | +| CSP-001 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-002 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-003 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-004 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-005 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-006 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. Callback-to-Prover exact Application-origin continuity is covered by the [handoff checks](#exact-origin-handoff), including a changed origin in the same opener window. | +| CSP-007 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [browser flows](../e2e/flow.spec.ts) check embedded Callback execution with external scripts blocked, plus local unsupported/unconfigured failure. Full hostile-policy and optional-fallback combinations remain. | +| CSP-008 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-009 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-010 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-011 | Partial | [Notary build tests](../build/notary.test.ts) check HTTPS/WSS network permissions while keeping script/worker sources restricted. [Distribution checks](../build/distribution.test.ts) test emitted response policies. Live cross-network and multi-Bridge notarization remain external qualification. | +| CSP-013 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-014 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-015 | Automated | [Actual SWS HTTP checks](../build/distribution.test.ts) validate generated MIME/nosniff/isolation/cache/ETag policies. Immutable policy IDs retain previous responses. | +| CSP-016 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-017 | Partial | [Worker lifetime tests](../src/assets/worker.test.ts) keep fetch and prefetch events alive through persistence. [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-018 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-019 | Partial | [Actual SWS HTTP tests](../build/distribution.test.ts) and [three-engine popup/proof flows](../e2e/flow.spec.ts) check emitted policies. Full hostile-origin/unsupported-capability/fallback combinations remain. | +| CSP-020 | Partial | [Google proof flows](../e2e/flow.spec.ts) and [real one/two-session TLSN probes alongside proving](../e2e/runtime.spec.ts) exercise emitted CSP and nested workers. Local complete-suite evidence exists; hosted Firefox proof/concurrency timeouts remain unresolved. Primary-DIP notarization and physical-device coverage remain; see [qualification](qualification.md#evidence-obtained). | +| LIBID-BROWSER-001 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-002 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-003 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-004 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-005 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-006 | Automated | [Shared events](../src/events.test.ts), [client tests](../src/ccdp/client/ceremony.test.ts), and [dev browser tests](../../../apps/dev/src/app.spec.ts) cover monotonic stage subsets, producer timestamps, overlapping durations, terminal outcomes and observer reentry. Synthetic transport cases do not qualify real OAuth or physical devices. | +| LIBID-BROWSER-007 | Partial | [Prover document tests](../src/ccdp/documents/prover.test.ts), [GitHub pipeline tests](../src/platforms/github/1/prover.test.ts) and [X pipeline tests](../src/platforms/x/1/prover.test.ts), and [dev history tests](../../../apps/dev/src/app.spec.ts) exercise the common event transport and per-platform presentation. Real Google proofs exercise its actual producer; live X/GitHub matched-notary event runs remain unqualified by automation. | +| LIBID-BROWSER-008 | Partial | [Client tests](../src/ccdp/client/ceremony.test.ts) and [document tests](../src/ccdp/documents/prefetch.test.ts) enforce mandatory readiness, core state/cardinality and observer independence; missing optional extension observations cannot abort delivery. Actual-popup flows cover navigation; the complete hostile-origin/device matrix remains. | +| LIBID-BROWSER-009 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-010 | Blocked | Missing observable issuance anchor and request-direction-only SDK completion contract; see [qualification](qualification.md#remaining-qualification). No response-timeout substitute. | +| LIBID-BROWSER-011 | External | Requires real devices, provider registrations/accounts and native-app handoffs; emulation is insufficient. | +| LIBID-BROWSER-012 | External | Optional external fallback adapter/signaling is not implemented by ceremony; requires corresponding constructors and real openerless returns. | +| LIBID-BROWSER-013 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. Client closure/race tests and dev-app browser tests cover closure without a CCDP message. Retaining the connection after cancellation is not exposed by the current API. | +| LIBID-BROWSER-014 | Partial | [Actual-popup browser case](../e2e/flow.spec.ts) exercises two independently supplied connections. An earlier WebKit completion timeout passed five unchanged focused repetitions and the later complete suite. Its cause remains unresolved; see [qualification](qualification.md). | +| LIBID-BROWSER-015 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-016 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-017 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-018 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. The current denial message and closure without a CCDP cancel are covered; physical-device behavior remains external. | +| LIBID-BROWSER-019 | External | Requires physical iOS/Android memory-pressure, suspension, chrome and scheduling tests. | +| LIBID-BROWSER-020 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-021 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-022 | Partial | [Actual-popup browser flows](../e2e/flow.spec.ts) and [client state tests](../src/ccdp/client/ceremony.test.ts) cover core behavior. Full lifecycle/reload/closure/fallback/device combinations in this row remain. | +| LIBID-BROWSER-024 | Partial | [Prover document tests](../src/ccdp/documents/prover.test.ts) and [shared events](../src/events.test.ts) exercise local UI projection and observer isolation; [browser tests](../e2e/flow.spec.ts) check operation-driven progress, stage labels, the slow-proving hint and cleanup. Physical background/suspension behavior remains unqualified. | +| LIBID-BROWSER-025 | Partial | [Browser fake-clock UI test](../e2e/flow.spec.ts) covers hint/timer cleanup. Real Vanadium check remains. | +| LIBID-BROWSER-028 | External | Build-owned fallback integration exists; actual adapter/signaling and private openerless handoff qualification remain. | +| LIBID-BROWSER-029 | Deferred / partial | Full resource accounting/export are deferred. Operation events, package-owned progress and focused measurements exist; complete prescribed spans/timings/export remain untested and unclaimed. | +| LIBID-BROWSER-030 | Partial | [Prover document tests](../src/ccdp/documents/prover.test.ts) check navigation timeOrigin, ordering and direct-path absence; [client tests](../src/ccdp/client/ceremony.test.ts) preserve retrospective timestamps. [Dev app browser tests](../../../apps/dev/src/app.spec.ts) display the fallback-to-readiness interval despite forwarding delay and freeze it at the terminal outcome; synthetic event delivery does not qualify replacement itself. Independent browser delay/missing-connection combinations remain. | + +## Exact-origin handoff + +[TEST-CCDP-03 and TEST-CCDP-04](https://github.com/libid-org/libid/blob/docs/ceremony-browser-architecture/specs/ccdp.md#conformance) +map to [fragment codecs](../src/ccdp/index.test.ts), +[Callback](../src/ccdp/documents/callback.test.ts), +[Prover](../src/ccdp/documents/prover.test.ts), and +[actual-popup handoff](../e2e/flow.spec.ts) checks. These cover exact origin +selection rather than allowlist order or OAuth fields, invalid/missing/duplicate +origin inputs, port preservation through isolation, and a different origin in +the same opener window. The popup PR owns restored/fallback carrier admission +and transport-version tests; real WebRTC and physical-device qualification remain +separate requirements. diff --git a/ts/packages/ceremony/e2e/admission.spec.ts b/ts/packages/ceremony/e2e/admission.spec.ts new file mode 100644 index 00000000..0e5590f5 --- /dev/null +++ b/ts/packages/ceremony/e2e/admission.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from './fixtures.js' + +test('Bridge config admits its effective origins without exposing the allowlist [KIT-004] [KIT-005]', async ({ + request, + app, + bridge, + ccdp, +}) => { + for (const [origin, site] of [ + [app, 'cross-site'], + [ccdp, undefined], + ['https://other.test', 'same-origin'], + ['null', 'same-origin'], + [undefined, undefined], + [undefined, 'same-origin'], + [undefined, 'same-site'], + [undefined, 'cross-site'], + [undefined, 'none'], + ]) { + const response = await request.get(`${bridge}/api/v1/ceremony/config`, { + headers: { + ...(origin === undefined ? {} : { Origin: origin }), + ...(site === undefined ? {} : { 'Sec-Fetch-Site': site }), + }, + }) + const admitted = + origin === app || origin === ccdp || (origin === undefined && site === 'same-origin') + expect(response.status()).toBe(admitted ? 200 : 403) + expect(response.headers()['access-control-allow-origin']).toBe(admitted ? origin : undefined) + if (admitted) { + expect(Object.keys(await response.json()).sort()).toEqual(['ccdpOrigin', 'platforms']) + expect(response.headers().vary).toBe('Origin, Sec-Fetch-Site') + } + } +}) diff --git a/ts/packages/ceremony/e2e/app.ts b/ts/packages/ceremony/e2e/app.ts new file mode 100644 index 00000000..9c500aed --- /dev/null +++ b/ts/packages/ceremony/e2e/app.ts @@ -0,0 +1,83 @@ +import { mainnet, testnet } from '@libid/ledger/testing' +import { type Message, PopupConnection, PopupWindow } from '@libid/popup' +import { CeremonyError, createCCDPClient } from '../src/ccdp/client/index.js' + +const bridge = `${location.protocol}//localhost:${Number(location.port) + 1}`, + ccdp = `${location.protocol}//localhost:${Number(location.port) + 2}` + +const client = await createCCDPClient({ oauthBridge: bridge }) + +let activeId = '' + +const anchor = document.querySelector('#launch')! + +let connection: PopupConnection | undefined + +Object.assign(window, { + ready: true, + result: undefined, + events: [], + completed: [], + runs: [], + ceremonyClosed: undefined, + async after() { + await connection!.navigate(`${ccdp}/after`, new URLSearchParams({ id: activeId })) + }, +}) + +anchor.addEventListener('click', (event) => { + const run: Window['runs'][number] = { events: [], diagnostics: [] } + window.runs.push(run) + const id = crypto.randomUUID() + activeId = id + anchor.target = `ceremony-${id}` + const popup = PopupWindow.open(anchor.target, 'width=480,height=720') + connection = PopupConnection.connect(popup, { + connectionId: id, + allowedPopupOrigins: [bridge, ccdp], + onDiagnostic: ({ code }) => run.diagnostics.push(code), + }) + connection.on( + { + type: 'after', + decode(value: unknown) { + if ((value as Message)?.type !== 'after') throw new Error() + return value as Message + }, + }, + () => Object.assign(window, { afterReady: true }), + ) + connection.closed.then((closed) => { + run.closed = closed + Object.assign(window, { ceremonyClosed: closed }) + }) + const ceremony = client.new( + connection, + id, + 'google', + new URL(location.href).searchParams.get('ledger') === 'test:mainnet' ? mainnet : testnet, + new Uint8Array(32), + new Uint8Array([1]), + ) + anchor.href = ceremony.launchUrl + Object.assign(window, { cancel: () => connection!.close(), ceremony }) + ceremony.onEvent((event) => { + run.events.push(event) + window.events.push(event) + }) + void ceremony + .proveUserIdentity() + .then((result) => { + run.outcome = result.status + window.completed.push(result) + Object.assign(window, { result }) + }) + .catch((error: unknown) => { + run.outcome = 'failed' + Object.assign(window, { + result: { status: 'failed' }, + failureEvent: error instanceof CeremonyError ? error.event : undefined, + }) + }) + if (popup.opened) event.preventDefault() +}) diff --git a/ts/packages/ceremony/e2e/build-smoke.mjs b/ts/packages/ceremony/e2e/build-smoke.mjs new file mode 100644 index 00000000..e1442048 --- /dev/null +++ b/ts/packages/ceremony/e2e/build-smoke.mjs @@ -0,0 +1,36 @@ +import { join } from 'node:path' +import { mediaType, resolveAssets } from '../build/assets.ts' +import { bundle } from '../build/bundle.ts' +import { responseHeaders } from '../build/profiles.ts' +import { packageDir } from '../build/release.ts' +import { writeDistribution } from '../build/sws.ts' + +const data = await resolveAssets() + +const emitted = await bundle('e2e/smoke.ts', data, { groupModules: false }) + +const records = new Map(data.local) + +const options = {} + +for (const item of emitted.output) { + const path = `/${item.fileName}` + const policy = emitted.workerFiles.has(item.fileName) ? 'executionWorker' : 'asset' + records.set(path, { + bytes: Buffer.from(item.type === 'chunk' ? item.code : item.source), + headers: { ...responseHeaders(policy, options), 'Content-Type': mediaType(path) }, + }) +} + +const entry = emitted.output.find((item) => item.type === 'chunk' && item.isEntry) + +if (!entry) throw new Error('Missing smoke entry') + +records.set('/index.html', { + bytes: Buffer.from( + `Ceremony engine qualification`, + ), + headers: responseHeaders('proverFallback', options), +}) + +writeDistribution(join(packageDir, '.cache/smoke'), records) diff --git a/ts/packages/ceremony/e2e/build.mjs b/ts/packages/ceremony/e2e/build.mjs new file mode 100644 index 00000000..e925590b --- /dev/null +++ b/ts/packages/ceremony/e2e/build.mjs @@ -0,0 +1,24 @@ +import { join } from 'node:path' +import { build } from 'vite' +import { packageDir } from '../build/release.ts' + +for (const [entry, name] of [ + ['e2e/app.ts', 'app.js'], + ['src/ccdp/documents/ui.ts', 'ui.js'], + ['src/events.ts', 'events.js'], + ['src/platforms/google/1/events.ts', 'google-events.js'], + ['../popup/src/index.ts', 'popup.js'], +]) + await build({ + configFile: false, + root: packageDir, + logLevel: 'warn', + build: { + outDir: join(packageDir, '.cache/e2e'), + emptyOutDir: false, + minify: false, + target: 'es2022', + lib: { entry: join(packageDir, entry), formats: ['es'], fileName: () => name }, + rollupOptions: { output: { inlineDynamicImports: true } }, + }, + }) diff --git a/ts/packages/ceremony/e2e/callback.ts b/ts/packages/ceremony/e2e/callback.ts new file mode 100644 index 00000000..9a0daf73 --- /dev/null +++ b/ts/packages/ceremony/e2e/callback.ts @@ -0,0 +1,50 @@ +import { scriptHash } from '../build/profiles.ts' + +/** Reference Bridge data insertion only; this is not a production Bridge server. */ +export function prepareCallback( + html: string, + sourceHeaders: Record, + inputs: readonly [readonly string[], string, ...unknown[]], +) { + const ccdpOrigin = inputs[1] + const marker = '__LIBID_CALLBACK_CONFIG__' + const slot = `` + const headers = new Headers(sourceHeaders) + const policy = headers.get('Content-Security-Policy') ?? '' + const scriptPolicies = [...policy.matchAll(/(?:^|;)\s*script-src\s+([^;]+)/g)] + const scripts = [...html.matchAll(/]*>[\s\S]*?<\/script\s*>/gi)] + const executable = scripts.filter(([script]) => script !== slot) + const code = + executable.length === 1 + ? /^`, + }) + }) + await page.goto(`${app}?ledger=${native ? 'test:mainnet' : 'test:testnet'}`) + await page.waitForFunction(() => window.ready) + if (native) + await page.evaluate(() => { + window.open = () => null + }) + const popupPromise = context.waitForEvent('page') + await page.locator('#launch').click() + const popup = await popupPromise + try { + await expect.poll(() => page.evaluate(() => window.result)).toEqual({ status: 'denied' }) + } catch (error) { + console.log({ + path: new URL(popup.url()).pathname, + text: await popup.locator('body').innerText(), + errors, + events: await page.evaluate(() => window.events), + }) + throw error + } + expect(await popup.evaluate(() => location.hash)).toBe('') + expect(await popup.evaluate(() => crossOriginIsolated)).toBe(true) + expect(await page.evaluate(() => window.ceremonyClosed)).toBeUndefined() + await page.evaluate(() => window.after()) + await expect.poll(() => page.evaluate(() => window.afterReady)).toBe(true) + expect(errors).toEqual([]) + expect(callbackScripts).toEqual([]) + }) + +test('emitted route policies and inert missing paths [CSP-001] [CSP-003]', async ({ + request, + ccdp, +}) => { + for (const path of [ + '/ccdp/v1/prefetch', + '/ccdp/v1/prover', + '/ccdp/v1/prover/fallback', + '/ccdp/v1/worker.js', + '/ccdp/callback.html', + ]) { + const a = await request.get(ccdp + path), + b = await request.get(`${ccdp + path}?not-a-config=1`) + expect(a.status()).toBe(200) + expect(await a.body()).toEqual(await b.body()) + expect(a.headers()['x-content-type-options']).toBe('nosniff') + expect(a.headers()['cache-control']).toBe('no-cache') + } + const fallback = await request.get(`${ccdp}/ccdp/v1/prover/fallback`) + expect(fallback.headers()['cross-origin-embedder-policy']).toBe('require-corp') + const worker = await request.get(`${ccdp}/ccdp/v1/worker.js`) + expect(worker.headers()['service-worker-allowed']).toBe('/') + expect((await request.get(`${ccdp}/ccdp/v99/prover`)).status()).toBe(404) +}) + +test('migrates the known nested worker and joins a pending prefetch [LIBID-ASSET-020]', async ({ + app, + bridge, + ccdp, + page, + context, + request, + assetControl, +}) => { + const graph = JSON.parse( + readFileSync( + new URL('../.cache/qualification-assets/distribution-graph.json', import.meta.url), + 'utf8', + ), + ) + const asset = graph.requestsByProfile['google/1'].find((r: { url: string }) => + r.url.endsWith('/oidc_google.json'), + ).url + const control = assetControl(asset) + const before = (await (await request.get(`${control}&hold=1`)).json()).count + const seed = await context.newPage() + await seed.goto(`${ccdp}/ccdp/v1/seed`) + await seed.evaluate(async () => { + for (const scope of ['/', '/ccdp/v1/']) { + const r = await navigator.serviceWorker.register('/ccdp/v1/worker.js', { + scope, + type: 'module', + }) + await new Promise((resolve) => { + const poll = () => (r.active?.state === 'activated' ? resolve() : setTimeout(poll, 20)) + poll() + }) + } + }) + await seed.close() + await context.route('https://accounts.google.com/**', async (route) => { + const state = new URL(route.request().url()).searchParams.get('state') + await route.fulfill({ + contentType: 'text/html', + body: ``, + }) + }) + await page.goto(app) + await page.waitForFunction(() => window.ready) + const popupPromise = context.waitForEvent('page') + await page.locator('#launch').click() + const popup = await popupPromise + await expect.poll(() => page.evaluate(() => window.result)).toEqual({ status: 'denied' }) + expect( + await popup.evaluate(async () => + (await navigator.serviceWorker.getRegistrations()).map((r) => new URL(r.scope).pathname), + ), + ).toEqual(['/']) + const fetched = popup.evaluate( + async (asset) => (await fetch(asset)).arrayBuffer().then((b) => b.byteLength), + asset, + ) + // Observe rejection if an assertion fails and teardown closes the popup. + void fetched.catch(() => {}) + await expect.poll(async () => (await (await request.get(control)).json()).count).toBe(before + 1) + await request.get(`${control}&release=1`) + expect(await fetched).toBeGreaterThan(0) + expect((await (await request.get(control)).json()).count).toBe(before + 1) + await page.evaluate(() => window.after()) + await expect.poll(() => page.evaluate(() => window.afterReady)).toBe(true) +}) + +test('immutable assets reuse the HTTP cache after Cache Storage eviction [LIBID-ASSET-017]', async ({ + ccdp, + browser, + request, + assetControl, +}, testInfo) => { + const graph = JSON.parse( + readFileSync( + new URL('../.cache/qualification-assets/distribution-graph.json', import.meta.url), + 'utf8', + ), + ) + const asset = graph.requestsByProfile['google/1'] + .filter( + (r: { url: string; range?: string }) => + r.url.startsWith('/') && !r.range && r.url.endsWith('.js'), + ) + .sort((a: { bytes: number }, b: { bytes: number }) => a.bytes - b.bytes)[0] + const control = assetControl(asset.url) + const before = (await (await request.get(control)).json()).count + const args = [...(testInfo.project.use.launchOptions?.args ?? [])] + if (browser.browserType().name() === 'chromium' && ccdp.startsWith('https:')) + args.push( + `--ignore-certificate-errors-spki-list=${readFileSync(new URL('../.cache/e2e/cert-spki', import.meta.url), 'utf8')}`, + ) + // WebKit's ephemeral test context does not retain this HTTP-cache entry. + const context = await browser + .browserType() + .launchPersistentContext(testInfo.outputPath('http-cache-profile'), { + ...testInfo.project.use.launchOptions, + args, + ignoreHTTPSErrors: true, + }) + try { + const page = await context.newPage() + await page.goto(`${ccdp}/ccdp/v1/seed`) + await page.evaluate(async () => { + await navigator.serviceWorker.register('/ccdp/v1/worker.js', { scope: '/', type: 'module' }) + await navigator.serviceWorker.ready + if (!navigator.serviceWorker.controller) + await new Promise((resolve) => + navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { + once: true, + }), + ) + }) + // No Playwright routes: routing disables the browser HTTP cache being tested. + expect( + await page.evaluate( + async (url) => (await (await fetch(url)).arrayBuffer()).byteLength, + asset.url, + ), + ).toBe(asset.bytes) + expect((await (await request.get(control)).json()).count).toBe(before + 1) + await request.get(`${control}&fail=1`) + for (let attempt = 0; attempt < 2; attempt++) { + // Evict a durable entry so an unfinished write's flight cannot mask HTTP-cache reuse. + await expect + .poll(() => + page.evaluate(async () => { + const cache = await caches.open('libid-ceremony-assets-v1') + return (await cache.keys()).length + }), + ) + .toBe(1) + expect( + await page.evaluate(async (url) => { + await caches.delete('libid-ceremony-assets-v1') + const response = await fetch(url) + if (!response.ok) throw new Error(`Asset response ${response.status}`) + return (await response.arrayBuffer()).byteLength + }, asset.url), + ).toBe(asset.bytes) + } + expect((await (await request.get(control)).json()).count).toBe(before + 1) + } finally { + await context.close() + } +}) + +test('real Google fixture proof under emitted CSP, independently released-key verified [LIBID-PROVER-001] [CSP-020]', async ({ + app, + bridge, + page, + context, +}) => { + test.setTimeout(480000) + // This controlled fixture is old and has its own digest. This proves runtime/key + // compatibility, not real consent or authorization for the application transaction. + // WebKit's controlled-page fetch bypasses Playwright routing. Substitute only + // this public JWKS fixture at the page boundary; all proof assets use real loaders. + // This does not qualify the live JWKS endpoint's CORS/CSP behavior. + await context.addInitScript((key) => { + Date.now = () => 1725001000000 + const fetch = window.fetch + window.fetch = (...args) => + String(args[0]) === 'https://www.googleapis.com/oauth2/v3/certs' + ? Promise.resolve( + new Response(JSON.stringify({ keys: [key] }), { + headers: { 'Content-Type': 'application/json' }, + }), + ) + : fetch(...args) + }, fixture.jwk) + await context.route('https://accounts.google.com/**', async (route) => { + const state = new URL(route.request().url()).searchParams.get('state') + await route.fulfill({ + contentType: 'text/html', + body: ``, + }) + }) + await page.goto(app) + await page.waitForFunction(() => window.ready) + await page.locator('#launch').click() + await expect + .poll( + async () => { + const status = await page.evaluate(() => window.result?.status) + if (status === 'failed') { + console.log('Fixture progress:', await page.evaluate(() => window.events)) + throw new Error('Fixture ceremony failed') + } + return status + }, + { timeout: 420000 }, + ) + .toBe('accepted') + const result = await page.evaluate(() => { + const result = window.result + if (result?.status !== 'accepted' || result.identity.platformId !== 'google') + throw new Error('Missing Google proof') + const proof = result.oauthProof.proof + return { + identity: result.identity, + ...proof, + identityProof: Array.from(proof.identityProof), + signingKeyModulus: Array.from(proof.signingKeyModulus), + } + }) + const proof: GoogleProofV1 = { + tokenExpiresAt: result.tokenExpiresAt, + identityProof: Uint8Array.from(result.identityProof), + signingKeyModulus: Uint8Array.from(result.signingKeyModulus), + } + const digest = Uint8Array.from(Buffer.from(fixture.authorizationDigest.replace(/^0x/, ''), 'hex')) + await verifyBrowserProof('oidc_google', { + proof: result.identityProof, + publicInputs: buildGooglePublicInputs(digest, result.identity, proof), + }) +}) + +test('popup progress follows operation events independently of stage labels [LIBID-PROVER-011] [LIBID-BROWSER-024] [LIBID-BROWSER-025]', async ({ + app, + page, +}) => { + await page.clock.install() + await page.goto(`${app}/ui`) + const bar = page.getByRole('progressbar') + await expect(bar).toHaveAttribute('value', '0') + await expect(page.locator('.libid-activity')).toHaveCount(0) + await expect(page.getByText('Preparing your identity proof')).toBeVisible() + await page.evaluate(() => { + for (const event of ['proof-worker-bootstrap', 'proof-wasm-load']) + window.testEvents.emit({ event, phase: 'finished', timestamp: 1, status: 'active' }) + }) + const value = await bar.evaluate((node: HTMLProgressElement) => node.value) + expect(value).toBeGreaterThan(0) + await expect(page.getByText('Preparing your identity proof')).toBeVisible() + await page.evaluate(() => { + for (const event of ['proof-wasm-load', 'zk-proof-preparation', 'unrelated-observation']) + window.testEvents.emit({ event, phase: 'finished', timestamp: 1, status: 'active' }) + }) + expect(await bar.evaluate((node: HTMLProgressElement) => node.value)).toBe(value) + await page.clock.runFor(15000) + await expect(page.getByText(/Still proving/)).toBeVisible() + await page.evaluate(() => { + window.testEvents.emit({ + event: 'zk-proof-generation', + phase: 'started', + timestamp: 2, + status: 'active', + }) + for (const event of [ + 'proof-circuit-load', + 'proof-backend-initialization', + 'circuit-inputs', + 'signing-key-fetch', + 'witness', + 'proof', + ]) + window.testEvents.emit({ event, phase: 'finished', timestamp: 3, status: 'active' }) + }) + // Proof work fills the bar before backend teardown and delivery. + await expect(bar).toHaveAttribute('value', '1') + await expect(page.getByText('Creating your identity proof with ZK')).toBeVisible() + const frames = await page.evaluate(async () => { + let frames = 0 + const requestFrame = window.requestAnimationFrame.bind(window) + window.requestAnimationFrame = (callback) => + requestFrame((time) => { + frames++ + callback(time) + }) + await window.testView.finishProof() + window.requestAnimationFrame = requestFrame + return frames + }) + expect(frames).toBe(2) + await page.evaluate(() => { + window.testView.stop() + window.testView.delivered() + }) + await expect(bar).toHaveAttribute('value', '1') + await expect(page.getByText('Proof delivered. Return to your application.')).toBeVisible() + await expect(page.getByText(/Still proving/)).toHaveCount(0) + await expect(page.locator('.libid-activity')).toHaveCount(0) +}) + +test('popup paint wait skips hidden documents and tolerates stopped animation frames [LIBID-BROWSER-024]', async ({ + app, + page, +}) => { + await page.goto(`${app}/ui`) + await page.evaluate(async () => { + Object.defineProperty(document, 'hidden', { configurable: true, value: true }) + window.requestAnimationFrame = () => { + throw new Error('Hidden documents should not wait') + } + await window.testView.finishProof() + }) + await expect(page.getByRole('progressbar')).toHaveAttribute('value', '1') + await page.evaluate(async () => { + Object.defineProperty(document, 'hidden', { configurable: true, value: false }) + // Simulate frames stopping while the document is waiting to paint. + window.requestAnimationFrame = () => 0 + await window.testView.finishProof() + }) +}) + +test('authenticated worker failure aborts before OAuth [LIBID-OAUTH-026]', async ({ + app, + page, + context, + assetControl, +}) => { + let oauth = 0 + await context.route('https://accounts.google.com/**', (route) => { + oauth++ + return route.abort() + }) + const control = assetControl('/ccdp/v1/worker.js') + await context.request.get(`${control}&fail`) + await page.goto(app) + await page.waitForFunction(() => window.ready) + await page.locator('#launch').click() + await expect.poll(() => page.evaluate(() => window.result)).toEqual({ status: 'failed' }) + expect(oauth).toBe(0) + expect(await page.evaluate(() => window.failureEvent)).toBe('prefetch-dispatch') +}) + +test('two independently supplied connections cannot replace each other [LIBID-BROWSER-014]', async ({ + app, + bridge, + page, + context, +}) => { + const states = new Set() + await context.route('https://accounts.google.com/**', async (route) => { + const state = new URL(route.request().url()).searchParams.get('state')! + states.add(state) + await route.fulfill({ + contentType: 'text/html', + body: ``, + }) + }) + await page.goto(app) + await page.waitForFunction(() => window.ready) + await page.locator('#launch').click() + await page.locator('#launch').click() + try { + await expect.poll(() => page.evaluate(() => window.completed.length)).toBe(2) + } catch (error) { + console.log( + 'Concurrent ceremony failure', + JSON.stringify({ + states: states.size, + runs: await page.evaluate(() => window.runs), + popups: await Promise.all( + context + .pages() + .filter((popup) => popup !== page) + .map((popup) => + popup + .evaluate(() => ({ + path: location.pathname, + status: document.querySelector('[role="status"]')?.textContent, + })) + .catch(() => 'document unavailable'), + ), + ), + }), + ) + throw error + } + expect(states.size).toBe(2) + expect(await page.evaluate(() => window.completed)).toEqual([ + { status: 'denied' }, + { status: 'denied' }, + ]) +}) + +test('Callback clears unsupported versions and unconfigured direct visits locally [KIT-010] [CSP-007]', async ({ + bridge, + ccdp, + page, +}) => { + const id = '6e171568-54e1-4f0d-aeb5-e8859826476a' + const outbound: string[] = [] + await page.route('**/*', async (route) => { + if (new URL(route.request().url()).pathname === '//auth/callback') + await route.fulfill({ response: await page.request.get(`${bridge}/auth/callback`) }) + else if (route.request().isNavigationRequest()) await route.continue() + else { + outbound.push(route.request().resourceType()) + await route.abort() + } + }) + for (const path of [ + `/auth/callback?state=v99.${id}`, + `/auth/callback#state=v99.${id}`, + `//auth/callback?state=v99.${id}`, + ]) { + // A hash-only navigation in the previous Callback document does not rerun its entry. + await page.goto('about:blank') + await page.goto(bridge + path) + await expect(page.getByRole('status')).toHaveText( + 'This ceremony version is no longer supported. Update the application and try again.', + ) + await expect(page).toHaveURL(bridge + path.split(/[?#]/)[0]) + } + await page.goto(`${ccdp}/ccdp/callback.html#state=v1.${id}`) + // Native JSON error wording differs across engines; the bounded text remains local. + await expect(page.getByRole('status')).toContainText('Return to your application.') + await expect(page.getByRole('status')).toContainText(/JSON/i) + expect(page.url()).toBe(`${ccdp}/ccdp/callback.html`) + expect(outbound).toEqual([]) +}) + +// Real RC WASM and its nested module workers; no simulated SDK initialization. +test('released TLSNotary initializes concurrently from mounted assets [LIBID-ASSET-017]', async ({ + ccdp, + page, + context, +}) => { + test.setTimeout(90000) + const graph = JSON.parse( + readFileSync( + new URL('../.cache/qualification-assets/distribution-graph.json', import.meta.url), + 'utf8', + ), + ) + const resources = graph.requestsByProfile['x/1'] as { url: string }[] + const moduleUrl = ccdp + resources.find((r) => r.url.endsWith('/tlsn_wasm.js'))!.url + const wasmUrl = ccdp + resources.find((r) => r.url.endsWith('/tlsn_wasm_bg.wasm'))!.url + const snippet = resources.find((r) => + /\/snippets\/web-spawn-[^/]+\/js\/spawn\.js$/.test(r.url), + )!.url + const count = async () => + ( + await ( + await context.request.get( + `${ccdp}/qualification-control?asset=${encodeURIComponent(snippet)}`, + ) + ).json() + ).count + const before = await count() + await page.goto(`${ccdp}/ccdp/v1/prover/fallback`) + const result = await page.evaluate( + async ({ moduleUrl, wasmUrl }) => { + const code = `try{const {default:init,initialize}=await import(${JSON.stringify(moduleUrl)});await init({module_or_path:${JSON.stringify(wasmUrl)}});await initialize(null,2);postMessage('ready')}catch(e){postMessage(String(e))}` + const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' })) + try { + return await Promise.all( + [0, 1].map( + () => + new Promise((resolve, reject) => { + const worker = new Worker(url, { type: 'module' }) + const timeout = setTimeout(() => { + worker.terminate() + reject(new Error('TLSN initialization timed out')) + }, 60000) + worker.onmessage = (event) => { + clearTimeout(timeout) + worker.terminate() + resolve(event.data) + } + worker.onerror = (event) => { + clearTimeout(timeout) + worker.terminate() + reject(new Error(`TLSN worker failed: ${event.message}`)) + } + }), + ), + ) + } finally { + URL.revokeObjectURL(url) + } + }, + { moduleUrl, wasmUrl }, + ) + expect(result).toEqual(['ready', 'ready']) + expect(await count()).toBeGreaterThan(before) +}) + +test('Prover rejects a changed Application origin in the same opener window [TEST-CCDP-04]', async ({ + app, + bridge, + ccdp, + page, + context, +}) => { + await context.route('https://accounts.google.com/**', async (route) => { + const state = new URL(route.request().url()).searchParams.get('state') + await route.fulfill({ + contentType: 'text/html', + body: ``, + }) + }) + // A second origin admitted by Callback's deployment. The retained WindowProxy + // is unchanged, but this new document is not the Application Callback bound. + await context.route(`${ccdp}/changed-application`, (route) => + route.fulfill({ + contentType: 'text/html', + body: ``, + }), + ) + await context.route(`${ccdp}/ccdp/v1/prover`, async (route) => { + // Callback already authenticated and constructed the private fragment. + await page.goto(`${ccdp}/changed-application`) + await route.continue() + }) + await page.goto(app) + await page.waitForFunction(() => window.ready) + const popupPromise = context.waitForEvent('page') + await page.locator('#launch').click() + const popup = await popupPromise + await expect(popup.locator('body')).toContainText('connection failed authentication') + expect(await popup.evaluate(() => location.hash)).toBe('') + expect(new URL(page.url()).origin).toBe(ccdp) +}) + +test('provider isolation ends Application and returning Callback reports its own connection failure [TEST-CCDP-08] [LIBID-BROWSER-005]', async ({ + app, + bridge, + page, + context, +}) => { + await context.route('https://accounts.google.com/**', async (route) => { + const state = new URL(route.request().url()).searchParams.get('state') + // Serve COOP over HTTP: WebKit does not apply it to the intercepted response. + await route.fulfill({ + contentType: 'text/html', + body: ``, + }) + }) + await page.goto(app) + await page.waitForFunction(() => window.ready) + const opened = context.waitForEvent('page') + await page.locator('#launch').click() + const popup = await opened + await expect(popup.locator('#return')).toBeVisible() + expect(await popup.evaluate(() => window.opener === null)).toBe(true) + // Background polling may be suspended; observe from the active application. + await page.bringToFront() + await expect + .poll(() => page.evaluate(() => window.ceremonyClosed)) + .toEqual({ + outcome: 'failed', + code: 'popup-unavailable', + }) + // Consent can continue even though Application has lost the window handle. + expect(popup.isClosed()).toBe(false) + await popup.locator('#return').click() + await expect(popup.locator('[role="status"]')).toContainText( + 'Unable to reconnect to the application', + ) + await expect(popup.locator('[role="status"]')).toContainText( + 'The sign-in provider may have isolated this window', + ) + await expect(popup.locator('progress')).toHaveCount(0) + await expect(popup).toHaveURL(`${bridge}/auth/callback`) + expect(await popup.evaluate(() => window.opener)).toBeNull() + expect(await page.evaluate(() => window.completed)).toEqual([]) +}) diff --git a/ts/packages/ceremony/e2e/globals.d.ts b/ts/packages/ceremony/e2e/globals.d.ts new file mode 100644 index 00000000..6e372f83 --- /dev/null +++ b/ts/packages/ceremony/e2e/globals.d.ts @@ -0,0 +1,33 @@ +import type { CeremonyEvent } from '../src/ccdp/client/index.js' +import type { Events } from '../src/events.js' +import type { IdentityResult } from '../src/index.js' + +declare global { + interface Window { + failureEvent?: string + ready: boolean + completed: IdentityResult<'google'>[] + runs: { events: CeremonyEvent[]; diagnostics: string[]; outcome?: string; closed?: unknown }[] + testEvents: Events + testView: { + trackProof(weights: Readonly>): void + finishProof(): Promise + delivered(): void + stop(): void + } + result: IdentityResult<'google'> | { status: 'failed' } | undefined + events: CeremonyEvent[] + ceremonyClosed: unknown + afterReady: boolean + after(): Promise + proveBearerFixture(): Promise<{ + proof: number[] + publicInputs: string[] + runtime: { effectiveThreads: number; sharedMemory: boolean } + }> + notarizeRequests( + count: number, + platform?: 'x' | 'github', + ): Promise<{ sent: number; received: number; attestedData: number }[]> + } +} diff --git a/ts/packages/ceremony/e2e/runtime.spec.ts b/ts/packages/ceremony/e2e/runtime.spec.ts new file mode 100644 index 00000000..6c68180f --- /dev/null +++ b/ts/packages/ceremony/e2e/runtime.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from './fixtures.js' +import { verifyBrowserProof } from './verify.js' + +// Controlled circuit inputs and unauthenticated X/GitHub requests; no live OAuth credentials. +test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4986/index.html') + await page.waitForFunction(() => typeof window.proveBearerFixture === 'function') + expect(await page.evaluate(() => crossOriginIsolated)).toBe(true) +}) + +test('real bearer-link fixture proof [LIBID-PROVER-001] [LIBID-PROVER-015]', async ({ page }) => { + test.setTimeout(480000) + const result = await page.evaluate(() => window.proveBearerFixture()) + expect(result.runtime.sharedMemory).toBe(true) + expect(result.runtime.effectiveThreads).toBeGreaterThan(1) + await verifyBrowserProof('bearer_link', result) +}) + +for (const [platform, count] of [ + ['x', 1], + ['x', 2], + ['github', 2], +] as const) + test(`real ${platform} notary: ${count} session(s) alongside proving [LIBID-PROVER-019]`, async ({ + page, + }) => { + test.setTimeout(480000) + const logs: string[] = [] + page.on('console', (message) => { + const text = message.text() + if (/sdk-core\/src\/prover\.rs|session driver|HTTP connection error/.test(text)) + logs.push(text) + }) + const [proof, attestations] = await page + .evaluate( + async ({ platform, count }) => + Promise.all([window.proveBearerFixture(), window.notarizeRequests(count, platform)]), + { platform, count }, + ) + .catch((error: unknown) => { + // These sessions contain only the synthetic, unauthenticated requests above. + console.error('Notary runtime progress:', JSON.stringify(logs)) + throw error + }) + await verifyBrowserProof('bearer_link', proof) + expect(attestations).toHaveLength(count) + for (const attestation of attestations) { + expect(attestation.sent).toBeGreaterThan(0) + expect(attestation.received).toBeGreaterThan(0) + expect(attestation.attestedData).toBeGreaterThan(0) + } + }) diff --git a/ts/packages/ceremony/e2e/server.mjs b/ts/packages/ceremony/e2e/server.mjs new file mode 100644 index 00000000..b2c6806e --- /dev/null +++ b/ts/packages/ceremony/e2e/server.mjs @@ -0,0 +1,191 @@ +import { createHash, createPublicKey } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { createServer as createHttpServer, request as proxyRequest } from 'node:http' +import { createServer } from 'node:https' +import { join } from 'node:path' +import { packageDir } from '../build/release.ts' +import { prepareCallback } from './callback.ts' +import { makeCertificate } from './tls.mjs' + +const sws = 'http://127.0.0.1:4980' + +const artifactDir = join(packageDir, '.cache/qualification-assets') + +const counts = new Map(), + holds = new Map(), + failures = new Set() + +const graph = JSON.parse(readFileSync(join(artifactDir, 'distribution-graph.json'))) + +const html = (body) => + `Ceremony qualification${body}` + +const certificate = makeCertificate(['localhost']) + +// Chromium's HTTP cache needs a clean certificate result, not just an ignored TLS error. +writeFileSync( + join(packageDir, '.cache/e2e/cert-spki'), + createHash('sha256') + .update(createPublicKey(certificate.cert).export({ type: 'spki', format: 'der' })) + .digest('base64'), +) + +// Both schemes run the same emitted bytes and protocol tests on separate origins. +for (const secure of [true, false]) { + const offset = secure ? 200 : 100 + const scheme = secure ? 'https' : 'http' + const app = `${scheme}://localhost:${4681 + offset}`, + ccdp = `${scheme}://localhost:${4683 + offset}` + const allowedOrigins = [app, ccdp] + const server = secure ? createServer.bind(null, certificate) : createHttpServer + // Prepared once, independently of OAuth requests; both bytes and policy change together. + const callback = prepareCallback( + readFileSync(join(artifactDir, 'public/ccdp/callback.html'), 'utf8'), + graph.headers['/ccdp/callback.html'], + [allowedOrigins, ccdp], + ) + for (const port of [4681, 4682, 4683]) + server(async (req, res) => { + const path = new URL(req.url, 'https://localhost').pathname + const send = (body, headers = {}, status = 200) => { + const merged = new Headers({ + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }) + for (const [key, value] of Object.entries(headers)) merged.set(key, value) + res.writeHead(status, Object.fromEntries(merged)) + res.end(req.method === 'HEAD' ? undefined : body) + } + if (!['GET', 'HEAD'].includes(req.method)) { + send('Method not allowed', {}, 405) + return + } + try { + if (port === 4681) { + if (['/ui.js', '/events.js', '/google-events.js'].includes(path)) + return send(readFileSync(join(packageDir, '.cache/e2e', path.slice(1))), { + 'Content-Type': 'text/javascript', + }) + if (path === '/ui') + return send( + html( + '
', + ), + ) + if (path === '/app.js') + return send(readFileSync(join(packageDir, '.cache/e2e/app.js')), { + 'Content-Type': 'text/javascript', + }) + if (path === '/') + return send( + html( + 'Start ceremony', + ), + ) + } + if (port === 4682) { + if (path === '/api/v1/ceremony/config') { + const origin = req.headers.origin + const admitted = + origin === undefined + ? req.headers['sec-fetch-site'] === 'same-origin' + : allowedOrigins.includes(origin) + if (!admitted) return send('Forbidden', { Vary: 'Origin, Sec-Fetch-Site' }, 403) + return send( + JSON.stringify({ + ccdpOrigin: ccdp, + platforms: { + google: { + clientId: '407408718192.apps.googleusercontent.com', + ceremonyVersions: [1], + }, + }, + }), + { + 'Content-Type': 'application/json', + ...(origin === undefined ? {} : { 'Access-Control-Allow-Origin': origin }), + Vary: 'Origin, Sec-Fetch-Site', + }, + ) + } + if (path === '/isolating-provider') { + const state = new URL(req.url, app).searchParams.get('state') + const target = `${scheme}://localhost:${4682 + offset}/auth/callback#error=access_denied&state=${encodeURIComponent(state ?? '')}` + return send( + html( + ``, + ), + { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, + ) + } + if (path === '/auth/callback') return send(callback.body, callback.headers) + } + if (port === 4683) { + if (path === '/qualification-control') { + const query = new URL(req.url, ccdp).searchParams, + target = query.get('asset') + if (query.has('fail')) failures.add(target) + if (query.has('restore')) failures.delete(target) + if (query.has('hold')) holds.set(target, []) + if (query.has('release')) { + for (const resume of holds.get(target) ?? []) resume() + holds.delete(target) + } + return send(JSON.stringify({ count: counts.get(target) ?? 0 }), { + 'Content-Type': 'application/json', + }) + } + if (path === '/ccdp/v1/seed') return send(html('Worker seed')) + + if (path === '/popup.js') + return send(readFileSync(join(packageDir, '.cache/e2e/popup.js')), { + 'Content-Type': 'text/javascript', + 'Cross-Origin-Resource-Policy': 'same-origin', + }) + if (path === '/after') + return send( + html( + ``, + ), + { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, + ) + if (failures.has(path)) return send('Unavailable', {}, 503) + if (Object.hasOwn(graph.headers, path)) { + counts.set(path, (counts.get(path) ?? 0) + 1) + if (holds.has(path)) await new Promise((resolve) => holds.get(path).push(resolve)) + } + // Transparent HTTPS ingress to the real static server, including HEAD/ranges/304. + const upstream = proxyRequest( + new URL(req.url, sws), + { + method: req.method, + headers: { ...req.headers, host: new URL(sws).host }, + }, + (response) => { + res.writeHead(response.statusCode, response.headers) + response.pipe(res) + }, + ) + upstream.on('error', () => { + if (!res.headersSent) res.writeHead(502) + res.end() + }) + req.pipe(upstream) + return + } + } catch {} + send( + 'Not found

Not found.

', + { 'Content-Security-Policy': "default-src 'none'" }, + 404, + ) + }).listen(port + offset, '127.0.0.1', () => + console.log(`Ceremony harness listening on ${port}`), + ) +} diff --git a/ts/packages/ceremony/e2e/smoke.ts b/ts/packages/ceremony/e2e/smoke.ts new file mode 100644 index 00000000..d348da5d --- /dev/null +++ b/ts/packages/ceremony/e2e/smoke.ts @@ -0,0 +1,104 @@ +import { sha256 } from '@noble/hashes/sha2.js' +import { resolve as assetUrl } from '../src/assets/index.js' +import { + bearerCircuit, + bearerVerificationKey, +} from '../src/barretenberg/circuits/bearer_link/bearer_link.assets.js' +import { buildBearerLinkWitness } from '../src/barretenberg/circuits/bearer_link/inputs.js' +import { ProofEngine } from '../src/barretenberg/engine.js' +import { Notarization } from '../src/notary/session.js' +import { buildTokenRequest } from '../src/platforms/github/1/token.js' +import { identityRequest } from '../src/platforms/github/1/transcript.js' + +Object.assign(window, { + async proveBearerFixture() { + const bearer = `AAAA${'x'.repeat(96)}` + const opening = (start: number) => { + const blinder = Uint8Array.from({ length: 16 }, (_, i) => i + start) + return { + start: 0, + end: 100, + blinder, + hash: sha256(Uint8Array.from([...new TextEncoder().encode(bearer), ...blinder])), + } + } + const inputs = buildBearerLinkWitness(bearer, opening(0), opening(16)) + const engine = new ProofEngine({ + circuitUrl: assetUrl(bearerCircuit), + verificationKeyUrl: assetUrl(bearerVerificationKey), + threads: 2, + }) + try { + const result = await engine.prove(inputs) + return { + proof: Array.from(result.proof), + publicInputs: result.publicInputs, + runtime: result.runtime, + } + } finally { + engine.destroy() + } + }, + async notarizeRequests(count: number, platform: 'x' | 'github' = 'x') { + const abort = new AbortController() + const pending = Array.from({ length: count }, () => 'prepare') + const timer = setTimeout( + () => + abort.abort( + new Error( + `Notary smoke timed out: ${JSON.stringify({ platform, hardwareConcurrency: navigator.hardwareConcurrency, pending })}`, + ), + ), + 120000, + ) + try { + const notary = new Notarization('http://localhost:4987', abort.signal) + const results = await Promise.all( + Array.from({ length: count }, async (_, index) => { + // Deliberately invalid fixture credentials exercise both public GitHub endpoints, + // not a successful OAuth exchange or authenticated identity. + const request = + platform === 'github' + ? index === 0 + ? buildTokenRequest({ + clientId: 'fixture', + code: 'fixture', + redirectUri: 'http://localhost:4682/auth/callback', + codeVerifier: 'A'.repeat(43), + clientCredential: 'fixture', + }) + : identityRequest('fixture') + : { + url: 'https://api.x.com/2/users/me', + method: 'GET' as const, + headers: { + Host: new TextEncoder().encode('api.x.com'), + Connection: new TextEncoder().encode('close'), + }, + body: new Uint8Array(), + } + const session = await notary.prepare(request.url) + pending[index] = 'send' + const transcript = await session.send(request) + pending[index] = 'reveal' + const result = await session.reveal({ + sent: [{ start: 0, end: transcript.sent.length }], + received: [{ start: 0, end: transcript.received.length }], + }) + pending[index] = 'attestation' + const attestation = await result.attestation + pending[index] = 'done' + return { + sent: transcript.sent.length, + received: transcript.received.length, + attestedData: attestation.attestedData.length, + } + }), + ) + return results + } finally { + clearTimeout(timer) + abort.abort() + } + }, +}) diff --git a/ts/packages/ceremony/e2e/tls.mjs b/ts/packages/ceremony/e2e/tls.mjs new file mode 100644 index 00000000..3ec1e9d8 --- /dev/null +++ b/ts/packages/ceremony/e2e/tls.mjs @@ -0,0 +1,38 @@ +// Per-run self-signed certificate covering every e2e hostname, so the +// multi-origin topology is genuinely cross-origin over HTTPS (the only way +// COOP and opener severing behave realistically). Playwright runs with +// ignoreHTTPSErrors; nothing here is a production artifact. + +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +export function makeCertificate(hostnames, days = 2) { + mkdirSync(new URL('../.cache/', import.meta.url), { recursive: true }) + const dir = mkdtempSync(new URL('../.cache/tls-', import.meta.url)) + const key = join(dir, 'key.pem') + const cert = join(dir, 'cert.pem') + const sans = hostnames.map((h) => `DNS:${h}`).join(',') + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + key, + '-out', + cert, + '-days', + String(days), + '-subj', + '/CN=popup-e2e', + '-addext', + `subjectAltName=${sans}`, + ], + { stdio: 'ignore' }, + ) + return { key: readFileSync(key), cert: readFileSync(cert) } +} diff --git a/ts/packages/ceremony/e2e/verify.ts b/ts/packages/ceremony/e2e/verify.ts new file mode 100644 index 00000000..ee835d89 --- /dev/null +++ b/ts/packages/ceremony/e2e/verify.ts @@ -0,0 +1,31 @@ +import { Barretenberg, UltraHonkVerifierBackend } from '@aztec/bb.js' +import { readArchive } from '../build/archive.ts' +import { loadAssetCatalog } from '../build/assets.ts' + +/** Verify fixture browser output in Node with the released key and reject a changed public input. */ +export async function verifyBrowserProof( + name: string, + result: { proof: number[]; publicInputs: string[] }, +): Promise { + const catalog = await loadAssetCatalog() + const circuit = catalog.circuits.find((asset) => asset.member === `${name}.json`) + if (!circuit) throw new Error('Unknown circuit') + const files = await readArchive(circuit.source), + api = await Barretenberg.new({ threads: 1 }) + try { + const verifier = new UltraHonkVerifierBackend(api) + const proofData = { + proof: Uint8Array.from(result.proof), + publicInputs: result.publicInputs, + verificationKey: new Uint8Array(files.get('vk')!), + } + if (!(await verifier.verifyProof(proofData, { verifierTarget: 'evm' }))) + throw new Error('Released-key verification failed') + const publicInputs = [...result.publicInputs] + publicInputs[0] = `0x${(BigInt(publicInputs[0]) ^ 1n).toString(16).padStart(64, '0')}` + if (await verifier.verifyProof({ ...proofData, publicInputs }, { verifierTarget: 'evm' })) + throw new Error('Mutated public input was accepted') + } finally { + await api.destroy() + } +} diff --git a/ts/packages/ceremony/package.json b/ts/packages/ceremony/package.json new file mode 100644 index 00000000..08ba366c --- /dev/null +++ b/ts/packages/ceremony/package.json @@ -0,0 +1,64 @@ +{ + "name": "@libid/ceremony", + "version": "0.0.0", + "private": true, + "description": "Browser OAuth identity ceremonies using an externally supplied popup connection.", + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "git+https://github.com/libid-org/libid.git", + "directory": "ts/packages/ceremony" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./ccdp/client": { + "types": "./dist/ccdp/client/index.d.ts", + "default": "./dist/ccdp/client/index.js" + } + }, + "files": [ + "dist", + "src", + "docs", + "!src/**/*.test.ts", + "!src/**/*.fixture.*", + "build", + "ccdp.Dockerfile" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit && pnpm typecheck:build", + "test": "vitest run", + "test:e2e": "pnpm build:qualification-artifacts && node e2e/build-smoke.mjs && playwright test", + "typecheck:e2e": "tsc -p tsconfig.e2e.json", + "test:e2e:install": "playwright install chromium firefox webkit", + "build:ccdp-artifacts": "node build/distribution.ts", + "test:distribution": "node --test build/*.test.ts", + "typecheck:build": "tsc -p tsconfig.scripts.json", + "build:qualification-artifacts": "node build/distribution.ts --out-dir .cache/qualification-assets" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/estree": "^1.0.8", + "@types/node": "^22.0.0", + "smol-toml": "^1.8.0", + "tar": "^7.5.22", + "typescript": "^5.9.0", + "vite": "^7.3.6", + "vitest": "^3.2.0" + }, + "dependencies": { + "@aztec/bb.js": "5.2.0", + "@libid/ledger": "workspace:^", + "@libid/popup": "workspace:^", + "@noble/hashes": "^2.3.0", + "@noir-lang/acvm_js": "1.0.0-beta.25", + "@noir-lang/noir_js": "1.0.0-beta.25", + "@noir-lang/noirc_abi": "1.0.0-beta.25" + }, + "sideEffects": false +} diff --git a/ts/packages/ceremony/playwright.config.ts b/ts/packages/ceremony/playwright.config.ts new file mode 100644 index 00000000..c8ba195e --- /dev/null +++ b/ts/packages/ceremony/playwright.config.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto' +import { defineConfig, devices } from '@playwright/test' + +// A second invocation must never recreate another run's containers during startup. +const compose = `docker compose -p ceremony-e2e-${randomUUID()} -f e2e/compose.yaml` + +export default defineConfig({ + forbidOnly: Boolean(process.env.CI), + testDir: 'e2e', + testMatch: '*.spec.ts', + timeout: 60000, + expect: { timeout: 15000 }, + workers: 1, + retries: 0, + use: { + baseURL: 'https://localhost:4881', + ignoreHTTPSErrors: true, + trace: 'off', + video: 'off', + screenshot: 'off', + }, + projects: [ + ...(['chromium', 'firefox', 'webkit'] as const).map((browserName) => ({ + name: `${browserName}-http`, + // Runtime fixtures use their own HTTP origin; run them once per browser below. + testIgnore: 'runtime.spec.ts', + use: { browserName, baseURL: 'http://localhost:4781', ignoreHTTPSErrors: false }, + })), + { + name: 'chromium', + use: { browserName: 'chromium', launchOptions: { args: ['--ignore-certificate-errors'] } }, + }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + { + name: 'android-emulated', + use: { + ...devices['Pixel 7'], + browserName: 'chromium', + launchOptions: { args: ['--ignore-certificate-errors'] }, + }, + }, + { name: 'ios-emulated', use: { ...devices['iPhone 15'], browserName: 'webkit' } }, + ], + webServer: [ + { + // Compose can exit during startup while leaving healthy sibling containers running. + command: `trap '${compose} down' EXIT; trap 'exit 1' INT TERM; ${compose} up --abort-on-container-exit`, + url: 'http://127.0.0.1:4986/index.html', + stdout: 'pipe', + reuseExistingServer: false, + timeout: 300000, + gracefulShutdown: { signal: 'SIGTERM', timeout: 30000 }, + }, + { + command: 'node e2e/build.mjs && node e2e/server.mjs', + url: 'https://localhost:4881', + ignoreHTTPSErrors: true, + reuseExistingServer: false, + timeout: 60000, + }, + ], +}) diff --git a/ts/packages/ceremony/src/assets/cache.test.ts b/ts/packages/ceremony/src/assets/cache.test.ts new file mode 100644 index 00000000..e65264c2 --- /dev/null +++ b/ts/packages/ceremony/src/assets/cache.test.ts @@ -0,0 +1,227 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { AssetCache, validateResponse } from './cache.js' + +const spec = { url: 'https://assets.example/g1', range: 'bytes=0-1', bytes: 2 } + +afterEach(() => vi.unstubAllGlobals()) + +it('joins pending downloads and preserves independent readers and worker CSP in stored bodies', async () => { + const stored = new Map() + vi.stubGlobal('caches', { + open: async () => ({ + match: async (k: string) => stored.get(k)?.clone(), + put: async (k: string, r: Response) => { + stored.set(k, r) + }, + delete: async (k: string) => stored.delete(k), + }), + }) + let resolve!: (response: Response) => void + const fetching = vi.fn( + () => + new Promise((r) => { + resolve = r + }), + ) + vi.stubGlobal('fetch', fetching) + const cache = new AssetCache('https://ccdp.example'), + first = cache.load(spec) + await first.dispatched + expect(fetching).toHaveBeenCalledTimes(1) + const second = cache.load(spec) + await second.dispatched + expect(fetching).toHaveBeenCalledTimes(1) + resolve( + new Response(new Uint8Array([4, 5]), { + status: 206, + headers: { + 'Content-Security-Policy': "default-src 'none'", + 'Content-Range': 'bytes 0-1/100', + }, + }), + ) + const [a, b] = await Promise.all([first.response, second.response]) + expect(new Uint8Array(await a.arrayBuffer())).toEqual(new Uint8Array([4, 5])) + expect(new Uint8Array(await b.arrayBuffer())).toEqual(new Uint8Array([4, 5])) + const hit = await cache.load(spec).response + expect(hit.status).toBe(206) + expect(hit.headers.get('content-security-policy')).toBe("default-src 'none'") + expect(fetching).toHaveBeenCalledTimes(1) +}) + +it.each([200, 404])('rejects status %i for range fetches', (status) => + expect(() => validateResponse(new Response(null, { status }), spec)).toThrow(), +) + +it('allows unexposed range headers but rejects exposed mismatches and wrong lengths', () => { + expect(() => validateResponse(new Response(null, { status: 206 }), spec)).not.toThrow() + for (const headers of [ + new Headers({ 'Content-Range': 'bytes 2-3/100' }), + new Headers({ 'Content-Length': '3' }), + ]) + expect(() => validateResponse(new Response(null, { status: 206, headers }), spec)).toThrow() +}) + +it('storage denial still fetches; failed bodies never become a reusable flight', async () => { + vi.stubGlobal('caches', { + open: async () => { + throw new Error('denied') + }, + }) + const fetcher = vi.fn(async () => new Response(new Uint8Array([1]), { status: 206 })) + vi.stubGlobal('fetch', fetcher) + const cache = new AssetCache('https://ccdp.example') + await expect(cache.load(spec).response).rejects.toThrow('Incomplete') + await expect(cache.load(spec).response).rejects.toThrow('Incomplete') + expect(fetcher).toHaveBeenCalledTimes(2) +}) + +it.each(['application/wasm', 'text/javascript'])( + 'returns ordinary cached %s bodies without consuming them', + async (mime) => { + const hit = new Response(new Uint8Array([4, 5]), { + headers: { + 'Content-Type': mime, + 'Content-Length': '2', + 'Content-Security-Policy': "default-src 'none'", + }, + }) + vi.stubGlobal('caches', { open: async () => ({ match: async () => hit }) }) + const fetching = vi.fn() + vi.stubGlobal('fetch', fetching) + const loaded = new AssetCache('https://ccdp.example').load({ + url: 'https://ccdp.example/asset', + bytes: 2, + mime, + }) + const result = await loaded.response + await loaded.dispatched + expect(hit.bodyUsed).toBe(false) + expect(fetching).not.toHaveBeenCalled() + expect(result.headers.get('content-security-policy')).toBe("default-src 'none'") + expect(new Uint8Array(await result.arrayBuffer())).toEqual(new Uint8Array([4, 5])) + }, +) + +it.each(['miss', 'denied'])( + 'uses the browser HTTP cache after a Cache Storage %s, retaining request options', + async (storage) => { + vi.stubGlobal('caches', { + open: async () => { + if (storage === 'denied') throw new Error('denied') + return { match: async () => undefined, put: async () => {} } + }, + }) + const fetching = vi.fn(async () => new Response(new Uint8Array([4, 5]), { status: 206 })) + vi.stubGlobal('fetch', fetching) + await new AssetCache('https://ccdp.example').load(spec).response + expect(fetching).toHaveBeenCalledWith(spec.url, { + credentials: 'omit', + mode: 'cors', + redirect: 'error', + headers: { Range: spec.range }, + cache: 'force-cache', + }) + }, +) + +it.each([ + { 'Content-Type': 'text/html', 'Content-Length': '2' }, + { 'Content-Type': 'application/wasm', 'Content-Length': '3' }, +])('rejects invalid cached metadata and validates the fetched body', async (headers) => { + vi.stubGlobal('caches', { + open: async () => ({ + match: async () => new Response(new Uint8Array([4, 5]), { headers }), + put: async () => {}, + }), + }) + const fetching = vi.fn( + async () => + new Response(new Uint8Array([4]), { + headers: { 'Content-Type': 'application/wasm' }, + }), + ) + vi.stubGlobal('fetch', fetching) + await expect( + new AssetCache('https://ccdp.example').load({ + url: 'https://ccdp.example/asset.wasm', + bytes: 2, + mime: 'application/wasm', + }).response, + ).rejects.toThrow('Incomplete') + expect(fetching).toHaveBeenCalledTimes(1) +}) + +it.each([false, true])( + 'delivers validated bytes before persistence, keeping joiners until write completion (range=%s)', + async (range) => { + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + const stored = new Map() + const put = vi.fn(async (key: string, response: Response) => { + await held + stored.set(key, response) + }) + vi.stubGlobal('caches', { + open: async () => ({ match: async (key: string) => stored.get(key)?.clone(), put }), + }) + const fetcher = vi.fn( + async () => new Response(new Uint8Array([4, 5]), { status: range ? 206 : 200 }), + ) + vi.stubGlobal('fetch', fetcher) + const cache = new AssetCache('https://ccdp.example') + const spec = { + url: 'https://ccdp.example/asset', + bytes: 2, + ...(range ? { range: 'bytes=0-1' } : {}), + } + const first = cache.load(spec) + let complete = false + void first.complete.then(() => { + complete = true + }) + const a = await first.response + expect(Array.from(new Uint8Array(await a.arrayBuffer()))).toEqual([4, 5]) + expect(a.status).toBe(range ? 206 : 200) + expect(complete).toBe(false) + expect(stored.size).toBe(0) + const joined = cache.load(spec) + expect(joined.complete).toBe(first.complete) + expect(Array.from(new Uint8Array(await (await joined.response).arrayBuffer()))).toEqual([4, 5]) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(put).toHaveBeenCalledTimes(1) + release() + await first.complete + expect(complete).toBe(true) + await cache.load(spec).response + expect(fetcher).toHaveBeenCalledTimes(1) + }, +) + +it('absorbs failed writes, releases the pending entry and retries later', async () => { + let reject!: (error: Error) => void + vi.stubGlobal('caches', { + open: async () => ({ + match: async () => undefined, + put: () => + new Promise((_, r) => { + reject = r + }), + }), + }) + const fetcher = vi.fn(async () => new Response(new Uint8Array([4, 5]))) + vi.stubGlobal('fetch', fetcher) + const cache = new AssetCache('https://ccdp.example'), + spec = { url: 'https://ccdp.example/asset', bytes: 2 } + const first = cache.load(spec) + await first.response + reject(new Error('quota')) + await first.complete + const second = cache.load(spec) + await second.response + expect(fetcher).toHaveBeenCalledTimes(2) + reject(new Error('quota')) + await second.complete +}) diff --git a/ts/packages/ceremony/src/assets/cache.ts b/ts/packages/ceremony/src/assets/cache.ts new file mode 100644 index 00000000..1b35728e --- /dev/null +++ b/ts/packages/ceremony/src/assets/cache.ts @@ -0,0 +1,135 @@ +import { readBody } from '../response.js' +import type { AssetRequest } from './index.js' + +const CACHE = 'libid-ceremony-assets-v1', + PREFIX = '/__libid_ceremony_cache__/' + +/** Validate status and exposed metadata before accepting an asset response. */ +export function validateResponse(response: Response, spec: AssetRequest): void { + if ( + response.redirected || + response.type === 'opaque' || + response.type === 'opaqueredirect' || + response.status !== (spec.range ? 206 : 200) + ) + throw new Error('Invalid asset response') + if (spec.mime && response.headers.get('content-type')?.split(';')[0].trim() !== spec.mime) + throw new Error('Invalid asset media type') + const range = response.headers.get('content-range') + if ( + spec.range && + range && + !new RegExp(`^bytes ${spec.range.slice(6)}/(?:[1-9][0-9]*|\\*)$`).test(range) + ) + throw new Error('Unexpected asset range') + if ( + spec.bytes !== undefined && + range && + range.split('/')[1] !== '*' && + BigInt(range.split('/')[1]) < BigInt(spec.bytes) + ) + throw new Error('Invalid asset total') + const length = response.headers.get('content-length') + if ( + length !== null && + (!/^[0-9]+$/.test(length) || (spec.bytes !== undefined && Number(length) !== spec.bytes)) && + !response.headers.has('content-encoding') + ) + throw new Error('Unexpected asset size') +} + +/** Single-flight delivery of immutable bytes. Storage failure falls back to the same fetch. */ +export class AssetCache { + private readonly pending = new Map< + string, + { dispatched: Promise; response: Promise; complete: Promise } + >() + + constructor(private readonly origin: string) {} + + /** + * Join one URL/range fetch, with an independently consumable response for each caller. + * `dispatched` acknowledges a cache hit or fetch invocation, allowing OAuth navigation. + * `response` validates the body before delivery; `complete` keeps the worker and pending + * entry alive through best-effort cache persistence, without delaying delivery. + */ + load(spec: AssetRequest) { + const key = `${spec.url}\n${spec.range ?? ''}` + const existing = this.pending.get(key) + if (existing) return { ...existing, response: existing.response.then((r) => r.clone()) } + let dispatched!: () => void + const started = new Promise((resolve) => { + dispatched = resolve + }) + let writing = Promise.resolve() + const response = (async () => { + let cache: Cache | undefined + const cacheKey = this.origin + PREFIX + encodeURIComponent(key) + try { + cache = await caches.open(CACHE) + const hit = await cache.match(cacheKey) + if (hit) { + // Complete bodies were checked before cache.put; ordinary hits need no copy. + if (!spec.range) { + validateResponse(hit, spec) + dispatched() + return hit + } + const bytes = await readBody(hit, spec.bytes ?? Number.MAX_SAFE_INTEGER) + if (spec.bytes === undefined || bytes.length === spec.bytes) { + const response = new Response(bytes.slice().buffer, { + status: 206, + headers: hit.headers, + }) + validateResponse(response, spec) + dispatched() + return response + } + await cache.delete(cacheKey) + } + } catch { + /* Storage denial does not disable fetching. */ + } + let fetching: Promise + try { + fetching = fetch(spec.url, { + credentials: 'omit', + mode: 'cors', + redirect: 'error', + headers: spec.range ? { Range: spec.range } : {}, + cache: 'force-cache', + }) + } finally { + dispatched() + } + const received = await fetching + validateResponse(received, spec) + const bytes = await readBody(received, spec.bytes ?? Number.MAX_SAFE_INTEGER) + if (spec.bytes !== undefined && bytes.length !== spec.bytes) + throw new Error('Incomplete asset body') + const headers = new Headers(received.headers) + headers.delete('content-encoding') + headers.set('content-length', String(bytes.length)) + const stored = new Response(bytes.slice().buffer, { headers }) + if (cache) + try { + writing = cache.put(cacheKey, stored.clone()).catch(() => {}) + } catch { + /* A valid response remains usable when storage is full. */ + } + return spec.range + ? new Response(bytes.slice().buffer, { status: 206, headers: stored.headers }) + : stored + })().finally(dispatched) + const complete = response + .then( + () => writing, + () => writing, + ) + .finally(() => { + this.pending.delete(key) + }) + this.pending.set(key, { dispatched: started, response, complete }) + return { dispatched: started, response: response.then((r) => r.clone()), complete } + } +} diff --git a/ts/packages/ceremony/src/assets/index.test.ts b/ts/packages/ceremony/src/assets/index.test.ts new file mode 100644 index 00000000..20573726 --- /dev/null +++ b/ts/packages/ceremony/src/assets/index.test.ts @@ -0,0 +1,27 @@ +import { afterEach, expect, it, vi } from 'vitest' +import * as assets from './index.js' + +vi.mock('virtual:ceremony-assets', () => ({ + urls: { + 'tlsn/v1/snippets/web-spawn-*/js/spawn.js': + '/ccdp/assets/tlsn/v1/snippets/web-spawn-abcd/js/spawn.js', + }, +})) + +afterEach(() => vi.unstubAllGlobals()) + +it('resolves exact build matches at the executing origin without fetching [LIBID-ASSET-025]', () => { + vi.stubGlobal('location', { origin: 'https://ccdp.test', pathname: '/ccdp/v1/prover' }) + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + const member = assets + .archive('https://release.test/tlsn.tar.gz', 'tlsn/v1') + .member('snippets/web-spawn-*/js/spawn.js', assets.headers.executionWorker) + expect(assets.resolve(member)).toBe( + 'https://ccdp.test/ccdp/assets/tlsn/v1/snippets/web-spawn-abcd/js/spawn.js', + ) + const external = assets.external('https://cdn.test/g1.dat', { range: 'bytes=0-31' }) + expect(assets.resolve(external)).toBe(external.source) + expect(external.range).toBe('bytes=0-31') + expect(fetch).not.toHaveBeenCalled() +}) diff --git a/ts/packages/ceremony/src/assets/index.ts b/ts/packages/ceremony/src/assets/index.ts new file mode 100644 index 00000000..dd51df9c --- /dev/null +++ b/ts/packages/ceremony/src/assets/index.ts @@ -0,0 +1,63 @@ +import { urls } from 'virtual:ceremony-assets' + +export * as headers from '../ccdp/headers.js' + +/** Exact fetch selected by the emitted graph; ranges distinguish requests to the same URL. */ +export interface AssetRequest { + url: string + range?: string + bytes?: number + mime?: string +} + +export type LocalAsset = { + source: string + mount: string + member?: string + headers: Readonly> + bundledUrlModules?: readonly string[] + isExternal?: false +} + +export type ExternalAsset = { + source: string + isExternal: true + range?: string + bytes?: number + fallback?: readonly string[] +} + +export type Asset = LocalAsset | ExternalAsset + +/** Declare one archive mount. Members select paths or wildcard matches resolved at build time. */ +export function archive(source: string, mount: string) { + return { + member: (member: string, headers: LocalAsset['headers']): LocalAsset => ({ + source, + mount, + member, + headers, + }), + } +} + +/** Installed package files and standalone downloads use the same publication rules. */ +export function file(source: string, mount: string, headers: LocalAsset['headers']): LocalAsset { + return { source, mount, headers } +} + +/** Retain a native external loader URL and its request shape; the build does not rehost it. */ +export function external( + source: string, + options: Omit = {}, +): ExternalAsset { + return { ...options, source, isExternal: true } +} + +/** Resolve synchronously at the CCDP origin, or retain an external URL. Never fetches. */ +export function resolve(asset: Asset): string { + if (asset.isExternal) return asset.source + const path = urls[`${asset.mount}/${asset.member ?? ''}`] + if (!path) throw new Error('Missing built asset') + return new URL(path, location.origin).href +} diff --git a/ts/packages/ceremony/src/assets/registration.ts b/ts/packages/ceremony/src/assets/registration.ts new file mode 100644 index 00000000..5d3e51e6 --- /dev/null +++ b/ts/packages/ceremony/src/assets/registration.ts @@ -0,0 +1,95 @@ +import { route } from '../ccdp/navigation.js' + +/** Activate the canonical root registration and retire only the known legacy nested scope. */ +export async function rootWorker(): Promise { + const registration = await navigator.serviceWorker.register(route('worker.js'), { + scope: '/', + type: 'module', + updateViaCache: 'none', + }) + if (registration.scope !== `${location.origin}/`) + throw new Error('Incorrect Service Worker scope') + const newest = registration.installing ?? registration.waiting ?? registration.active + if (newest?.state !== 'activated') { + const worker = newest + if (!worker) throw new Error('Missing Service Worker') + await new Promise((resolve, reject) => { + const timer = setTimeout(() => done(new Error('Service Worker activation timed out')), 15000) + const done = (error?: Error) => { + clearTimeout(timer) + worker.removeEventListener('statechange', changed) + error ? reject(error) : resolve() + } + const changed = () => { + if (worker.state === 'activated') done() + else if (worker.state === 'redundant') done(new Error('Service Worker failed')) + } + worker.addEventListener('statechange', changed) + changed() + }) + } + // Retire only the known legacy scope. A root worker cannot claim pages that + // still match a longer registration; popup port selection alone cannot fix it. + const script = new URL(route('worker.js'), location.origin).href + for (const old of await navigator.serviceWorker.getRegistrations()) { + const workers = [old.active, old.waiting, old.installing].filter( + (worker): worker is ServiceWorker => worker !== null, + ) + if ( + old.scope === `${location.origin}/ccdp/v1/` && + workers.length && + workers.every((worker) => worker.scriptURL === script) + ) + await old.unregister() + } + return registration +} + +/** Wait for fetch-dispatch acknowledgement for this profile, not download completion. */ +export async function dispatchPrefetch( + registration: ServiceWorkerRegistration, + profile: string, +): Promise { + await new Promise((resolve, reject) => { + const channel = new MessageChannel(), + timer = setTimeout(() => done(new Error('Asset dispatch timed out')), 15000) + const done = (error?: Error) => { + clearTimeout(timer) + channel.port1.close() + error ? reject(error) : resolve() + } + channel.port1.onmessage = (event) => + event.data?.dispatched === true ? done() : done(new Error('Invalid dispatch acknowledgement')) + try { + registration.active!.postMessage({ type: 'ceremony-prefetch', profile }, [channel.port2]) + } catch { + channel.port2.close() + done(new Error('Asset dispatch failed')) + } + }) +} + +/** Newly isolated documents can initially be uncontrolled; claim before execution fetches. */ +export async function claimRootWorker(): Promise { + const registration = await navigator.serviceWorker.getRegistration('/') + if (registration?.scope !== `${location.origin}/` || !registration.active) + throw new Error('Missing root Service Worker') + if (navigator.serviceWorker.controller === registration.active) return + await new Promise((resolve, reject) => { + const channel = new MessageChannel(), + timer = setTimeout(() => done(new Error('Service Worker control timed out')), 15000) + const done = (error?: Error) => { + clearTimeout(timer) + channel.port1.close() + navigator.serviceWorker.removeEventListener('controllerchange', changed) + error ? reject(error) : resolve() + } + const changed = () => { + if (navigator.serviceWorker.controller === registration.active) done() + } + navigator.serviceWorker.addEventListener('controllerchange', changed) + channel.port1.onmessage = changed + registration.active!.postMessage({ type: 'ceremony-claim' }, [channel.port2]) + changed() + }) +} diff --git a/ts/packages/ceremony/src/assets/worker.test.ts b/ts/packages/ceremony/src/assets/worker.test.ts new file mode 100644 index 00000000..8b095891 --- /dev/null +++ b/ts/packages/ceremony/src/assets/worker.test.ts @@ -0,0 +1,61 @@ +import { expect, it, vi } from 'vitest' + +const { load } = vi.hoisted(() => ({ load: vi.fn() })) + +vi.mock('./cache.js', () => ({ + AssetCache: class { + load = load + }, +})) + +vi.mock('@libid/popup/worker', () => ({ installPortKeeper: vi.fn() })) + +vi.mock('virtual:ceremony-assets', () => ({ + requestsByProfile: { 'google/1': [{ url: '/asset' }] }, + allowedRequests: [{ url: '/asset' }], +})) + +import { startWorker } from './worker.js' + +it.each(['fetch', 'message', 'failed-prefetch'])( + 'keeps %s lifetime tied to persistence after response delivery', + async (type) => { + let finish!: () => void + const complete = new Promise((resolve) => { + finish = resolve + }) + const response = + type === 'failed-prefetch' + ? Promise.reject(new Error('asset unavailable')) + : Promise.resolve(new Response(new Uint8Array([1]))) + load.mockReturnValue({ dispatched: Promise.resolve(), response, complete }) + const handlers = new Map void>() + startWorker({ + location: { origin: 'https://ccdp.example' }, + addEventListener: (type: string, handler: (event: unknown) => void) => + handlers.set(type, handler), + } as unknown as ServiceWorkerGlobalScope) + const waits: Promise[] = [], + respondWith = vi.fn() + handlers.get(type === 'fetch' ? 'fetch' : 'message')!({ + request: new Request('https://ccdp.example/asset'), + respondWith, + waitUntil: (p: Promise) => waits.push(p), + data: { type: 'ceremony-prefetch', profile: 'google/1' }, + ports: [{ postMessage: vi.fn(), close: vi.fn() }], + source: { url: 'https://ccdp.example/ccdp/v1/prefetch' }, + }) + expect(waits).toHaveLength(1) + let settled = false + void waits[0].then(() => { + settled = true + }) + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + if (type === 'fetch') expect(respondWith).toHaveBeenCalledWith(response) + finish() + await waits[0] + expect(settled).toBe(true) + }, +) diff --git a/ts/packages/ceremony/src/assets/worker.ts b/ts/packages/ceremony/src/assets/worker.ts new file mode 100644 index 00000000..da2090c7 --- /dev/null +++ b/ts/packages/ceremony/src/assets/worker.ts @@ -0,0 +1,73 @@ +import { allowedRequests, requestsByProfile } from 'virtual:ceremony-assets' +import { installPortKeeper } from '@libid/popup/worker' +import { hasExactKeys, isRecord } from '../primitives.js' +import { AssetCache } from './cache.js' + +/** Install the emitted fetch allowlist, cache delivery and popup-owned port continuity. */ +export function startWorker(scope: ServiceWorkerGlobalScope): void { + installPortKeeper() + const cache = new AssetCache(scope.location.origin) + const resolve = (r: (typeof allowedRequests)[number]) => ({ + ...r, + url: new URL(r.url, scope.location.origin).href, + }) + const allowed = new Map( + allowedRequests.map((r) => { + const spec = resolve(r) + return [`${spec.url}\n${spec.range ?? ''}`, spec] + }), + ) + scope.addEventListener('install', (event) => event.waitUntil(scope.skipWaiting())) + scope.addEventListener('activate', (event) => event.waitUntil(scope.clients.claim())) + scope.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return + const spec = allowed.get(`${event.request.url}\n${event.request.headers.get('range') ?? ''}`) + if (!spec) return + const { response, complete } = cache.load(spec) + event.respondWith(response) + event.waitUntil(complete) + }) + scope.addEventListener('message', (event) => { + const value: unknown = event.data + if (!isRecord(value)) return + if ( + value.type === 'ceremony-claim' && + hasExactKeys(value, ['type']) && + event.ports.length === 1 && + event.source && + 'url' in event.source && + new URL(event.source.url).origin === scope.location.origin + ) { + const port = event.ports[0] + event.waitUntil( + scope.clients.claim().then(() => { + port.postMessage({ claimed: true }) + port.close() + }), + ) + return + } + if (value.type !== 'ceremony-prefetch') return + const reply = event.ports[0] + if ( + !reply || + event.ports.length !== 1 || + !hasExactKeys(value, ['type', 'profile']) || + typeof value.profile !== 'string' || + !Object.hasOwn(requestsByProfile, value.profile) || + !event.source || + !('url' in event.source) || + new URL(event.source.url).origin !== scope.location.origin + ) { + for (const port of event.ports) port.close() + return + } + const jobs = requestsByProfile[value.profile].map((r) => cache.load(resolve(r))) + for (const job of jobs) void job.response.catch(() => {}) + const dispatch = Promise.all(jobs.map((j) => j.dispatched)).then(() => { + reply.postMessage({ dispatched: true }) + reply.close() + }) + event.waitUntil(Promise.all([dispatch, ...jobs.map((j) => j.complete)]).then(() => {})) + }) +} diff --git a/ts/packages/ceremony/src/barretenberg/barretenberg.assets.ts b/ts/packages/ceremony/src/barretenberg/barretenberg.assets.ts new file mode 100644 index 00000000..0456e9e2 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/barretenberg.assets.ts @@ -0,0 +1,48 @@ +import * as assets from '../assets/index.js' + +// One shared CRS serves every current circuit. The browser loader uses 4 MiB chunks +// (at least 2^17 points); oidc_google needs 2^18. Capacity is checked in build/circuits.ts. +export const SRS_SIZE = 2 ** 18 + +export const acvm = assets.file( + 'npm:@noir-lang/acvm_js/web/acvm_js_bg.wasm', + 'noir/1.0.0-beta.25/acvm_js_bg.wasm', + { ...assets.headers.immutable, ...assets.headers.wasm }, +) + +export const abi = assets.file( + 'npm:@noir-lang/noirc_abi/web/noirc_abi_wasm_bg.wasm', + 'noir/1.0.0-beta.25/noirc_abi_wasm_bg.wasm', + { ...assets.headers.immutable, ...assets.headers.wasm }, +) + +export const bbWasm = { + ...assets.file( + 'npm:@aztec/bb.js/dest/node/barretenberg_wasm/barretenberg-threads.wasm.gz', + 'bb/5.2.0/wasm/barretenberg-threads.wasm', + { ...assets.headers.immutable, ...assets.headers.wasm }, + ), + bundledUrlModules: [ + '@aztec/bb.js/dest/browser/barretenberg_wasm/fetch_code/browser/barretenberg-threads.js', + '@aztec/bb.js/dest/browser/barretenberg_wasm/fetch_code/browser/barretenberg.js', + ], +} + +// bb.js 5.2.0 browser fetches ignore crsPath as an override. Keep these external +// and matched to its native URLs/ranges; build/loaders.test.ts observes real loaders. +const primary = 'https://crs.aztec-cdn.foundation', + fallback = 'https://crs.aztec-labs.com' + +export const crs = [ + assets.external(`${primary}/g1_compressed.dat`, { + fallback: [`${fallback}/g1_compressed.dat`], + range: `bytes=0-${SRS_SIZE * 32 - 1}`, + }), + assets.external(`${primary}/g2.dat`, { fallback: [`${fallback}/g2.dat`], bytes: 128 }), + assets.external(`${primary}/grumpkin_g1_v2.dat`, { + fallback: [`${fallback}/grumpkin_g1_v2.dat`], + range: `bytes=0-${2 ** 16 * 64 - 1}`, + }), +] as const + +export const proofAssets = [acvm, abi, bbWasm, ...crs] as const diff --git a/ts/packages/ceremony/src/barretenberg/circuits/bearer_link/bearer_link.assets.ts b/ts/packages/ceremony/src/barretenberg/circuits/bearer_link/bearer_link.assets.ts new file mode 100644 index 00000000..6963bdd3 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/circuits/bearer_link/bearer_link.assets.ts @@ -0,0 +1,13 @@ +import * as assets from '../../../assets/index.js' + +export const bearerRelease = assets.archive( + 'https://github.com/libid-org/libid-circuits/releases/download/v0.3.0/libid-circuits-0.3.0-bearer-link.tar.gz', + 'circuits/v0.3.0/bearer-link', +) + +export const bearerVerificationKey = bearerRelease.member('vk', assets.headers.immutable) + +export const bearerCircuit = bearerRelease.member('bearer_link.json', { + ...assets.headers.immutable, + ...assets.headers.json, +}) diff --git a/ts/packages/ceremony/src/barretenberg/circuits/bearer_link/inputs.test.ts b/ts/packages/ceremony/src/barretenberg/circuits/bearer_link/inputs.test.ts new file mode 100644 index 00000000..d5270863 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/circuits/bearer_link/inputs.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from 'vitest' +import { buildBearerLinkWitness, validateBearerLinkPublicInputs } from './inputs.js' + +it('matches token then identity commitments exactly [LIBID-PROVER-001]', () => { + const opening = (byte: number) => ({ + start: 0, + end: 3, + blinder: new Uint8Array(16).fill(byte), + hash: new Uint8Array(32).fill(byte), + }) + const inputs = buildBearerLinkWitness('abc', opening(1), opening(2)) + const token = new Array(32).fill(`0x${'0'.repeat(63)}1`) + const identity = new Array(32).fill(`0x${'0'.repeat(63)}2`) + const expected = [...token, ...identity] + expect(validateBearerLinkPublicInputs(expected, inputs)).toBe(true) + for (const value of [ + expected.slice(1), + [...expected, expected[0]], + [...identity, ...token], + [...expected.slice(0, -1), token[0]], + ]) + expect(validateBearerLinkPublicInputs(value, inputs)).toBe(false) +}) diff --git a/ts/packages/ceremony/src/barretenberg/circuits/bearer_link/inputs.ts b/ts/packages/ceremony/src/barretenberg/circuits/bearer_link/inputs.ts new file mode 100644 index 00000000..3fb80bc5 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/circuits/bearer_link/inputs.ts @@ -0,0 +1,57 @@ +import type { CorrelatedCommitment } from '../../../notary/notarize.js' + +const encoder = new TextEncoder() + +const MAX_BEARER_BYTES = 128 + +function validateOpening(opening: CorrelatedCommitment, name: string, bearerLength: number): void { + if (!(opening.blinder instanceof Uint8Array) || opening.blinder.length !== 16) { + throw new Error(`${name} blinder must be exactly 16 bytes`) + } + if (!(opening.hash instanceof Uint8Array) || opening.hash.length !== 32) { + throw new Error(`${name} commitment must be exactly 32 bytes`) + } + if (opening.end - opening.start !== bearerLength) { + throw new Error(`${name} opening length must match the bearer`) + } +} + +/** Construct the exact libid-circuits v0.3.0 `bearer_link` witness. */ +export function buildBearerLinkWitness( + bearer: string, + token: CorrelatedCommitment, + identity: CorrelatedCommitment, +): Record { + const bytes = encoder.encode(bearer) + if (bytes.length === 0 || bytes.length > MAX_BEARER_BYTES) { + throw new Error('bearer must contain between 1 and 128 bytes') + } + if (bytes.some((byte) => byte < 0x20 || byte > 0x7e)) { + throw new Error('bearer must be printable ASCII') + } + validateOpening(token, 'token', bytes.length) + validateOpening(identity, 'identity', bytes.length) + + const padded = new Uint8Array(MAX_BEARER_BYTES) + padded.set(bytes) + return { + bearer: Array.from(padded), + bearer_len: String(bytes.length), + blinder_token: Array.from(token.blinder), + blinder_identity: Array.from(identity.blinder), + token_commitment: Array.from(token.hash), + identity_commitment: Array.from(identity.hash), + } +} + +/** Match the two commitments in the circuit's exact public-input order. */ +export function validateBearerLinkPublicInputs( + value: readonly string[], + inputs: Record, +): boolean { + const expected = [ + ...(inputs.token_commitment as number[]), + ...(inputs.identity_commitment as number[]), + ].map((n) => `0x${BigInt(n).toString(16).padStart(64, '0')}`) + return value.length === 64 && value.every((v, i) => v === expected[i]) +} diff --git a/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/google-v1.fixture.json b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/google-v1.fixture.json new file mode 100644 index 00000000..bd863d85 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/google-v1.fixture.json @@ -0,0 +1,12 @@ +{ + "idToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6Imdvb2dsZS12MS10ZXN0LWtleSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMjM0NTY3ODkwMTIzNDU2Nzg5MDEiLCJlbWFpbCI6ImhvbGRlckBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJub25jZSI6InN4ajdWWjRXb1htNFUtMG9VMWRzMmhZRExaT3dnNXU0R2xVVFhUTk1DdlUiLCJpYXQiOjE3MjUwMDAwMDAsImV4cCI6MTcyNTAwMzYwMH0.nNYvvwGicw2EvsCnC37ZRB0vDvg2uOOX3XbGP4-5Bb0KprL9pFMjhHjmhDSw8RPzWJ6SQaEn9dgW2Hnl9fl-yFxG-WMnzPQuGT3autYMAwuACXCuiUqikvGvnF4k6sEX3DRVfYkBKjXfbTaV4zR7ljZHNrZpnCTBGBk-uAgTvvyco4SqwjyIvYl9iyClKHHLn_DeTGTPHoaRPOKPGn-scbNqLbKw0VL5RRN9TpzwgPot65L8dFsNrKouLZAnTE7GeqoKnVPT33afHWv693OviEhAAI08n0zySjdu54yWGceVCZSajjY_KmzWSGUkkVpRXxLeCNceUhCaoTssMwtwVw", + "jwk": { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "google-v1-test-key", + "n": "vULoypVGyED9RpwXicoCmyHqAJ_rFXpqsZl10QNf3xIhLBqgsh9yu1OC5x32ne9UZweD08L860UvitIGStqbfaoxtkokxjbUvA_5JPAnGyIyT9eapBsTHlExrLKRIJ3JuMDfr3V7tHqNIojtNVEFkX9cN5ovMQv6mG24JRNyR8vEdrGqL18LRIzXA1xrqQ8-G0RqiEGA9aMfQ6nTBDc0AKB9zpTMFc9RRIT2J0Jv-tzz2QWyeGA5qb8EUEvBynZznt97vxEag7iXA3DFHnQesZGNAiAeqchd3OSAuM2h9Gc1YhHuxW8MYQy00r0xIE4RIc1xXA7-hoP9VjFkh0U2vQ", + "e": "AQAB" + }, + "authorizationDigest": "b318fb559e16a179b853ed2853576cda16032d93b0839bb81a55135d334c0af5" +} diff --git a/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/inputs.test.ts b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/inputs.test.ts new file mode 100644 index 00000000..43a78dbf --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/inputs.test.ts @@ -0,0 +1,259 @@ +import { createPublicKey, verify } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { buildGoogleWitness, type GoogleCircuitInputs } from './inputs.js' +import { buildGooglePublicInputs, validateGooglePublicInputs } from './publicInputs.js' + +interface Fixture { + idToken: string + jwk: Record + authorizationDigest: string +} + +const fixture = JSON.parse( + readFileSync(new URL('./google-v1.fixture.json', import.meta.url), 'utf8'), +) as Fixture + +const digest = new Uint8Array(Buffer.from(fixture.authorizationDigest, 'hex')) + +// Generated once by running this fixture through the official libid-circuits +// v0.3.0 oidc_google ACIR and bb.js 5.2.0, not by this adapter. +const BB_PUBLIC_INPUTS = [ + '0x00000000000000000000000000000000000000000000000000000000000000b3', + '0x0000000000000000000000000000000000000000000000000000000000000018', + '0x00000000000000000000000000000000000000000000000000000000000000fb', + '0x0000000000000000000000000000000000000000000000000000000000000055', + '0x000000000000000000000000000000000000000000000000000000000000009e', + '0x0000000000000000000000000000000000000000000000000000000000000016', + '0x00000000000000000000000000000000000000000000000000000000000000a1', + '0x0000000000000000000000000000000000000000000000000000000000000079', + '0x00000000000000000000000000000000000000000000000000000000000000b8', + '0x0000000000000000000000000000000000000000000000000000000000000053', + '0x00000000000000000000000000000000000000000000000000000000000000ed', + '0x0000000000000000000000000000000000000000000000000000000000000028', + '0x0000000000000000000000000000000000000000000000000000000000000053', + '0x0000000000000000000000000000000000000000000000000000000000000057', + '0x000000000000000000000000000000000000000000000000000000000000006c', + '0x00000000000000000000000000000000000000000000000000000000000000da', + '0x0000000000000000000000000000000000000000000000000000000000000016', + '0x0000000000000000000000000000000000000000000000000000000000000003', + '0x000000000000000000000000000000000000000000000000000000000000002d', + '0x0000000000000000000000000000000000000000000000000000000000000093', + '0x00000000000000000000000000000000000000000000000000000000000000b0', + '0x0000000000000000000000000000000000000000000000000000000000000083', + '0x000000000000000000000000000000000000000000000000000000000000009b', + '0x00000000000000000000000000000000000000000000000000000000000000b8', + '0x000000000000000000000000000000000000000000000000000000000000001a', + '0x0000000000000000000000000000000000000000000000000000000000000055', + '0x0000000000000000000000000000000000000000000000000000000000000013', + '0x000000000000000000000000000000000000000000000000000000000000005d', + '0x0000000000000000000000000000000000000000000000000000000000000033', + '0x000000000000000000000000000000000000000000000000000000000000004c', + '0x000000000000000000000000000000000000000000000000000000000000000a', + '0x00000000000000000000000000000000000000000000000000000000000000f5', + '0x000000000000000000000000000000002f36be056956af2b8464eba0d8b9c613', + '0x00000000000000000000000000000000f8c22af17a2f81cb1421e176333e62ab', + '0x0031323334353637383930313233343536373839303100000000000000000000', + '0x00686f6c646572406578616d706c652e636f6d00000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000066d17750', + '0x0000000000000000000000000000000000cd715c0efe8683fd563164874536bd', + '0x000000000000000000000000000000000011eec56f0c610cb4d2bd31204e1121', + '0x0000000000000000000000000000000000201ea9c85ddce480b8cda1f4673562', + '0x0000000000000000000000000000000000111a83b8970370c51e741eb1918d02', + '0x00000000000000000000000000000000006039a9bf04504bc1ca76739edf7bbf', + '0x0000000000000000000000000000000000cf514484f627426ffadcf3d905b278', + '0x0000000000000000000000000000000000a31f43a9d304373400a07dce94cc15', + '0x00000000000000000000000000000000008cd7035c6ba90f3e1b446a884180f5', + '0x00000000000000000000000000000000006db825137247cbc476b1aa2f5f0b44', + '0x000000000000000000000000000000000088ed355105917f5c379a2f310bfa98', + '0x0000000000000000000000000000000000b291209dc9b8c0dfaf757bb47a8d22', + '0x0000000000000000000000000000000000f0271b22324fd79aa41b131e5131ac', + '0x0000000000000000000000000000000000da9b7daa31b64a24c636d4bc0ff924', + '0x0000000000000000000000000000000000ef54670783d3c2fceb452f8ad2064a', + '0x000000000000000000000000000000000012212c1aa0b21f72bb5382e71df69d', + '0x000000000000000000000000000000000021ea009feb157a6ab19975d1035fdf', + '0x000000000000000000000000000000000042e8ca9546c840fd469c1789ca029b', + '0x00000000000000000000000000000000000000000000000000000000000000bd', +] + +const ABI_KEYS: Array = [ + 'signing_input', + 'signing_input_len', + 'header_b64_len', + 'payload_json', + 'payload_json_len', + 'email_offset', + 'nonce_offset', + 'sub_offset', + 'email_verified_offset', + 'exp_offset', + 'exp_len', + 'iss_offset', + 'aud_offset', + 'email_bytes', + 'email_len', + 'sub_bytes', + 'sub_len', + 'audience_bytes', + 'audience_len', + 'signature', + 'redc', + 'authorization_digest', + 'audience_hash', + 'sub_packed', + 'email_packed', + 'exp', + 'modulus', +] + +function recompose(limbs: string[]): bigint { + return limbs.reduce((value, limb, index) => value | (BigInt(limb) << (120n * BigInt(index))), 0n) +} + +function tokenWith(change: { + header?: Record + payload?: Record + signature?: Uint8Array +}): string { + const [header, payload, signature] = fixture.idToken.split('.') + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString('base64url') + return [ + change.header ? encode(change.header) : header, + change.payload ? encode(change.payload) : payload, + change.signature ? Buffer.from(change.signature).toString('base64url') : signature, + ].join('.') +} + +function tokenWithPayload(payload: string): string { + const [header, , signature] = fixture.idToken.split('.') + return `${header}.${Buffer.from(payload).toString('base64url')}.${signature}` +} + +describe('[LIBID-PROVER-002] Google v1 witness and verifier fields', () => { + it('builds the released ABI exactly from a valid fixed RS256 token', () => { + const [header, payload, signature] = fixture.idToken.split('.') + expect( + verify( + 'RSA-SHA256', + Buffer.from(`${header}.${payload}`), + createPublicKey({ key: fixture.jwk, format: 'jwk' }), + Buffer.from(signature, 'base64url'), + ), + ).toBe(true) + + const { inputs, identity, proofFields } = buildGoogleWitness(fixture.idToken, fixture.jwk) + expect(Object.keys(inputs)).toEqual(ABI_KEYS) + expect(inputs.signing_input_len).toBe('412') + expect(inputs.header_b64_len).toBe('72') + expect(inputs.payload_json_len).toBe('254') + expect(inputs.signing_input.slice(0, 412)).toEqual( + Array.from(Buffer.from(`${header}.${payload}`)), + ) + expect(inputs.signing_input.slice(412).every((byte) => byte === 0)).toBe(true) + expect(inputs.payload_json.slice(0, 254)).toEqual(Array.from(Buffer.from(payload, 'base64url'))) + expect(inputs.payload_json.slice(254).every((byte) => byte === 0)).toBe(true) + expect({ + email: inputs.email_offset, + nonce: inputs.nonce_offset, + sub: inputs.sub_offset, + emailVerified: inputs.email_verified_offset, + exp: inputs.exp_offset, + iss: inputs.iss_offset, + aud: inputs.aud_offset, + }).toEqual({ + email: '115', + nonce: '166', + sub: '85', + emailVerified: '144', + exp: '237', + iss: '1', + aud: '37', + }) + expect(inputs.authorization_digest).toEqual(Array.from(digest)) + expect(recompose(inputs.signature)).toBe( + BigInt(`0x${Buffer.from(signature, 'base64url').toString('hex')}`), + ) + const modulus = BigInt(`0x${Buffer.from(fixture.jwk.n, 'base64url').toString('hex')}`) + expect(recompose(inputs.modulus)).toBe(modulus) + expect(recompose(inputs.redc)).toBe((1n << 4102n) / modulus) + + const proof = { identityProof: new Uint8Array([1]), ...proofFields } + expect(buildGooglePublicInputs(digest, identity, proof)).toEqual(BB_PUBLIC_INPUTS) + }) + + it('rejects malformed token and JWK values before witness construction', () => { + const payload = JSON.parse( + Buffer.from(fixture.idToken.split('.')[1], 'base64url').toString(), + ) as Record + const header = JSON.parse( + Buffer.from(fixture.idToken.split('.')[0], 'base64url').toString(), + ) as Record + const payloadJson = Buffer.from(fixture.idToken.split('.')[1], 'base64url').toString() + const cases: Array<[string, string, unknown]> = [ + ['wrong algorithm', tokenWith({ header: { ...header, alg: 'ES256' } }), fixture.jwk], + ['missing kid', tokenWith({ header: { alg: 'RS256' } }), fixture.jwk], + ['wrong nonce width', tokenWith({ payload: { ...payload, nonce: 'AA' } }), fixture.jwk], + [ + 'wrong claim type', + tokenWith({ payload: { ...payload, exp: String(payload.exp) } }), + fixture.jwk, + ], + [ + 'unverified email', + tokenWith({ payload: { ...payload, email_verified: false } }), + fixture.jwk, + ], + ['short signature', tokenWith({ signature: new Uint8Array(255) }), fixture.jwk], + [ + 'missing canonical claim spelling', + tokenWithPayload(payloadJson.replace('"email":"', '"email": "')), + fixture.jwk, + ], + [ + 'missing structural terminator', + tokenWithPayload(payloadJson.replace('","email_verified"', '" ,"email_verified"')), + fixture.jwk, + ], + ['wrong key id', fixture.idToken, { ...fixture.jwk, kid: 'other' }], + ['wrong exponent', fixture.idToken, { ...fixture.jwk, e: 'Aw' }], + [ + 'short modulus', + fixture.idToken, + { ...fixture.jwk, n: Buffer.alloc(255, 0xff).toString('base64url') }, + ], + ] + for (const [name, token, jwk] of cases) { + expect(() => buildGoogleWitness(token, jwk), name).toThrow() + } + }) + + it('does not require optional JWK metadata', () => { + const { kid, kty, e, n } = fixture.jwk + const jwk = { kid, kty, e, n } + expect(buildGoogleWitness(fixture.idToken, jwk)).toEqual( + buildGoogleWitness(fixture.idToken, fixture.jwk), + ) + }) + + it('rejects wrong-length, wrong-order, wrong-type, and one-byte-changed public inputs', () => { + const { identity, proofFields } = buildGoogleWitness(fixture.idToken, fixture.jwk) + const proof = { identityProof: new Uint8Array([1]), ...proofFields } + expect(validateGooglePublicInputs(BB_PUBLIC_INPUTS, digest, identity, proof)).toBe(true) + expect(validateGooglePublicInputs(BB_PUBLIC_INPUTS.slice(1), digest, identity, proof)).toBe( + false, + ) + const reordered = [...BB_PUBLIC_INPUTS] + const first = reordered[0] + reordered[0] = reordered[1] + reordered[1] = first + expect(validateGooglePublicInputs(reordered, digest, identity, proof)).toBe(false) + const mistyped: unknown[] = [...BB_PUBLIC_INPUTS] + mistyped[0] = 0xb3 + expect(validateGooglePublicInputs(mistyped, digest, identity, proof)).toBe(false) + const changed = [...BB_PUBLIC_INPUTS] + changed[55] = `${changed[55].slice(0, -2)}bc` + expect(validateGooglePublicInputs(changed, digest, identity, proof)).toBe(false) + }) +}) diff --git a/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/inputs.ts b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/inputs.ts new file mode 100644 index 00000000..d8515335 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/inputs.ts @@ -0,0 +1,188 @@ +import { sha256 } from '@noble/hashes/sha2.js' +import { parseGoogleIdToken } from '../../../platforms/google/1/token.js' +import { + type GoogleProofV1, + MAX_AUD_BYTES, + MAX_EMAIL_BYTES, + MAX_SUB_BYTES, + RSA_MODULUS_BYTES, +} from '../../../platforms/google/1/types.js' +import type { Identity } from '../../../platforms/types.js' +import { b64urlDecode, isRecord } from '../../../primitives.js' + +const SIGNING_INPUT_MAX = 1280 + +const PAYLOAD_JSON_MAX = 768 + +const NUM_LIMBS = 18 + +const LIMB_BITS = 120n + +const BARRETT_OVERFLOW_BITS = 6n + +const encoder = new TextEncoder() + +export interface GoogleCircuitInputs extends Record { + signing_input: number[] + signing_input_len: string + header_b64_len: string + payload_json: number[] + payload_json_len: string + email_offset: string + nonce_offset: string + sub_offset: string + email_verified_offset: string + exp_offset: string + exp_len: string + iss_offset: string + aud_offset: string + email_bytes: number[] + email_len: string + sub_bytes: number[] + sub_len: string + audience_bytes: number[] + audience_len: string + signature: string[] + redc: string[] + authorization_digest: number[] + audience_hash: string[] + sub_packed: string[] + email_packed: string[] + exp: string + modulus: string[] +} + +export interface BuiltGoogleWitness { + inputs: GoogleCircuitInputs + identity: Identity<'google'> + proofFields: Omit +} + +function bytesToBigInt(bytes: Uint8Array): bigint { + let value = 0n + for (const byte of bytes) value = (value << 8n) | BigInt(byte) + return value +} + +function limbs(value: bigint): string[] { + const mask = (1n << LIMB_BITS) - 1n + return Array.from( + { length: NUM_LIMBS }, + (_, index) => `0x${((value >> (LIMB_BITS * BigInt(index))) & mask).toString(16)}`, + ) +} + +function pad(bytes: Uint8Array, length: number): number[] { + if (bytes.length > length) throw new Error(`value exceeds circuit limit ${length}`) + const result = new Uint8Array(length) + result.set(bytes) + return Array.from(result) +} + +function pack31(bytes: Uint8Array): string { + return `0x${bytesToBigInt(bytes).toString(16)}` +} + +function findOffset(payload: Uint8Array, pattern: string): number { + const needle = encoder.encode(pattern) + outer: for (let offset = 0; offset + needle.length <= payload.length; offset++) { + for (let index = 0; index < needle.length; index++) { + if (payload[offset + index] !== needle[index]) continue outer + } + if (offset < 1) break + const trailing = payload[offset + needle.length] + if (trailing !== 0x2c && trailing !== 0x7d) { + throw new Error('signed claim lacks a structural terminator') + } + return offset + } + throw new Error(`missing canonical signed claim ${pattern.slice(0, pattern.indexOf(':'))}`) +} + +/** Build the exact libid-circuits v0.3.0 `oidc_google` witness. */ +export function buildGoogleWitness(idToken: string, jwk: unknown): BuiltGoogleWitness { + const token = parseGoogleIdToken(idToken) + if ( + !isRecord(jwk) || + jwk.kty !== 'RSA' || + jwk.e !== 'AQAB' || + jwk.kid !== token.kid || + typeof jwk.n !== 'string' + ) { + throw new Error('JWK does not match the Google ID token') + } + + const modulus = b64urlDecode(jwk.n) + if (!modulus || modulus.length !== RSA_MODULUS_BYTES || (modulus[0] & 0x80) === 0) { + throw new Error('Google signing key must be RSA-2048') + } + if (token.signature.length !== RSA_MODULUS_BYTES) { + throw new Error('Google ID token signature must be 256 bytes') + } + const authorizationDigest = b64urlDecode(token.claims.nonce) + if (authorizationDigest?.length !== 32) { + throw new Error('Google nonce must encode a 32-byte authorization digest') + } + + const signingInput = encoder.encode(`${token.headerB64}.${token.payloadB64}`) + if (signingInput.length > SIGNING_INPUT_MAX) throw new Error('Google signing input is too long') + if (token.payload.length > PAYLOAD_JSON_MAX) throw new Error('Google token payload is too long') + + const { aud, sub, email, exp, nonce } = token.claims + const emailBytes = encoder.encode(email) + const subBytes = encoder.encode(sub) + const audienceBytes = encoder.encode(aud) + const paddedEmail = new Uint8Array(pad(emailBytes, MAX_EMAIL_BYTES)) + const paddedSub = new Uint8Array(pad(subBytes, MAX_SUB_BYTES)) + const paddedAudience = new Uint8Array(pad(audienceBytes, MAX_AUD_BYTES)) + const expString = String(exp) + + const emailOffset = findOffset(token.payload, `"email":"${email}"`) + const nonceOffset = findOffset(token.payload, `"nonce":"${nonce}"`) + const subOffset = findOffset(token.payload, `"sub":"${sub}"`) + const emailVerifiedOffset = findOffset(token.payload, '"email_verified":true') + const expOffset = findOffset(token.payload, `"exp":${expString}`) + const issOffset = findOffset(token.payload, '"iss":"https://accounts.google.com"') + const audOffset = findOffset(token.payload, `"aud":"${aud}"`) + + const modulusInteger = bytesToBigInt(modulus) + const redc = (1n << (2n * 2048n + BARRETT_OVERFLOW_BITS)) / modulusInteger + const audienceDigest = sha256(audienceBytes) + + return { + inputs: { + signing_input: pad(signingInput, SIGNING_INPUT_MAX), + signing_input_len: String(signingInput.length), + header_b64_len: String(token.headerB64.length), + payload_json: pad(token.payload, PAYLOAD_JSON_MAX), + payload_json_len: String(token.payload.length), + email_offset: String(emailOffset), + nonce_offset: String(nonceOffset), + sub_offset: String(subOffset), + email_verified_offset: String(emailVerifiedOffset), + exp_offset: String(expOffset), + exp_len: String(expString.length), + iss_offset: String(issOffset), + aud_offset: String(audOffset), + email_bytes: Array.from(paddedEmail), + email_len: String(emailBytes.length), + sub_bytes: Array.from(paddedSub), + sub_len: String(subBytes.length), + audience_bytes: Array.from(paddedAudience), + audience_len: String(audienceBytes.length), + signature: limbs(bytesToBigInt(token.signature)), + redc: limbs(redc), + authorization_digest: Array.from(authorizationDigest), + audience_hash: [pack31(audienceDigest.subarray(0, 16)), pack31(audienceDigest.subarray(16))], + sub_packed: [pack31(paddedSub)], + email_packed: [pack31(paddedEmail.subarray(0, 31)), pack31(paddedEmail.subarray(31))], + exp: expString, + modulus: limbs(modulusInteger), + }, + identity: { platformId: 'google', oauthClientId: aud, userId: sub, userName: email }, + proofFields: { + tokenExpiresAt: exp, + signingKeyModulus: modulus, + }, + } +} diff --git a/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/oidc_google.assets.ts b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/oidc_google.assets.ts new file mode 100644 index 00000000..c67e5835 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/oidc_google.assets.ts @@ -0,0 +1,13 @@ +import * as resource from '../../../assets/index.js' + +export const release = resource.archive( + 'https://github.com/libid-org/libid-circuits/releases/download/v0.3.0/libid-circuits-0.3.0-oidc-google.tar.gz', + 'circuits/v0.3.0/oidc-google', +) + +export const circuit = release.member('oidc_google.json', { + ...resource.headers.immutable, + ...resource.headers.json, +}) + +export const verificationKey = release.member('vk', resource.headers.immutable) diff --git a/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/publicInputs.ts b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/publicInputs.ts new file mode 100644 index 00000000..d1c604f3 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/circuits/oidc_google/publicInputs.ts @@ -0,0 +1,69 @@ +import { sha256 } from '@noble/hashes/sha2.js' +import { + type GoogleProofV1, + RSA_MODULUS_BYTES, + validateIdentity, + validateProof, +} from '../../../platforms/google/1/types.js' +import type { Identity } from '../../../platforms/types.js' + +const encoder = new TextEncoder() + +function integer(bytes: Uint8Array): bigint { + let result = 0n + for (const byte of bytes) result = (result << 8n) | BigInt(byte) + return result +} + +const field = (value: bigint | number) => `0x${BigInt(value).toString(16).padStart(64, '0')}` + +function packed(value: string, fields: number): string[] { + const bytes = encoder.encode(value) + const padded = new Uint8Array(fields * 31) + padded.set(bytes) + return Array.from({ length: fields }, (_, index) => + field(integer(padded.subarray(index * 31, (index + 1) * 31))), + ) +} + +function modulusLimbs(modulus: Uint8Array): string[] { + if (modulus.length !== RSA_MODULUS_BYTES) throw new Error('invalid Google signing modulus') + const value = integer(modulus) + const mask = (1n << 120n) - 1n + return Array.from({ length: 18 }, (_, index) => field((value >> (120n * BigInt(index))) & mask)) +} + +/** Flatten Google v1's named proof values into the exact 56 verifier fields. */ +export function buildGooglePublicInputs( + authorizationDigest: Uint8Array, + identity: Identity<'google'>, + value: GoogleProofV1, +): string[] { + const proof = validateProof(value) + validateIdentity(identity) + if (authorizationDigest.length !== 32) { + throw new Error('authorizationDigest must be exactly 32 bytes') + } + const audienceHash = sha256(encoder.encode(identity.oauthClientId)) + return [ + ...Array.from(authorizationDigest, field), + field(integer(audienceHash.subarray(0, 16))), + field(integer(audienceHash.subarray(16))), + ...packed(identity.userId, 1), + ...packed(identity.userName, 2), + field(proof.tokenExpiresAt), + ...modulusLimbs(proof.signingKeyModulus), + ] +} + +/** Exact-match bb.js output before discarding its positional array. */ +export function validateGooglePublicInputs( + value: unknown, + authorizationDigest: Uint8Array, + identity: Identity<'google'>, + proof: GoogleProofV1, +): value is string[] { + if (!Array.isArray(value)) return false + const expected = buildGooglePublicInputs(authorizationDigest, identity, proof) + return value.length === expected.length && value.every((item, index) => item === expected[index]) +} diff --git a/ts/packages/ceremony/src/barretenberg/engine.test.ts b/ts/packages/ceremony/src/barretenberg/engine.test.ts new file mode 100644 index 00000000..072d12d5 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/engine.test.ts @@ -0,0 +1,94 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { ProofEngine } from './engine.js' + +vi.mock('../assets/index.js', () => ({ resolve: () => 'https://ccdp.test/asset' })) + +vi.mock('./barretenberg.assets.js', () => ({ abi: {}, acvm: {}, bbWasm: {}, crs: [{}] })) + +afterEach(() => vi.unstubAllGlobals()) + +function engine() { + let receive!: (event: { data: unknown }) => void + const postMessage = vi.fn(), + terminate = vi.fn() + vi.stubGlobal('location', { href: 'https://ccdp.test/prover' }) + vi.stubGlobal('navigator', { hardwareConcurrency: 4 }) + vi.stubGlobal( + 'Worker', + class { + postMessage = postMessage + terminate = terminate + + addEventListener(type: string, listener: typeof receive) { + if (type === 'message') receive = listener + } + }, + ) + const events: import('../events.js').OperationEvent[] = [] + const instance = new ProofEngine({ + circuitUrl: 'https://ccdp.test/circuit', + verificationKeyUrl: 'https://ccdp.test/vk', + emit: (event) => events.push(event), + }) + receive({ data: { type: 'engine-booted', timestamp: 1 } }) + return { instance, postMessage, terminate, events, send: (data: unknown) => receive({ data }) } +} + +it('cancels an early witness, terminates the worker and ignores late delivery [LIBID-PROVER-014]', async () => { + const e = engine() + const controller = new AbortController() + const result = e.instance.prove({ fixture: 1 }, controller.signal) + const rejected = expect(result).rejects.toMatchObject({ event: 'zk-proof-generation' }) + e.send({ + type: 'engine-event', + event: { event: 'proof-backend-initialization', phase: 'started', timestamp: 2 }, + }) + expect(e.postMessage).toHaveBeenCalledTimes(1) + e.send({ type: 'engine-ready' }) + await vi.waitFor(() => + expect(e.postMessage).toHaveBeenCalledWith({ type: 'engine-prove', inputs: { fixture: 1 } }), + ) + e.send({ type: 'engine-event', event: { event: 'witness', phase: 'started', timestamp: 3 } }) + controller.abort() + await rejected + expect(e.terminate).toHaveBeenCalledOnce() + expect( + e.events.filter((event) => event.phase === 'finished').map((event) => event.event), + ).toEqual(['proof-worker-bootstrap']) + const count = e.events.length + e.send({ type: 'engine-event', event: { event: 'witness', phase: 'finished', timestamp: 4 } }) + e.send({ type: 'engine-result', result: {} }) + e.instance.destroy() + expect(e.events).toHaveLength(count) + expect(e.terminate).toHaveBeenCalledOnce() +}) + +it('initialization failure releases waiting inputs without dispatching them [LIBID-PROVER-014]', async () => { + const e = engine() + const result = e.instance.prove({ fixture: 1 }) + const rejected = expect(result).rejects.toMatchObject({ event: 'zk-proof-generation' }) + e.send({ type: 'engine-error', event: 'zk-proof-generation', error: 'Proof engine failed' }) + await rejected + e.send({ type: 'engine-ready' }) + expect(e.postMessage).toHaveBeenCalledTimes(1) + expect(e.terminate).toHaveBeenCalledOnce() +}) + +it('preparation finishes only once both backend and inputs are ready, without blocking witness dispatch', async () => { + const e = engine() + const result = e.instance.prove({ fixture: 1 }).catch(() => {}) + e.send({ type: 'engine-ready' }) + await vi.waitFor(() => + expect(e.postMessage).toHaveBeenCalledWith({ type: 'engine-prove', inputs: { fixture: 1 } }), + ) + expect( + e.events.filter((x) => x.event === 'zk-proof-preparation' && x.phase === 'finished'), + ).toHaveLength(0) + const timestamp = performance.timeOrigin + performance.now() + 1 + e.send({ type: 'engine-prepared', timestamp }) + expect( + e.events.filter((x) => x.event === 'zk-proof-preparation' && x.phase === 'finished'), + ).toEqual([{ event: 'zk-proof-preparation', phase: 'finished', timestamp }]) + e.instance.destroy() + await result +}) diff --git a/ts/packages/ceremony/src/barretenberg/engine.ts b/ts/packages/ceremony/src/barretenberg/engine.ts new file mode 100644 index 00000000..140dca64 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/engine.ts @@ -0,0 +1,203 @@ +import { resolve as resolveAsset } from '../assets/index.js' +import { ceremonyError } from '../errors.js' +import { now, type OperationEvent } from '../events.js' +import { abi, acvm, bbWasm, crs } from './barretenberg.assets.js' + +/** Browser-generated bb output; structural checks here do not establish cryptographic validity. */ +export interface RawProof { + proof: Uint8Array + publicInputs: string[] + runtime: { effectiveThreads: number; sharedMemory: boolean } +} + +export interface ProofEngineOptions { + /** Compiled Noir circuit and matching released verification key, resolved by the asset graph. */ + circuitUrl: string + verificationKeyUrl: string + emit?: (event: OperationEvent) => void + threads?: number +} + +type WorkerMessage = + | { type: 'engine-booted'; timestamp: number } + | { type: 'engine-ready' } // Ready for witness execution; bb may still be initializing. + | { type: 'engine-event'; event: OperationEvent } + | { type: 'engine-prepared'; timestamp: number } + | { type: 'engine-result'; result: RawProof } + | { type: 'engine-error'; error: string; event: string } + +/** One boot, one witness, one proof, then unconditional worker destruction. */ +export class ProofEngine { + #worker: Worker | null = null + readonly #emit: (event: OperationEvent) => void + #inputsAt: number | undefined + #backendAt: number | undefined + #prepared = false + readonly #ready: Promise + #resolveReady!: () => void + #failure: Error | null = null + #used = false + #result: Promise | null = null + #resolveResult: ((result: RawProof) => void) | null = null + #rejectResult: ((error: Error) => void) | null = null + #settled = false + #preload: { + type: 'engine-preload' + circuitUrl: string + verificationKeyUrl: string + threads: number + acvmUrl: string + abiUrl: string + wasmPath: string + crsPath: string + } | null = null + + constructor({ + circuitUrl, + verificationKeyUrl, + emit = () => undefined, + threads, + }: ProofEngineOptions) { + const [url, keyUrl] = [circuitUrl, verificationKeyUrl].map((value) => { + const url = new URL(value, location.href) + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.hash) + throw new Error('invalid circuit resource URL') + return url.href + }) + this.#emit = (event) => { + try { + emit(event) + } catch { + /* Observers cannot control proving. */ + } + } + this.#emit({ event: 'zk-proof-preparation', phase: 'started', timestamp: now() }) + this.#emit({ event: 'proof-worker-bootstrap', phase: 'started', timestamp: now() }) + this.#ready = new Promise((resolve) => { + this.#resolveReady = resolve + }) + void this.#start(url, keyUrl, threads).catch((error: unknown) => this.#fail(error)) + } + + /** Execute one witness and proof; initialization overlaps until bb is needed. Aborting retires the worker. */ + async prove(inputs: Record, signal?: AbortSignal): Promise { + if (this.#used) throw new Error('proof engine is single-use') + this.#used = true + this.#inputsAt = now() + this.#finishPreparation() + const abort = () => + this.#fail(signal?.reason ?? new DOMException('Proving aborted', 'AbortError')) + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) abort() + try { + await this.#ready + if (this.#failure) throw this.#failure + this.#result = new Promise((resolve, reject) => { + this.#resolveResult = resolve + this.#rejectResult = reject + }) + try { + this.#worker?.postMessage({ type: 'engine-prove', inputs }) + } catch (error) { + this.#fail(error) + } + return await this.#result + } finally { + signal?.removeEventListener('abort', abort) + } + } + + /** Retire pending work and the worker; repeated calls after settlement are harmless. */ + destroy(): void { + if (!this.#settled) this.#fail('proof engine destroyed') + } + + async #start(circuitUrl: string, verificationKeyUrl: string, threads?: number): Promise { + if (this.#settled) return + const worker = new Worker(new URL('./engine.worker.ts', import.meta.url), { type: 'module' }) + this.#worker = worker + worker.addEventListener('message', (event: MessageEvent) => { + try { + this.#onMessage(event.data) + } catch (error) { + this.#fail(error) + } + }) + worker.addEventListener('error', (event) => { + this.#fail( + [ + event.message || 'proof worker failed', + event.filename || 'unknown worker source', + `${event.lineno}:${event.colno}`, + ].join(' · '), + ) + }) + this.#preload = { + type: 'engine-preload', + circuitUrl, + verificationKeyUrl, + threads: Math.max(1, Math.min(threads ?? 4, navigator.hardwareConcurrency || 1, 4)), + acvmUrl: resolveAsset(acvm), + abiUrl: resolveAsset(abi), + wasmPath: resolveAsset(bbWasm).replace('-threads.wasm', '.wasm'), + crsPath: new URL('.', resolveAsset(crs[0])).href, + } + } + + #onMessage(message: WorkerMessage): void { + if (!message || typeof message !== 'object' || this.#settled) return + switch (message.type) { + case 'engine-booted': + this.#emit({ + event: 'proof-worker-bootstrap', + phase: 'finished', + timestamp: message.timestamp, + }) + if (this.#preload) { + this.#worker?.postMessage(this.#preload) + this.#preload = null + } + break + case 'engine-event': + this.#emit(message.event) + break + case 'engine-prepared': + this.#backendAt = message.timestamp + this.#finishPreparation() + break + case 'engine-ready': + this.#resolveReady() + break + case 'engine-result': + this.#settled = true + this.#worker?.terminate() + this.#resolveResult?.(message.result) + break + case 'engine-error': + this.#fail(ceremonyError(message.error, message.event)) + break + default: + this.#fail('unexpected proof worker message') + } + } + + #finishPreparation(): void { + if (this.#prepared || this.#inputsAt === undefined || this.#backendAt === undefined) return + this.#prepared = true + this.#emit({ + event: 'zk-proof-preparation', + phase: 'finished', + timestamp: Math.max(this.#inputsAt, this.#backendAt), + }) + } + + #fail(reason: unknown): void { + if (this.#settled) return + this.#settled = true + this.#worker?.terminate() + const error = ceremonyError(reason, this.#used ? 'zk-proof-generation' : 'zk-proof-preparation') + this.#failure = error + this.#resolveReady() + this.#rejectResult?.(error) + } +} diff --git a/ts/packages/ceremony/src/barretenberg/engine.worker.test.ts b/ts/packages/ceremony/src/barretenberg/engine.worker.test.ts new file mode 100644 index 00000000..cad25c21 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/engine.worker.test.ts @@ -0,0 +1,350 @@ +import { gzipSync } from 'node:zlib' +import { afterEach, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + initialize: vi.fn(), + acvm: vi.fn(), + abi: vi.fn(), + execute: vi.fn(), + prove: vi.fn(), + destroy: vi.fn(), +})) + +vi.mock('@noir-lang/acvm_js', () => ({ default: mocks.acvm })) + +vi.mock('@noir-lang/noirc_abi', () => ({ default: mocks.abi })) + +vi.mock('@noir-lang/noir_js', () => ({ + Noir: class { + execute = mocks.execute + }, +})) + +vi.mock('@aztec/bb.js', () => ({ + BackendType: { Wasm: 'Wasm' }, + Barretenberg: { new: mocks.create }, +})) + +vi.mock('./barretenberg.assets.js', () => ({ SRS_SIZE: 2 ** 18 })) + +afterEach(() => { + vi.unstubAllGlobals() + vi.resetAllMocks() + vi.resetModules() +}) + +async function worker(key: Response | Promise = new Response(Uint8Array.of(11, 12))) { + let receive!: (event: { data: unknown }) => void + const postMessage = vi.fn() + const request = vi.fn(async (url: string) => + url.endsWith('/vk') + ? key + : new Response( + JSON.stringify({ + bytecode: gzipSync(Uint8Array.of(1, 2, 3)).toString('base64'), + }), + ), + ) + vi.stubGlobal('self', { + crossOriginIsolated: true, + postMessage, + addEventListener: (_: string, callback: typeof receive) => { + receive = callback + }, + }) + vi.stubGlobal('fetch', request) + mocks.create.mockImplementation(async ({ logger }) => { + await mocks.initialize() + logger('threads: 4; shared memory: true') + return { circuitProve: mocks.prove, destroy: mocks.destroy } + }) + mocks.execute.mockResolvedValue({ witness: gzipSync(Uint8Array.of(4, 5, 6)) }) + mocks.destroy.mockResolvedValue(undefined) + mocks.prove.mockResolvedValue({ + proof: [new Uint8Array(32).fill(7), new Uint8Array(32).fill(8)], + publicInputs: [new Uint8Array(32), new Uint8Array(32).fill(255)], + }) + await import('./engine.worker.js') + receive({ + data: { + type: 'engine-preload', + circuitUrl: 'https://ccdp.test/circuit.json', + verificationKeyUrl: 'https://ccdp.test/vk', + threads: 4, + acvmUrl: '/acvm.wasm', + abiUrl: '/abi.wasm', + wasmPath: '/bb.wasm', + crsPath: 'https://crs.test/', + }, + }) + return { + receive, + request, + postMessage, + has: (type: string) => postMessage.mock.calls.some(([m]) => m.type === type), + } +} + +it('uses the released VK with exact ZK Keccak settings and preserves proof encoding [LIBID-PROVER-001]', async () => { + const w = await worker() + await expect.poll(() => w.has('engine-ready')).toBe(true) + w.receive({ data: { type: 'engine-prove', inputs: { fixture: 1 } } }) + await expect.poll(() => w.has('engine-result')).toBe(true) + expect(w.request).toHaveBeenCalledWith('https://ccdp.test/vk', { + credentials: 'same-origin', + redirect: 'error', + }) + expect(mocks.prove).toHaveBeenCalledExactlyOnceWith({ + circuit: { + name: 'circuit', + bytecode: Uint8Array.of(1, 2, 3), + verificationKey: Uint8Array.of(11, 12), + }, + witness: Uint8Array.of(4, 5, 6), + settings: { + ipaAccumulation: false, + oracleHashType: 'keccak', + disableZk: false, + optimizedSolidityVerifier: false, + }, + }) + expect(w.postMessage.mock.calls.find(([m]) => m.type === 'engine-result')![0].result).toEqual({ + proof: Uint8Array.from([...new Uint8Array(32).fill(7), ...new Uint8Array(32).fill(8)]), + publicInputs: [`0x${'00'.repeat(32)}`, `0x${'ff'.repeat(32)}`], + runtime: { effectiveThreads: 4, sharedMemory: true }, + }) + expect(mocks.destroy).toHaveBeenCalledOnce() +}) + +it.each(['missing', 'empty'])( + 'fails for a %s VK without falling back to recomputation [LIBID-PROVER-001]', + async (kind) => { + const w = await worker(new Response(null, { status: kind === 'missing' ? 404 : 200 })) + await expect.poll(() => w.has('engine-error')).toBe(true) + expect(w.has('engine-ready')).toBe(false) + expect(mocks.destroy).toHaveBeenCalledOnce() + expect(mocks.prove).not.toHaveBeenCalled() + }, +) + +it('preserves cleanup when bb rejects the supplied key [LIBID-PROVER-001]', async () => { + const w = await worker() + await expect.poll(() => w.has('engine-ready')).toBe(true) + mocks.prove.mockRejectedValueOnce(new Error('Invalid verification key')) + w.receive({ data: { type: 'engine-prove', inputs: {} } }) + await expect.poll(() => w.has('engine-error')).toBe(true) + expect(w.has('engine-result')).toBe(false) + expect(mocks.prove).toHaveBeenCalledOnce() + expect(mocks.destroy).toHaveBeenCalledOnce() +}) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((ok, fail) => { + resolve = ok + reject = fail + }) + return { promise, resolve, reject } +} + +it.each(['backend', 'resources'])( + 'starts all preload branches before %s finishes [LIBID-PROVER-012]', + async (first) => { + const backend = deferred(), + acvm = deferred(), + abi = deferred(), + key = deferred() + mocks.initialize.mockReturnValueOnce(backend.promise) + mocks.acvm.mockReturnValueOnce(acvm.promise) + mocks.abi.mockReturnValueOnce(abi.promise) + const w = await worker(key.promise) + expect(mocks.create).toHaveBeenCalledOnce() + expect(mocks.acvm).toHaveBeenCalledOnce() + expect(mocks.abi).toHaveBeenCalledOnce() + expect(w.request).toHaveBeenCalledTimes(2) + expect(w.has('engine-ready')).toBe(false) + const resources = () => { + acvm.resolve() + abi.resolve() + key.resolve(new Response(Uint8Array.of(11, 12))) + } + if (first === 'backend') { + backend.resolve() + await expect + .poll(() => + w.postMessage.mock.calls.some( + ([m]) => + m.event?.event === 'proof-backend-initialization' && m.event?.phase === 'finished', + ), + ) + .toBe(true) + expect(w.has('engine-ready')).toBe(false) + resources() + } else { + resources() + await expect + .poll(() => + w.postMessage.mock.calls.some( + ([m]) => m.event?.event === 'proof-circuit-load' && m.event?.phase === 'finished', + ), + ) + .toBe(true) + await expect.poll(() => w.has('engine-ready')).toBe(true) + backend.resolve() + } + await expect.poll(() => w.has('engine-ready')).toBe(true) + expect(mocks.destroy).not.toHaveBeenCalled() + expect(mocks.execute).not.toHaveBeenCalled() + }, +) + +it.each(['circuit', 'wasm'])( + 'fails promptly on %s loading and releases a late backend [LIBID-PROVER-014]', + async (failure) => { + const backend = deferred() + mocks.initialize.mockReturnValueOnce(backend.promise) + if (failure === 'wasm') mocks.acvm.mockRejectedValueOnce(new Error('WASM load failed')) + const w = await worker( + failure === 'circuit' + ? new Response(null, { status: 404 }) + : new Response(Uint8Array.of(11, 12)), + ) + await expect.poll(() => w.has('engine-error')).toBe(true) + expect(mocks.destroy).not.toHaveBeenCalled() + backend.resolve() + await expect.poll(() => mocks.destroy.mock.calls.length).toBe(1) + expect(w.has('engine-ready')).toBe(false) + expect(mocks.prove).not.toHaveBeenCalled() + }, +) + +it('releases an initialized backend when Noir loading fails [LIBID-PROVER-014]', async () => { + const acvm = deferred() + mocks.acvm.mockReturnValueOnce(acvm.promise) + const w = await worker() + await expect + .poll(() => + w.postMessage.mock.calls.some( + ([m]) => m.event?.event === 'proof-backend-initialization' && m.event?.phase === 'finished', + ), + ) + .toBe(true) + acvm.reject(new Error('WASM load failed')) + await expect.poll(() => w.has('engine-error')).toBe(true) + expect(mocks.destroy).toHaveBeenCalledOnce() + expect(w.has('engine-ready')).toBe(false) +}) + +it('backend failure does not wait for pending resource loads [LIBID-PROVER-014]', async () => { + const key = deferred() + mocks.initialize.mockRejectedValueOnce(new Error('Backend unavailable')) + const w = await worker(key.promise) + await expect.poll(() => w.has('engine-error')).toBe(true) + expect(mocks.destroy).not.toHaveBeenCalled() + key.resolve(new Response(Uint8Array.of(11, 12))) + await expect + .poll(() => + w.postMessage.mock.calls.some( + ([m]) => m.event?.event === 'proof-circuit-load' && m.event?.phase === 'finished', + ), + ) + .toBe(true) + expect(w.has('engine-ready')).toBe(false) + expect(mocks.prove).not.toHaveBeenCalled() +}) + +it.each(['witness', 'backend'])( + 'overlaps witness execution with backend initialization when %s finishes first [LIBID-PROVER-012]', + async (first) => { + const backend = deferred() + const witness = deferred<{ witness: Uint8Array }>() + mocks.initialize.mockReturnValueOnce(backend.promise) + const w = await worker() + mocks.execute.mockReturnValueOnce(witness.promise) + await expect.poll(() => w.has('engine-ready')).toBe(true) + w.receive({ data: { type: 'engine-prove', inputs: { fixture: 1 } } }) + expect(mocks.execute).toHaveBeenCalledExactlyOnceWith({ fixture: 1 }) + expect(mocks.prove).not.toHaveBeenCalled() + const finishWitness = () => witness.resolve({ witness: gzipSync(Uint8Array.of(4, 5, 6)) }) + if (first === 'witness') finishWitness() + else backend.resolve() + await expect + .poll(() => + w.postMessage.mock.calls.some( + ([m]) => + m.event?.event === (first === 'witness' ? 'witness' : 'proof-backend-initialization') && + m.event?.phase === 'finished', + ), + ) + .toBe(true) + expect(mocks.prove).not.toHaveBeenCalled() + if (first === 'witness') backend.resolve() + else finishWitness() + await expect.poll(() => w.has('engine-result')).toBe(true) + expect(mocks.prove).toHaveBeenCalledOnce() + expect(mocks.destroy).toHaveBeenCalledOnce() + }, +) + +it('reports witness failure promptly and destroys a late backend once [LIBID-PROVER-014]', async () => { + const backend = deferred() + mocks.initialize.mockReturnValueOnce(backend.promise) + const w = await worker() + mocks.execute.mockRejectedValueOnce(new Error('Witness failed')) + await expect.poll(() => w.has('engine-ready')).toBe(true) + w.receive({ data: { type: 'engine-prove', inputs: {} } }) + await expect.poll(() => w.has('engine-error')).toBe(true) + expect(mocks.destroy).not.toHaveBeenCalled() + backend.resolve() + await expect.poll(() => mocks.destroy.mock.calls.length).toBe(1) + expect(mocks.prove).not.toHaveBeenCalled() + expect(w.has('engine-result')).toBe(false) +}) + +it('backend failure cannot wait for or revive a pending witness [LIBID-PROVER-014]', async () => { + const backend = deferred() + const witness = deferred<{ witness: Uint8Array }>() + mocks.initialize.mockReturnValueOnce(backend.promise) + const w = await worker() + mocks.execute.mockReturnValueOnce(witness.promise) + await expect.poll(() => w.has('engine-ready')).toBe(true) + w.receive({ data: { type: 'engine-prove', inputs: {} } }) + backend.reject(new Error('Backend failed')) + await expect.poll(() => w.has('engine-error')).toBe(true) + witness.resolve({ witness: gzipSync(Uint8Array.of(4, 5, 6)) }) + await expect + .poll(() => + w.postMessage.mock.calls.some( + ([m]) => m.event?.event === 'witness' && m.event?.phase === 'finished', + ), + ) + .toBe(true) + expect(w.postMessage.mock.calls.filter(([m]) => m.type === 'engine-error')).toHaveLength(1) + expect(mocks.prove).not.toHaveBeenCalled() + expect(w.has('engine-result')).toBe(false) +}) + +it('a duplicate request fails once and cannot deliver a late proof [LIBID-PROVER-014]', async () => { + const proof = deferred() + const w = await worker() + mocks.prove.mockReturnValueOnce(proof.promise) + await expect.poll(() => w.has('engine-ready')).toBe(true) + const message = { data: { type: 'engine-prove', inputs: {} } } + w.receive(message) + await expect.poll(() => mocks.prove.mock.calls.length).toBe(1) + w.receive(message) + await expect.poll(() => w.has('engine-error')).toBe(true) + proof.resolve({ proof: [new Uint8Array(32)], publicInputs: [] }) + await expect + .poll(() => + w.postMessage.mock.calls.some( + ([m]) => m.event?.event === 'proof' && m.event?.phase === 'finished', + ), + ) + .toBe(true) + expect(mocks.destroy).toHaveBeenCalledOnce() + expect(w.postMessage.mock.calls.filter(([m]) => m.type === 'engine-error')).toHaveLength(1) + expect(w.has('engine-result')).toBe(false) +}) diff --git a/ts/packages/ceremony/src/barretenberg/engine.worker.ts b/ts/packages/ceremony/src/barretenberg/engine.worker.ts new file mode 100644 index 00000000..dcb41b02 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/engine.worker.ts @@ -0,0 +1,183 @@ +import { BackendType, Barretenberg } from '@aztec/bb.js' +import { bytesToHex } from '@noble/hashes/utils.js' +import initACVM from '@noir-lang/acvm_js' +import { Noir } from '@noir-lang/noir_js' +import initAbi from '@noir-lang/noirc_abi' +import { ceremonyError, errorMessage } from '../errors.js' +import { now, type OperationEvent, operation } from '../events.js' +import { SRS_SIZE } from './barretenberg.assets.js' +import type { RawProof } from './engine.js' + +type Circuit = ConstructorParameters[0] + +type Api = Awaited> + +type ProvingCircuit = Parameters[0]['circuit'] + +type Preload = { + type: 'engine-preload' + circuitUrl: string + verificationKeyUrl: string + threads: number + acvmUrl: string + abiUrl: string + wasmPath: string + crsPath: string +} + +type Prove = { type: 'engine-prove'; inputs: Record } + +let runtime: { effectiveThreads: number; sharedMemory: boolean } | undefined + +let state: 'new' | 'loading' | 'ready' | 'proving' | 'done' = 'new' + +let ready: { noir: Noir; circuit: ProvingCircuit } | null = null + +let backend: Promise | null = null + +const send = (message: unknown): void => self.postMessage(message) + +const emit = (event: OperationEvent) => send({ type: 'engine-event', event }) + +const span = (event: string, work: () => Promise) => operation(emit, event, work) + +async function inflate(bytes: Uint8Array): Promise> { + const stream = new Response(Uint8Array.from(bytes)).body!.pipeThrough( + new DecompressionStream('gzip'), + ) + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +function destroyBackend(): Promise { + const pending = backend + backend = null + return pending ? pending.then((api) => api.destroy()) : Promise.resolve() +} + +function fail(error: unknown): void { + if (state === 'done') return + state = 'done' + ready = null + // Also releases a backend that finishes initializing after a sibling failed. + void destroyBackend().catch(() => {}) + const failure = ceremonyError(error, 'zk-proof-generation') + send({ type: 'engine-error', error: errorMessage(failure), event: failure.event }) +} + +/** Start bb initialization alongside circuit/key and Noir loading; witness readiness does not await bb. */ +async function preload(message: Preload): Promise { + if (state !== 'new') throw new Error('Duplicate engine initialization') + state = 'loading' + if (!self.crossOriginIsolated || typeof SharedArrayBuffer === 'undefined') { + throw new Error('proof worker requires cross-origin isolation') + } + backend = span('proof-backend-initialization', async () => { + const api = await Barretenberg.new({ + backend: BackendType.Wasm, + threads: message.threads, + logger: (message) => { + const match = /threads: ([0-9]+); shared memory: (true|false)/.exec(message) + if (match) + runtime = { effectiveThreads: Number(match[1]), sharedMemory: match[2] === 'true' } + }, + srsSize: SRS_SIZE, + wasmPath: message.wasmPath, + crsPath: message.crsPath, + }) + if (!runtime?.sharedMemory || runtime.effectiveThreads < 2) { + await api.destroy() + throw new Error('Multithreaded backend unavailable') + } + return api + }) + void backend.catch(fail) + const [{ compiled, circuit }] = await Promise.all([ + span('proof-circuit-load', async () => { + const [response, keyResponse] = await Promise.all( + [message.circuitUrl, message.verificationKeyUrl].map((url) => + fetch(url, { credentials: 'same-origin', redirect: 'error' }), + ), + ) + if (!response.ok || !keyResponse.ok) throw new Error('Circuit resource request failed') + const [compiled, key] = await Promise.all([ + response.json() as Promise, + keyResponse.arrayBuffer(), + ]) + // An empty key asks bb to recompute it; a missing release artifact must fail instead. + if (!key.byteLength) throw new Error('Empty verification key') + return { + compiled, + circuit: { + name: 'circuit', + bytecode: await inflate(Uint8Array.from(atob(compiled.bytecode), (c) => c.charCodeAt(0))), + verificationKey: new Uint8Array(key), + }, + } + }), + span('proof-wasm-load', async () => { + // Bundled worker URLs cannot infer wasm-bindgen's original sibling WASM paths. + // Noir must share these initialized ACVM/ABI modules, not load a second copy. + await Promise.all([ + initACVM({ module_or_path: message.acvmUrl }), + initAbi({ module_or_path: message.abiUrl }), + ]) + }), + ]) + if (state !== 'loading') return + ready = { noir: new Noir(compiled), circuit } + state = 'ready' + send({ type: 'engine-ready' }) + void backend + .then(() => { + if (state !== 'done') send({ type: 'engine-prepared', timestamp: now() }) + }) + .catch(fail) +} + +async function prove(message: Prove): Promise { + if (!ready || !backend || state !== 'ready') throw new Error('proof engine is not ready') + state = 'proving' + emit({ event: 'zk-proof-generation', phase: 'started', timestamp: now() }) + const { noir, circuit } = ready + const [api, { witness }] = await Promise.all([ + backend, + span('witness', () => noir.execute(message.inputs as Parameters[0])), + ]) + if (state !== 'proving') return + const generated = await span('proof', async () => + api.circuitProve({ + circuit, + witness: await inflate(witness), + // Exact bb.js 5.2.0 settings for verifierTarget: 'evm' (ZK-Honk/Keccak). + settings: { + ipaAccumulation: false, + oracleHashType: 'keccak', + disableZk: false, + optimizedSolidityVerifier: false, + }, + }), + ) + if (state !== 'proving') return + const proof = new Uint8Array(generated.proof.length * 32) + generated.proof.forEach((field, i) => { + proof.set(field, i * 32) + }) + const result: RawProof = { + proof, + publicInputs: generated.publicInputs.map((field) => `0x${bytesToHex(field)}`), + runtime: runtime!, + } + emit({ event: 'zk-proof-generation', phase: 'finished', timestamp: now() }) + await span('proof-backend-destroy', destroyBackend) + if (state !== 'proving') return + ready = null + state = 'done' + send({ type: 'engine-result', result }) +} + +send({ type: 'engine-booted', timestamp: now() }) + +self.addEventListener('message', (event: MessageEvent) => { + const work = event.data.type === 'engine-preload' ? preload(event.data) : prove(event.data) + void work.catch(fail) +}) diff --git a/ts/packages/ceremony/src/barretenberg/events.ts b/ts/packages/ceremony/src/barretenberg/events.ts new file mode 100644 index 00000000..e55d75f6 --- /dev/null +++ b/ts/packages/ceremony/src/barretenberg/events.ts @@ -0,0 +1,13 @@ +/** Core operations emitted by the Barretenberg pipeline. */ +export const proofEvents = ['zk-proof-preparation', 'zk-proof-generation'] as const + +/** Presentation weights estimate work; they are not elapsed durations. */ +export const proofWeights = { + 'proof-worker-bootstrap': 1, + 'proof-circuit-load': 2, + 'proof-wasm-load': 2, + 'proof-backend-initialization': 3, + 'circuit-inputs': 1, + witness: 3, + proof: 6, +} diff --git a/ts/packages/ceremony/src/ccdp/client/ceremony.test.ts b/ts/packages/ceremony/src/ccdp/client/ceremony.test.ts new file mode 100644 index 00000000..327d99d7 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/client/ceremony.test.ts @@ -0,0 +1,916 @@ +import type { LedgerId } from '@libid/ledger' +import { mainnet, testnet } from '@libid/ledger/testing' +import type { ConnectionEnd, Message, MessageType, PopupConnection } from '@libid/popup' +import { describe, expect, it, vi } from 'vitest' +import { CeremonyError } from '../../errors.js' +import { deriveAuthorizationDigest, deriveCodeChallenge } from '../../platforms/authorization.js' +import { platforms } from '../../platforms/index.js' +import { b64urlEncode } from '../../primitives.js' +import { type CeremonyEvent, ccdpClientFromConfig } from './ceremony.js' +import { validateCeremonyConfig } from './config.js' + +class Connection implements PopupConnection { + readonly peerOrigin = 'https://ccdp.test' + ready = Promise.resolve() + end!: (outcome?: ConnectionEnd) => void + ended = false + closed = new Promise((resolve) => { + this.end = (outcome = { outcome: 'closed' }) => resolve(outcome) + }) + send = vi.fn() + navigate = vi.fn(async (_url: string, _fragment?: URLSearchParams) => {}) + navigations: string[] = [] + navigateAway = vi.fn(async (url: string) => { + if (this.ended) throw new Error('Connection closed') + this.navigations.push(url) + }) + close = vi.fn(async () => { + this.ended = true + this.end() + }) + handlers = new Map void>() + + on(type: MessageType, handler: (m: M) => void) { + if (this.handlers.has(type.type)) throw new Error('Duplicate message handler') + this.handlers.set(type.type, (v) => handler(type.decode(v))) + return () => { + this.handlers.delete(type.type) + } + } + + receive(value: Message & Record) { + if (!this.ended) this.handlers.get(value.type)?.(value) + } +} + +const id = '6e171568-54e1-4f0d-aeb5-e8859826476a' + +const wireConfig = { + ccdpOrigin: 'https://ccdp.test', + platforms: { google: { clientId: 'client', ceremonyVersions: [1] } }, +} + +const config = validateCeremonyConfig(wireConfig, 'https://bridge.test') + +function setup() { + const connection = new Connection() + const data = new Uint8Array([1, 2]) + const ceremony = ccdpClientFromConfig(config).new( + connection, + id, + 'google', + testnet, + new Uint8Array(32), + data, + ) + return { connection, ceremony, data } +} + +const identity = { + platformId: 'google' as const, + oauthClientId: 'client', + userId: '1', + userName: 'a@b.c', +} + +const proof = { + identityProof: new Uint8Array([1]), + tokenExpiresAt: 42, + signingKeyModulus: new Uint8Array(256), +} + +describe('Client [LIBID-MOD-014] [LIBID-OAUTH-021] [LIBID-PROVER-021]', () => { + it('uses distinct origins and frozen input; never receives raw OAuth return', async () => { + const { connection: c, ceremony, data } = setup() + const events: string[] = [] + ceremony.onEvent((e) => events.push(e.status === 'active' ? `${e.event}.${e.phase}` : e.status)) + data[0] = 9 + const pending = ceremony.proveUserIdentity() + expect(c.navigate.mock.calls[0][0]).toBe('https://ccdp.test/ccdp/v1/prefetch') + c.receive({ type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 1 }) + expect(c.navigateAway).toHaveBeenCalledOnce() + c.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 3 }) + expect(c.send.mock.calls[0][0]).toEqual({ + type: 'prove-identity', + platformId: 'google', + platformCeremonyVersion: 1, + clientId: 'client', + redirectUri: config.redirectUri, + codeVerifier: null, + notaryAddress: testnet.notaryAddress(), + }) + c.receive({ type: 'identity-proof', identity, proof }) + const result = await pending + if (result.status !== 'accepted') throw new Error('Expected accepted') + expect(result.identity).toBe(identity) + expect(Object.keys(result.oauthProof)).toEqual([ + 'platformCeremonyVersion', + 'authorizationNonce', + 'proof', + ]) + expect(new URL(c.navigateAway.mock.calls[0][0]).searchParams.get('redirect_uri')).toBe( + config.redirectUri, + ) + expect(new URL(c.navigateAway.mock.calls[0][0]).searchParams.get('nonce')).toBe( + b64urlEncode( + deriveAuthorizationDigest({ + chainId: testnet.hash(), + operationDomain: new Uint8Array(32), + transactionData: new Uint8Array([1, 2]), + platformCeremonyVersion: 1, + authorizationNonce: result.oauthProof.authorizationNonce, + }), + ), + ) + expect(result.oauthProof.proof.identityProof).toEqual(new Uint8Array([1])) + expect(events).toEqual([ + 'prefetch-dispatch.started', + 'prefetch-dispatch.finished', + 'authorization.started', + 'prover.started', + 'completed', + ]) + expect(c.close).not.toHaveBeenCalled() + c.receive({ type: 'identity-proof', identity, proof }) + expect(events).toEqual([ + 'prefetch-dispatch.started', + 'prefetch-dispatch.finished', + 'authorization.started', + 'prover.started', + 'completed', + ]) + await expect(ceremony.proveUserIdentity()).rejects.toThrow('one-shot') + }) + it('closing the connection wins over late delivery without a CCDP cancel [LIBID-BROWSER-005]', async () => { + const { connection: c, ceremony } = setup() + const result = ceremony.proveUserIdentity() + const rejection = expect(result).rejects.toBeInstanceOf(CeremonyError) + await c.close() + c.receive({ type: 'identity-proof', identity, proof }) + await rejection + expect(c.close).toHaveBeenCalledOnce() + expect(c.send).not.toHaveBeenCalled() + }) + it('protocol CeremonyFailed remains a failure even with cancellation-like text [LIBID-OAUTH-022]', async () => { + const { connection, ceremony } = setup() + const events: CeremonyEvent[] = [] + ceremony.onEvent((event) => events.push(event)) + const result = ceremony.proveUserIdentity() + connection.receive({ + type: 'ceremony-failed', + event: 'authorization', + message: 'Ceremony canceled', + }) + await expect(result).rejects.toBeInstanceOf(CeremonyError) + expect(events.at(-1)).toMatchObject({ status: 'failed', event: 'authorization' }) + }) + it('rejects invalid predecessors', async () => { + const { connection: c, ceremony } = setup() + const pending = ceremony.proveUserIdentity() + c.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 3 }) + await expect(pending).rejects.toMatchObject({ + name: 'CeremonyError', + event: 'prefetch-dispatch', + message: expect.stringContaining('sequence'), + }) + }) + it('denial resolves only after start; observer failure is inert', async () => { + const { connection: c, ceremony } = setup() + ceremony.onEvent(() => { + throw new Error('observer') + }) + const pending = ceremony.proveUserIdentity() + c.receive({ type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 1 }) + c.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 3 }) + c.receive({ type: 'user-denied' }) + await expect(pending).resolves.toEqual({ status: 'denied' }) + }) + it('rejects setup failures without leaking handlers or connection ownership', async () => { + const { connection: c, ceremony } = setup() + c.handlers.set('event', () => {}) + await expect(ceremony.proveUserIdentity()).rejects.toThrow('initialize') + expect([...c.handlers.keys()]).toEqual(['event']) + c.handlers.clear() + const next = ccdpClientFromConfig(config).new( + c, + id, + 'google', + testnet, + new Uint8Array(32), + new Uint8Array(), + ) + const result = next.proveUserIdentity() + const rejection = expect(result).rejects.toMatchObject({ name: 'CeremonyError' }) + await c.close() + await rejection + }) + it('ignores unknown platforms and rejects oversized Google audiences', () => { + expect( + validateCeremonyConfig( + { ...wireConfig, platforms: { ...wireConfig.platforms, future: null } }, + 'https://bridge.test', + ).platforms, + ).toEqual(config.platforms) + expect(() => + validateCeremonyConfig( + { + ...wireConfig, + platforms: { google: { clientId: 'x'.repeat(129), ceremonyVersions: [1] } }, + }, + 'https://bridge.test', + ), + ).toThrow() + }) + it('accepts a local HTTP CCDP on a separate origin [LIBID-MOD-011]', () => { + for (const host of ['localhost', '127.0.0.1']) { + const bridge = `http://${host}:4682` + for (const ccdpOrigin of [`http://${host}`, `http://${host}:4683`]) { + const local = { ...wireConfig, ccdpOrigin } + expect(validateCeremonyConfig(local, bridge)).toMatchObject({ + ccdpOrigin, + redirectUri: `${bridge}/auth/callback`, + }) + } + } + expect(() => + validateCeremonyConfig( + { ...wireConfig, ccdpOrigin: 'http://ccdp.test' }, + 'https://bridge.test', + ), + ).toThrow() + }) + it('validates configuration without coupling Bridge and CCDP [LIBID-OAUTH-001]', () => { + expect(validateCeremonyConfig(wireConfig, 'https://bridge.test').ccdpOrigin).toBe( + 'https://ccdp.test', + ) + for (const patch of [ + { ccdpOrigin: 'https://ccdp.test/' }, + { callbackPath: '/auth/callback' }, + { redirectUri: 'https://bridge.test/auth/callback' }, + { allowedAppOrigins: [] }, + ]) + expect(() => + validateCeremonyConfig({ ...wireConfig, ...patch }, 'https://bridge.test'), + ).toThrow() + }) +}) + +it('rejects a duplicate live ID without coercing boxed strings [KIT-008]', async () => { + const client = ccdpClientFromConfig(config), + input = { + connection: new Connection(), + ledgerId: testnet, + platformId: 'google' as const, + operationDomain: new Uint8Array(32), + transactionData: new Uint8Array(), + } + const first = client.new( + input.connection, + id, + input.platformId, + input.ledgerId, + input.operationDomain, + input.transactionData, + ) + expect(() => + client.new( + new Connection(), + id, + input.platformId, + input.ledgerId, + input.operationDomain, + input.transactionData, + ), + ).toThrow('already live') + expect(() => + client.new( + input.connection, + Object(id) as string, + input.platformId, + input.ledgerId, + input.operationDomain, + input.transactionData, + ), + ).toThrow() + const result = first.proveUserIdentity() + const rejected = expect(result).rejects.toBeInstanceOf(CeremonyError) + await input.connection.close() + await rejected + expect(() => + client.new( + input.connection, + id, + input.platformId, + input.ledgerId, + input.operationDomain, + input.transactionData, + ), + ).not.toThrow() +}) + +it('rejects changed form serialization for X/GitHub client IDs, not signed Google audiences', () => { + for (const platform of ['x', 'github']) + for (const clientId of ['a+b', 'a b', 'a%2Fb', 'é']) + expect(() => + validateCeremonyConfig( + { ...wireConfig, platforms: { [platform]: { clientId, ceremonyVersions: [1] } } }, + 'https://bridge.test', + ), + ).toThrow() + expect(() => + validateCeremonyConfig( + { ...wireConfig, platforms: { google: { clientId: 'a+b', ceremonyVersions: [1] } } }, + 'https://bridge.test', + ), + ).not.toThrow() +}) + +it.each(['google', 'x', 'github'] as const)( + 'snapshots ledger hash and routing once for %s [LIBID-MOD-014/015]', + async (platformId) => { + const hash = testnet.hash(), + domain = new Uint8Array(32), + data = new Uint8Array([1, 2]) + const ledger = { + hash: vi.fn(() => hash), + notaryAddress: vi.fn(() => 'https://local-notary.test:8443'), + } + const connection = new Connection() + const ceremony = ccdpClientFromConfig({ + ...config, + platforms: { [platformId]: { clientId: 'client', ceremonyVersions: [1] } }, + }).new(connection, id, platformId, ledger, domain, data) + expect(ledger.hash).toHaveBeenCalledOnce() + expect(ledger.notaryAddress).toHaveBeenCalledOnce() + hash.fill(9) + domain.fill(9) + data.fill(9) + ledger.hash.mockImplementation(() => { + throw new Error('must not reread') + }) + ledger.notaryAddress.mockImplementation(() => { + throw new Error('must not reread') + }) + const pending = ceremony.proveUserIdentity() + connection.receive({ + type: 'event', + event: 'prefetch-dispatch', + phase: 'finished', + timestamp: 1, + }) + const authorization = new URL(connection.navigateAway.mock.calls[0][0]) + connection.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 3 }) + const message = connection.send.mock.calls[0][0] + expect(message.notaryAddress).toBe('https://local-notary.test:8443') + if (platformId === 'google') { + expect(message.codeVerifier).toBeNull() + expect(authorization.searchParams.has('code_challenge')).toBe(false) + } else { + expect(message.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(authorization.searchParams.get('code_challenge')).toBe( + deriveCodeChallenge(message.codeVerifier), + ) + } + for (const key of ['ledgerId', 'chainId', 'isTestnet']) expect(message).not.toHaveProperty(key) + if (platformId === 'google') { + connection.receive({ type: 'identity-proof', identity, proof }) + const result = await pending + if (result.status !== 'accepted') throw new Error('Expected proof') + expect(authorization.searchParams.get('nonce')).toBe( + b64urlEncode( + deriveAuthorizationDigest({ + chainId: testnet.hash(), + operationDomain: new Uint8Array(32), + transactionData: new Uint8Array([1, 2]), + platformCeremonyVersion: 1, + authorizationNonce: result.oauthProof.authorizationNonce, + }), + ), + ) + } else { + connection.receive({ type: 'user-denied' }) + await expect(pending).resolves.toEqual({ status: 'denied' }) + } + }, +) + +it('rejects missing, throwing or malformed hash methods before OAuth [LIBID-MOD-014]', () => { + const connection = new Connection() + for (const ledger of [ + null, + {}, + { hash: 1 }, + ...[null, [], new Uint8Array(31), new Uint8Array(33)].map((hash) => ({ hash: () => hash })), + { + hash: () => { + throw new Error('hash failure') + }, + }, + ]) + expect(() => + ccdpClientFromConfig(config).new( + connection, + id, + 'google', + ledger as LedgerId, + new Uint8Array(32), + new Uint8Array(), + ), + ).toThrow() + expect(connection.navigate).not.toHaveBeenCalled() +}) + +it.each(['google', 'x', 'github'] as const)( + 'rejects invalid notary addresses before OAuth for %s [LIBID-OAUTH-021]', + (platformId) => { + const connection = new Connection() + const client = ccdpClientFromConfig({ + ...config, + platforms: { [platformId]: { clientId: 'client', ceremonyVersions: [1] } }, + }) + for (const method of [ + undefined, + 1, + () => { + throw new Error('address failure') + }, + ...[ + null, + 1, + '', + 'http://notary.test', + 'https://notary.test/', + 'https://notary.test/path', + 'https://user@notary.test', + 'https://notary.test?x=1', + 'https://notary.test#x', + 'https://NOTARY.test', + 'https://notary.test:443', + ].map((value) => () => value), + ]) + expect(() => + client.new( + connection, + id, + platformId, + { hash: mainnet.hash, notaryAddress: method } as LedgerId, + new Uint8Array(32), + new Uint8Array(), + ), + ).toThrow() + expect(connection.navigate).not.toHaveBeenCalled() + expect(connection.send).not.toHaveBeenCalled() + }, +) + +it.each([ + { ...identity, platformId: 'x' }, + { ...identity, oauthClientId: 'other-client' }, + { ...identity, userId: '1'.repeat(32) }, +])('rejects a profile or client identity mismatch [LIBID-OAUTH-022]', async (identity) => { + const { connection, ceremony } = setup() + const result = ceremony.proveUserIdentity() + connection.receive({ type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 1 }) + connection.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 3 }) + connection.receive({ type: 'identity-proof', identity, proof }) + await expect(result).rejects.toThrow('sequence') +}) + +// Compile-only API checks: rejected forms must remain rejected by TypeScript. +function checkCreationTypes() { + const client = ccdpClientFromConfig(config) + const conn = new Connection(), + ledger = testnet, + bytes = new Uint8Array(32) + // @ts-expect-error Former object form is not supported. + client.new(id, { + connection: conn, + ledgerId: ledger, + platformId: 'google', + operationDomain: bytes, + transactionData: bytes, + }) + // @ts-expect-error Missing transaction data. + client.new(conn, id, 'google', ledger, bytes) + // @ts-expect-error Connection and ceremony ID have incompatible positions. + client.new(id, conn, 'google', ledger, bytes, bytes) + void client + .new(conn, id, 'google', ledger, bytes, bytes) + .proveUserIdentity() + .then((result) => { + if (result.status !== 'accepted') return + const platform: 'google' = result.identity.platformId + const proof: Uint8Array = result.oauthProof.proof.identityProof + // @ts-expect-error Retained operation inputs are not returned in OAuthProof. + result.oauthProof.transactionData + // @ts-expect-error Identity is not embedded in the platform proof. + result.oauthProof.proof.identity + return { platform, proof } + }) +} + +void checkCreationTypes + +it('preserves opaque failure text and operation context for the application', async () => { + const { connection, ceremony } = setup() + const result = ceremony.proveUserIdentity() + connection.receive({ + type: 'ceremony-failed', + event: 'authorization', + message: 'Invalid OAuth return or provider authorization error.', + }) + await expect(result).rejects.toMatchObject({ + name: 'CeremonyError', + event: 'authorization', + message: 'Invalid OAuth return or provider authorization error.', + }) +}) + +it.each(['google', 'x', 'github'] as const)( + 'projects %s stages without delaying or summing overlapping work [LIBID-BROWSER-007]', + async (platformId) => { + const c = new Connection() + const ceremony = ccdpClientFromConfig({ + ...config, + platforms: { [platformId]: { clientId: 'client', ceremonyVersions: [1] } }, + }).new(c, id, platformId, testnet, new Uint8Array(32), new Uint8Array()) + const stages: string[] = [] + const events: CeremonyEvent[] = [] + ceremony.onStage((e) => { + if (e.status === 'active') stages.push(e.stage) + }) + ceremony.onEvent((e) => events.push(e)) + const result = ceremony.proveUserIdentity() + const emit = (event: string, phase: 'started' | 'finished', timestamp = 10) => + c.receive({ type: 'event', event, phase, timestamp }) + emit('prefetch-dispatch', 'finished') + emit('authorization', 'finished', 20) + emit('prover', 'started', 30) + emit('zk-proof-preparation', 'started', 40) + if (platformId !== 'google') emit('token-fetch', 'started', 50) + emit('zk-proof-generation', 'started', 60) + emit('zk-proof-preparation', 'finished', 70) + emit('zk-proof-generation', 'finished', 80) + expect(events.at(-1)).toMatchObject({ + event: 'zk-proof-generation', + status: 'active', + timestamp: 80, + }) + expect(stages).toEqual([ + 'preparation', + 'authorization', + 'proof-preparation', + ...(platformId === 'google' ? [] : ['notarization']), + 'zk-proving', + ]) + const rejection = expect(result).rejects.toMatchObject({ name: 'CeremonyError' }) + await c.close() + await rejection + expect(events.at(-1)).toMatchObject({ status: 'closed' }) + expect( + events.some( + (e) => 'event' in e && e.event === 'prover' && 'phase' in e && e.phase === 'finished', + ), + ).toBe(false) + }, +) + +it.each(['success', 'denied', 'failed', 'closed', 'invalid-result', 'setup'] as const)( + 'finishes exactly once for %s, before settling the promise [LIBID-BROWSER-008]', + async (outcome) => { + const { ceremony, connection } = setup() + if (outcome === 'setup') connection.handlers.set('event', () => {}) + const events: CeremonyEvent[] = [] + let settled = false + const settledAtFinish: boolean[] = [] + ceremony.onEvent((event) => { + events.push(event) + if (event.status !== 'active') { + settledAtFinish.push(settled) + // Observer reentry must not turn a success into cancellation or emit twice. + void connection.close() + } + }) + const result = ceremony.proveUserIdentity().then( + () => { + settled = true + }, + () => { + settled = true + }, + ) + if (outcome !== 'setup') { + connection.receive({ + type: 'event', + event: 'prefetch-dispatch', + phase: 'finished', + timestamp: 1, + }) + connection.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 3 }) + if (outcome === 'success' || outcome === 'invalid-result') + connection.receive({ + type: 'identity-proof', + identity, + proof: outcome === 'success' ? proof : {}, + }) + else if (outcome === 'denied') connection.receive({ type: 'user-denied' }) + else if (outcome === 'closed') await connection.close() + else + connection.receive({ + type: 'ceremony-failed', + event: 'zk-proof-generation', + message: 'Proof engine failed.', + }) + } + await result + await connection.close() + connection.receive({ type: 'identity-proof', identity, proof }) + await connection.close() + expect(events.filter((e) => e.status !== 'active')).toEqual([ + expect.objectContaining({ + status: ['invalid-result', 'setup'].includes(outcome) + ? 'failed' + : outcome === 'success' + ? 'completed' + : outcome, + }), + ]) + expect(events.at(-1)?.status).not.toBe('active') + expect(settledAtFinish).toEqual([false]) + if (outcome === 'failed') expect(events.at(-1)).toMatchObject({ event: 'zk-proof-generation' }) + }, +) + +it('closure terminates the feed and late messages cannot revive it [TEST-CCDP-08]', async () => { + const { ceremony, connection } = setup() + const events: CeremonyEvent[] = [] + ceremony.onEvent((event) => events.push(event)) + const result = ceremony.proveUserIdentity() + const rejection = expect(result).rejects.toBeInstanceOf(CeremonyError) + await connection.close() + await rejection + const count = events.length + connection.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 2 }) + connection.receive({ type: 'user-denied' }) + connection.receive({ type: 'identity-proof', identity, proof }) + expect(events).toHaveLength(count) + expect(events.at(-1)).toMatchObject({ status: 'closed' }) + expect(connection.send).not.toHaveBeenCalled() +}) + +it('only core readiness events advance the protocol; preserves occurrence times [LIBID-BROWSER-006]', async () => { + const { ceremony, connection: c } = setup() + const events: CeremonyEvent[] = [] + ceremony.onEvent((e) => events.push(e)) + const result = ceremony.proveUserIdentity() + c.receive({ type: 'event', event: 'extension-ready', timestamp: 1 }) + expect(c.navigateAway).not.toHaveBeenCalled() + c.receive({ type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 2 }) + c.receive({ type: 'event', event: 'authorization', phase: 'finished', timestamp: 3 }) + c.receive({ type: 'event', event: 'prover-fallback', timestamp: 4 }) + expect(c.send).not.toHaveBeenCalled() + c.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 7 }) + c.receive({ type: 'user-denied' }) + await expect(result).resolves.toEqual({ status: 'denied' }) + expect(events).toContainEqual({ event: 'prover-fallback', timestamp: 4, status: 'active' }) + expect(events).toContainEqual({ + event: 'prover', + phase: 'started', + timestamp: 7, + status: 'active', + }) + const count = events.length + c.receive({ type: 'event', event: 'late', timestamp: 9 }) + expect(events).toHaveLength(count) +}) + +it('cancellation at authorization entry prevents provider navigation', async () => { + const { ceremony, connection } = setup() + ceremony.onEvent((event) => { + if (event.status === 'active' && event.event === 'authorization' && event.phase === 'started') + void connection.close() + }) + const result = ceremony.proveUserIdentity() + connection.receive({ type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 1 }) + await expect(result).rejects.toMatchObject({ name: 'CeremonyError' }) + expect(connection.navigations).toEqual([]) +}) + +it('readiness without the optional authorization observation still permits denial', async () => { + const { ceremony, connection: c } = setup() + const stages: string[] = [] + ceremony.onStage((e) => stages.push(e.stage)) + const events: CeremonyEvent[] = [] + ceremony.onEvent((e) => events.push(e)) + const result = ceremony.proveUserIdentity() + c.receive({ type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 1 }) + c.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 2 }) + c.receive({ type: 'user-denied' }) + await expect(result).resolves.toEqual({ status: 'denied' }) + expect(events.at(-1)).toMatchObject({ status: 'denied' }) + expect(stages).toContain('proof-preparation') +}) + +it('discovers compatible versions and honors explicit selection [LIBID-MOD-015] [LIBID-MOD-020]', async () => { + // A second catalog entry tests selection only; it is not a new or qualified Google profile. + Reflect.set(platforms.google.versions, '2', platforms.google.versions[1]) + try { + const client = ccdpClientFromConfig( + validateCeremonyConfig( + { + ...wireConfig, + platforms: { + google: { clientId: identity.oauthClientId, ceremonyVersions: [2, 99, 1] }, + x: { clientId: 'client', ceremonyVersions: [99] }, + }, + }, + 'https://bridge.test', + ), + ) + expect(client.enabledPlatforms).toEqual(['google']) + const versions = client.enabledVersions('google') + expect(versions).toEqual([1, 2]) + expect(Object.isFrozen(versions)).toBe(true) + expect(client.enabledVersions('x')).toEqual([]) + expect(client.enabledVersions('github')).toEqual([]) + const onlyNewer = ccdpClientFromConfig({ + ...config, + platforms: { google: { clientId: identity.oauthClientId, ceremonyVersions: [2] } }, + }) + expect(() => + onlyNewer.new( + new Connection(), + id, + 'google', + testnet, + new Uint8Array(32), + new Uint8Array(), + 1, + ), + ).toThrow('Unsupported ceremony version') + for (const selected of [1, undefined] as const) { + const c = new Connection() + const run = client.new( + c, + id, + 'google', + testnet, + new Uint8Array(32), + new Uint8Array(), + selected, + ) + const version = selected ?? 2 + expect(new URLSearchParams(new URL(run.launchUrl).hash.slice(1)).get('ceremonyVersion')).toBe( + String(version), + ) + const pending = run.proveUserIdentity() + c.receive({ type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 1 }) + c.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 3 }) + expect(c.send.mock.calls[0][0].platformCeremonyVersion).toBe(version) + c.receive({ type: 'identity-proof', identity, proof }) + const result = await pending + expect(result).toMatchObject({ oauthProof: { platformCeremonyVersion: version } }) + if (result.status !== 'accepted') throw new Error('Expected proof') + const digest = deriveAuthorizationDigest({ + chainId: testnet.hash(), + operationDomain: new Uint8Array(32), + transactionData: new Uint8Array(), + platformCeremonyVersion: version, + authorizationNonce: result.oauthProof.authorizationNonce, + }) + expect(new URL(c.navigateAway.mock.calls[0][0]).searchParams.get('nonce')).toBe( + b64urlEncode(digest), + ) + } + } finally { + Reflect.deleteProperty(platforms.google.versions, '2') + } +}) + +it('rejects unavailable explicit versions before reading ledger or reserving the run [LIBID-MOD-015]', async () => { + const client = ccdpClientFromConfig(config) + const ledger = { ...testnet, hash: vi.fn(testnet.hash) } + const c = new Connection() + for (const version of [0, 2, 99, -1, 1.5, NaN, null, '1']) { + expect(() => + client.new( + c, + id, + 'google', + ledger, + new Uint8Array(32), + new Uint8Array(), + // @ts-expect-error Reject unsupported versions and malformed runtime input. + version, + ), + ).toThrow('Unsupported ceremony version') + } + expect(ledger.hash).not.toHaveBeenCalled() + expect(c.handlers.size).toBe(0) + client.new(c, id, 'google', ledger, new Uint8Array(32), new Uint8Array(), 1) + await c.close() +}) + +it('a lost optional operation start does not prevent accepted proof delivery [LIBID-BROWSER-008]', async () => { + const { ceremony, connection: c } = setup() + const result = ceremony.proveUserIdentity() + c.receive({ type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 1 }) + c.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 2 }) + c.receive({ type: 'event', event: 'proof', phase: 'finished', timestamp: 4 }) + c.receive({ type: 'identity-proof', identity, proof }) + await expect(result).resolves.toMatchObject({ status: 'accepted' }) +}) + +it('reports closure before the first start without mislabeling it as a repeat [LIBID-BROWSER-013]', async () => { + const { ceremony, connection } = setup() + await connection.close() + await expect(ceremony.proveUserIdentity()).rejects.toMatchObject({ + name: 'CeremonyError', + event: 'prefetch-dispatch', + message: 'Popup connection ended', + }) + await expect(ceremony.proveUserIdentity()).rejects.toThrow('one-shot') + expect(connection.send).not.toHaveBeenCalled() + expect(connection.navigate).not.toHaveBeenCalled() +}) + +it('freezes and forwards the public credential from validated configuration [TEST-BRIDGE-03]', async () => { + const profile = { + clientId: 'client', + ceremonyVersions: [1], + clientCredential: 'public&original=1', + } + const config = validateCeremonyConfig( + { ...wireConfig, platforms: { github: profile } }, + 'https://bridge.test', + ) + const client = ccdpClientFromConfig(config) + profile.clientCredential = 'replacement' + const connection = new Connection() + const ceremony = client.new( + connection, + id, + 'github', + testnet, + new Uint8Array(32), + new Uint8Array(), + ) + const pending = ceremony.proveUserIdentity() + const rejected = expect(pending).rejects.toBeInstanceOf(CeremonyError) + connection.receive({ type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 1 }) + connection.receive({ type: 'event', event: 'prover', phase: 'started', timestamp: 2 }) + expect(connection.send).toHaveBeenCalledWith( + expect.objectContaining({ clientCredential: 'public&original=1' }), + ) + expect(Object.isFrozen(config.platforms.github)).toBe(true) + await connection.close() + await rejected +}) + +it('requires the GitHub public credential and validates optional credentials for other profiles [TEST-BRIDGE-03]', () => { + for (const platformId of ['github', 'x', 'google']) { + const profile = { clientId: 'client', ceremonyVersions: [1] } + const validate = (value: object) => + validateCeremonyConfig( + { ...wireConfig, platforms: { [platformId]: value } }, + 'https://bridge.test', + ) + if (platformId === 'github') expect(() => validate(profile)).toThrow() + else expect(() => validate(profile)).not.toThrow() + expect(() => validate({ ...profile, clientCredential: 'public' })).not.toThrow() + expect(() => + validate({ ...profile, clientCredential: 'public', tokenExchangeCredential: 'retired' }), + ).toThrow() + for (const clientCredential of [undefined, null, '', 1, 'with space', 'tail\n', 'é']) + expect(() => validate({ ...profile, clientCredential })).toThrow() + } +}) + +it.each(['closed', 'failed'] as const)( + 'preserves popup %s in errors and both terminal subscriptions', + async (outcome) => { + const { connection, ceremony } = setup() + const events = vi.fn(), + stages = vi.fn() + ceremony.onEvent(events) + ceremony.onStage(stages) + const pending = ceremony.proveUserIdentity() + const rejection = expect(pending).rejects.toMatchObject({ + name: 'CeremonyError', + status: outcome, + event: 'prefetch-dispatch', + ...(outcome === 'failed' ? { cause: { name: 'PopupError', code: 'decode-rejected' } } : {}), + }) + connection.end(outcome === 'closed' ? { outcome } : { outcome, code: 'decode-rejected' }) + await rejection + expect(events).toHaveBeenLastCalledWith(expect.objectContaining({ status: outcome })) + expect(stages).toHaveBeenLastCalledWith(expect.objectContaining({ status: outcome })) + expect(connection.send).not.toHaveBeenCalled() + }, +) + +it('preserves closure before proving starts', async () => { + const { connection, ceremony } = setup() + await connection.close() + await expect(ceremony.proveUserIdentity()).rejects.toMatchObject({ status: 'closed' }) +}) diff --git a/ts/packages/ceremony/src/ccdp/client/ceremony.ts b/ts/packages/ceremony/src/ccdp/client/ceremony.ts new file mode 100644 index 00000000..1fd298c0 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/client/ceremony.ts @@ -0,0 +1,453 @@ +import type { LedgerId } from '@libid/ledger' +import { type Message, type MessageType, type PopupConnection, PopupError } from '@libid/popup' +import { CeremonyError, ceremonyError } from '../../errors.js' +import { + deriveAuthorizationDigest, + deriveCodeChallenge, + deriveCodeVerifier, +} from '../../platforms/authorization.js' +import { + assembleResult, + commonVersions, + type IdentityResult, + implementationFor, + type PlatformId, + type SupportedCeremonyVersion, + supportedPlatforms, +} from '../../platforms/index.js' +import { hasExactKeys, isRecord, origin } from '../../primitives.js' +import { + CeremonyFailed, + Event as EventMessage, + IdentityProof, + type ProveIdentity, + UserDenied, + UUID, +} from '../index.js' +import { oauthState, prefetchFragment, route } from '../navigation.js' +import { messages } from '../ui-messages.js' +import { type CeremonyConfig, fetchCeremonyConfig } from './config.js' + +export type { CeremonyEvent, CeremonyStage, StageEvent } from '../../events.js' + +import { + type CeremonyEvent, + type CoreEvent, + coreEvents, + Events, + now, + type OperationEvent, + type StageEvent, +} from '../../events.js' + +/** One ceremony over a caller-supplied connection; the application owns the window. */ +export interface Ceremony

{ + /** Initial CCDP Prefetch URL, including the private ceremony navigation fragment. */ + readonly launchUrl: string + /** Subscribe to advisory events. Returns an unsubscribe function; listener exceptions do not fail the run. */ + onEvent(listener: (event: CeremonyEvent) => void): () => void + /** Subscribe to the sequential UI projection, including terminal status and error text. */ + onStage(listener: (event: StageEvent) => void): () => void + /** Start once; resolve accepted/denied output, or reject connection loss and technical failures. */ + proveUserIdentity(): Promise> +} + +interface Input

{ + connection: PopupConnection + notaryAddress: string + chainId: Uint8Array + platformId: P + version: SupportedCeremonyVersion

+ operationDomain: Uint8Array + transactionData: Uint8Array +} + +/** Application-scoped Bridge configuration used to construct independent ceremony runs. */ +export interface CCDPClient { + /** Intersection of supported platforms and versions advertised by the Bridge. */ + readonly enabledPlatforms: readonly PlatformId[] + /** Compatible versions in ascending order; returns an immutable list, empty for disabled platforms. */ + enabledVersions

(platformId: P): readonly SupportedCeremonyVersion

[] + /** + * Snapshot ledger hash/address and input bytes before OAuth; invalid inputs throw synchronously. + * Use the supplied connection's UUID as ceremonyId and one connection per live run. + * Omitted version selects the highest compatible version, not a disclosure preference. + */ + new:

( + conn: PopupConnection, + ceremonyId: string, + platformId: P, + ledgerId: LedgerId, + operationDomain: Uint8Array, + transactionData: Uint8Array, + ceremonyVersion?: SupportedCeremonyVersion

, + ) => Ceremony

+} + +/** Fetch and validate Bridge configuration once. Rejects unavailable or malformed configuration. */ +export async function createCCDPClient(options: { oauthBridge: string }): Promise { + if (!isRecord(options) || !hasExactKeys(options, ['oauthBridge'])) + throw new TypeError('Invalid client options') + return ccdpClientFromConfig(await fetchCeremonyConfig(options.oauthBridge)) +} + +/** Internal construction from an already validated, frozen Bridge configuration. */ +export function ccdpClientFromConfig(config: CeremonyConfig): CCDPClient { + const liveIds = new Set() + const enabledVersions =

(platform: P) => + commonVersions(platform, config.platforms[platform]?.ceremonyVersions ?? []) + const enabledPlatforms = Object.freeze( + supportedPlatforms.filter((p) => enabledVersions(p).length > 0), + ) + return Object.freeze({ + enabledPlatforms, + enabledVersions, + new

( + conn: PopupConnection, + id: string, + platformId: P, + ledgerId: LedgerId, + operationDomain: Uint8Array, + transactionData: Uint8Array, + ceremonyVersion?: SupportedCeremonyVersion

, + ): Ceremony

{ + if (typeof id !== 'string' || !UUID.test(id) || !enabledPlatforms.includes(platformId)) + throw new TypeError('Invalid ceremony selection') + const available = enabledVersions(platformId) + const version = + ceremonyVersion === undefined ? available[available.length - 1] : ceremonyVersion + if (!available.includes(version)) throw new TypeError('Unsupported ceremony version') + if (!ledgerId || typeof ledgerId.hash !== 'function') + throw new TypeError('Invalid ledger identity') + const hash = ledgerId.hash() + if (!(hash instanceof Uint8Array) || hash.length !== 32) + throw new TypeError('Ledger hash must be 32 bytes') + const chainId = Uint8Array.from(hash) + if (typeof ledgerId.notaryAddress !== 'function') + throw new TypeError('Missing notary address') + const notaryAddress = ledgerId.notaryAddress() + if (!origin(notaryAddress)) throw new TypeError('Invalid notary origin') + if (!(operationDomain instanceof Uint8Array) || operationDomain.length !== 32) + throw new TypeError('Operation domain must be 32 bytes') + if (!(transactionData instanceof Uint8Array) || transactionData.length > 0xffffffff) + throw new TypeError('Invalid transaction bytes') + if (liveIds.has(id)) throw new TypeError('Ceremony ID is already live') + const run = new Run( + id, + { + connection: conn, + platformId, + version, + operationDomain, + transactionData, + chainId, + notaryAddress, + }, + config, + () => { + liveIds.delete(id) + }, + ) + liveIds.add(id) + return run + }, + }) +} + +type Binding = { active: boolean; remove: (() => void)[] } + +const bindings = new WeakMap, Binding>() + +// Keep decoding late CCDP traffic without retaining the completed run's inputs. +function receiver(handler: ((message: M) => void) | undefined) { + return { + receive(message: M) { + handler?.(message) + }, + clear() { + handler = undefined + }, + } +} + +class Run

implements Ceremony

{ + readonly launchUrl: string + private state: 'new' | 'prefetch' | 'oauth' | 'proving' | 'done' = 'new' + private readonly events = new Events() + private readonly observations = new Set() + private proofWorkStarted = false + private readonly off: (() => void)[] = [] + private readonly connection: PopupConnection + private readonly platform: P + private readonly version: SupportedCeremonyVersion

+ private readonly retained: { + operationDomain: Uint8Array + authorizationNonce: Uint8Array + transactionData: Uint8Array + } + private readonly start: ProveIdentity + private authorizationUrl: string + private readonly prefetchUrl: string + private readonly fragment: URLSearchParams + private resolve: ((value: IdentityResult

) => void) | undefined + private reject: ((reason: Error) => void) | undefined + private binding: Binding | undefined + private startFailure: CeremonyError | undefined + + constructor( + id: string, + input: Input

, + config: CeremonyConfig, + private readonly releaseId: () => void, + ) { + this.connection = input.connection + this.platform = input.platformId + const platform = config.platforms[this.platform] + this.version = input.version + this.retained = { + operationDomain: Uint8Array.from(input.operationDomain), + authorizationNonce: crypto.getRandomValues(new Uint8Array(32)), + transactionData: Uint8Array.from(input.transactionData), + } + const digest = deriveAuthorizationDigest({ + ...this.retained, + chainId: input.chainId, + platformCeremonyVersion: this.version, + }) + const implementation = implementationFor(this.platform, this.version) + const codeVerifier = implementation.pkce + ? deriveCodeVerifier(digest, this.retained.authorizationNonce) + : null + this.authorizationUrl = implementation.buildAuthorizationUrl({ + clientId: platform.clientId, + redirectUri: config.redirectUri, + state: oauthState(id), + authorizationDigest: digest, + codeChallenge: codeVerifier === null ? null : deriveCodeChallenge(codeVerifier), + }) + this.start = { + type: 'prove-identity', + platformId: this.platform, + platformCeremonyVersion: this.version, + clientId: platform.clientId, + redirectUri: config.redirectUri, + codeVerifier, + notaryAddress: input.notaryAddress, + ...(platform.clientCredential === undefined + ? {} + : { clientCredential: platform.clientCredential }), + } + this.prefetchUrl = config.ccdpOrigin + route('prefetch') + this.fragment = prefetchFragment(id, this.platform, this.version) + this.launchUrl = `${this.prefetchUrl}#${this.fragment}` + Object.defineProperty(this, 'launchUrl', { writable: false }) + void this.connection.closed.then((end) => { + this.fail( + end.outcome === 'closed' ? new Error(messages.connectionEnded) : new PopupError(end.code), + end.outcome, + ) + }) + } + + onEvent(listener: (event: CeremonyEvent) => void): () => void { + return this.state === 'done' ? () => {} : this.events.onEvent(listener) + } + + onStage(listener: (event: StageEvent) => void): () => void { + return this.state === 'done' ? () => {} : this.events.onStage(listener) + } + + private publish(event: OperationEvent): void { + this.events.emit({ ...event, status: 'active' }) + } + + private finish(event: Exclude): void { + this.cleanup() + this.events.emit(event) + } + + private receiveEvent(message: EventMessage): void { + const { type: _type, ...event } = message + const core = coreEvents.includes(event.event as CoreEvent) + if (event.event === 'prefetch-dispatch') { + this.expect('prefetch') + if (event.phase !== 'finished') throw new Error('Invalid prefetch readiness') + this.state = 'oauth' + const url = this.authorizationUrl + this.authorizationUrl = '' + this.publish(event) + if (this.state !== 'oauth') return + this.publish({ event: 'authorization', phase: 'started', timestamp: now() }) + if (this.state === 'oauth') + void this.connection + .navigateAway(url) + .catch((error) => this.fail(ceremonyError(error, 'authorization'))) + return + } + if (event.event === 'prover') { + this.expect('oauth') + if (event.phase !== 'started') throw new Error('Invalid prover readiness') + this.state = 'proving' + // Readiness processing precedes observers; no subscription is needed to start proving. + this.connection.send({ ...this.start }) + this.publish(event) + return + } + if (event.event === 'authorization' || event.event === 'prover-fallback') { + this.expect('oauth') + if (event.event === 'authorization' && event.phase !== 'finished') + throw new Error('Invalid authorization observation') + } else if (core) { + this.expect('proving') + if (!implementationFor(this.platform, this.version).events.includes(event.event as CoreEvent)) + throw new Error('Event does not apply to platform') + this.proofWorkStarted = true + } + if (core) { + const key = `${event.event}/${event.phase ?? ''}` + if (this.observations.has(key)) throw new Error('Duplicate core occurrence') + if ( + event.phase === 'finished' && + event.event !== 'authorization' && + !this.observations.has(`${event.event}/started`) + ) + throw new Error('Core finish precedes start') + this.observations.add(key) + } + this.publish(event) + } + + private listen(type: MessageType, handler: (message: M) => void): void { + const listener = receiver((m: M) => { + if (this.state === 'done') return + try { + handler(m) + } catch (error) { + this.fail( + new Error( + `Invalid ceremony sequence: ${error instanceof Error ? error.message : 'unexpected message'}`, + ), + ) + } + }) + this.binding!.remove.push(this.connection.on(type, listener.receive)) + this.off.push(listener.clear) + } + + private expect(state: typeof this.state): void { + if (this.state !== state) throw new Error('Unexpected ceremony message') + } + + proveUserIdentity(): Promise> { + if (this.startFailure) { + const failure = this.startFailure + this.startFailure = undefined + return Promise.reject(failure) + } + if (this.state !== 'new') return Promise.reject(new Error('Ceremony is one-shot')) + const previous = bindings.get(this.connection) + if (previous?.active) { + const error = new CeremonyError( + 'prefetch-dispatch', + 'Connection already has an active ceremony', + ) + this.fail(error) + return Promise.reject(error) + } + for (const remove of previous?.remove ?? []) remove() + const binding = { active: true, remove: [] as (() => void)[] } + bindings.set(this.connection, binding) + this.binding = binding + void this.connection.closed.then(() => { + for (const remove of binding.remove) remove() + if (bindings.get(this.connection) === binding) bindings.delete(this.connection) + }) + this.state = 'prefetch' + const result = new Promise>((resolve, reject) => { + this.resolve = resolve + this.reject = reject + }) + try { + this.listen(EventMessage, (event) => this.receiveEvent(event)) + this.listen(IdentityProof, (m) => { + this.expect('proving') + const result = assembleResult( + this.platform, + this.version, + m, + this.start.clientId, + this.retained.authorizationNonce, + ) + const resolve = this.resolve + this.finish({ + event: 'prover', + phase: 'finished', + status: 'completed', + timestamp: now(), + }) + resolve?.(result) + }) + this.listen(UserDenied, () => { + this.expect('proving') + if (this.proofWorkStarted) throw new Error('Denial after proof work began') + const resolve = this.resolve + this.finish({ + status: 'denied', + timestamp: now(), + }) + resolve?.({ status: 'denied' }) + }) + this.listen(CeremonyFailed, (message) => + this.fail(new CeremonyError(message.event, message.message)), + ) + this.publish({ event: 'prefetch-dispatch', phase: 'started', timestamp: now() }) + if (this.state === 'prefetch') + void this.connection + .navigate(this.prefetchUrl, this.fragment) + .catch(() => this.fail(new Error('Prefetch navigation failed'))) + } catch { + for (const remove of binding.remove.splice(0)) remove() + if (bindings.get(this.connection) === binding) bindings.delete(this.connection) + this.fail(new Error(messages.connectionInitializationFailed)) + } + return result + } + + private fail(error: Error, status: 'failed' | 'closed' = 'failed'): void { + if (this.state === 'done') return + const reject = this.reject + const event = + this.state === 'prefetch' || this.state === 'new' + ? 'prefetch-dispatch' + : this.state === 'oauth' + ? 'authorization' + : 'prover' + const failure = + status === 'closed' + ? new CeremonyError(event, error.message, { cause: error, status }) + : ceremonyError(error, event) + if (this.state === 'new') this.startFailure = failure + this.finish({ + status: failure.status, + event: failure.event, + message: failure.message, + timestamp: now(), + }) + reject?.(failure) + } + + private cleanup(): void { + if (this.binding) this.binding.active = false + this.state = 'done' + this.releaseId() + for (const off of this.off.splice(0)) off() + this.observations.clear() + this.start.codeVerifier = null + this.authorizationUrl = '' + this.retained.authorizationNonce.fill(0) + this.retained.operationDomain.fill(0) + this.retained.transactionData.fill(0) + this.resolve = undefined + this.reject = undefined + } +} diff --git a/ts/packages/ceremony/src/ccdp/client/config.ts b/ts/packages/ceremony/src/ccdp/client/config.ts new file mode 100644 index 00000000..66c35f8e --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/client/config.ts @@ -0,0 +1,75 @@ +import { platforms as catalog, type PlatformId, supportedPlatforms } from '../../platforms/index.js' +import { hasExactKeys, isRecord, origin, uint } from '../../primitives.js' +import { isClientCredential } from '../index.js' + +export interface PlatformConfig { + clientId: string + ceremonyVersions: readonly number[] + clientCredential?: string +} + +/** Validated Bridge configuration with its registered redirect URI resolved once. */ +export interface CeremonyConfig { + redirectUri: string + ccdpOrigin: string + platforms: Readonly> +} + +export const CONFIG_PATH = '/api/v1/ceremony/config' + +/** Validate public configuration and derive the fixed callback URL from the supplied Bridge origin. */ +export function validateCeremonyConfig(v: unknown, bridge: string): CeremonyConfig { + if ( + !origin(bridge) || + !isRecord(v) || + !hasExactKeys(v, ['ccdpOrigin', 'platforms']) || + !origin(v.ccdpOrigin) || + !isRecord(v.platforms) + ) + throw new TypeError('Invalid Ceremony configuration') + const platforms: Record = Object.create(null) + for (const [key, p] of Object.entries(v.platforms)) { + if (!supportedPlatforms.includes(key as PlatformId)) continue + if ( + !isRecord(p) || + !hasExactKeys(p, [ + 'clientId', + 'ceremonyVersions', + ...(Object.hasOwn(p, 'clientCredential') ? ['clientCredential'] : []), + ]) || + ((catalog[key as PlatformId].requiresClientCredential || + Object.hasOwn(p, 'clientCredential')) && + !isClientCredential(p.clientCredential)) || + !catalog[key as PlatformId].isClientId(p.clientId) || + !Array.isArray(p.ceremonyVersions) || + !p.ceremonyVersions.length || + p.ceremonyVersions.some((n) => !uint(n, 65535)) || + new Set(p.ceremonyVersions).size !== p.ceremonyVersions.length + ) + throw new TypeError('Invalid platform configuration') + platforms[key] = Object.freeze({ + clientId: p.clientId, + ceremonyVersions: Object.freeze([...p.ceremonyVersions]), + ...(typeof p.clientCredential === 'string' ? { clientCredential: p.clientCredential } : {}), + }) + } + return Object.freeze({ + redirectUri: new URL('/auth/callback', bridge).href, + ccdpOrigin: v.ccdpOrigin, + platforms: Object.freeze(platforms), + }) +} + +/** Fetch current configuration without cookies, redirects or persistent browser caching. */ +export async function fetchCeremonyConfig(bridge: string): Promise { + if (!origin(bridge)) + throw new TypeError('oauthBridge must be a canonical HTTPS or localhost HTTP origin') + const response = await fetch(`${bridge}${CONFIG_PATH}`, { + mode: 'cors', + credentials: 'omit', + cache: 'no-store', + redirect: 'error', + }) + if (!response.ok) throw new Error('Configuration request failed') + return validateCeremonyConfig(await response.json(), bridge) +} diff --git a/ts/packages/ceremony/src/ccdp/client/index.ts b/ts/packages/ceremony/src/ccdp/client/index.ts new file mode 100644 index 00000000..ea0707c1 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/client/index.ts @@ -0,0 +1,15 @@ +export { + CeremonyStage, + type CeremonyStatus, + type OperationEvent, + type StageEvent, +} from '../../events.js' + +export * from '../../index.js' + +export { + type CCDPClient, + type Ceremony, + type CeremonyEvent, + createCCDPClient, +} from './ceremony.js' diff --git a/ts/packages/ceremony/src/ccdp/documents/callback.test.ts b/ts/packages/ceremony/src/ccdp/documents/callback.test.ts new file mode 100644 index 00000000..4f245415 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/documents/callback.test.ts @@ -0,0 +1,264 @@ +import { type ConnectionEnd, PopupError } from '@libid/popup' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { Events } from '../../events.js' +import { popupErrorMessages } from '../ui-messages.js' +import { startCallback } from './callback.js' + +const { accept, current, view, navigate, send, terminal } = vi.hoisted(() => ({ + accept: vi.fn(), + current: vi.fn(), + view: vi.fn(), + navigate: vi.fn(), + send: vi.fn(), + terminal: vi.fn(), +})) + +vi.mock('virtual:ceremony-popup-fallback', () => ({ fallback: undefined })) + +vi.mock('@libid/popup', async (original) => ({ + ...(await original()), + PopupConnection: { accept }, + PopupWindow: { current }, +})) + +vi.mock('./ui.js', () => ({ + view, + eventView: (events: Events) => { + events.onEvent(terminal) + return { stop: vi.fn(), message: vi.fn() } + }, +})) + +const id = '6e171568-54e1-4f0d-aeb5-e8859826476a' + +const v1Inputs = [['https://app.test', 'https://ccdp.test'], 'https://ccdp.test'] + +let peerOrigin: string | null, + config: unknown, + locationInput: { search: string; hash: string; pathname: string; origin: string } + +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + peerOrigin = 'https://app.test' + config = v1Inputs + locationInput = { + search: '', + hash: `#state=v1.${id}&error=access_denied`, + pathname: '/callback', + origin: 'https://bridge.test', + } + vi.stubGlobal('location', locationInput) + vi.stubGlobal('history', { + replaceState: vi.fn(() => { + locationInput.search = '' + locationInput.hash = '' + }), + }) + vi.stubGlobal('document', { getElementById: () => ({ textContent: JSON.stringify(config) }) }) + view.mockImplementation(() => { + expect(locationInput.search + locationInput.hash).toBe('') + }) + accept.mockImplementation(() => { + expect(locationInput.search + locationInput.hash).toBe('') + return { + peerOrigin, + ready: Promise.resolve(), + closed: new Promise(() => {}), + on: vi.fn(), + navigate, + send, + } + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +it('clears before acceptance and preserves exact private return with shared deployment inputs [KIT-006] [KIT-010]', async () => { + const original = locationInput.hash + peerOrigin = 'https://other-app.test' + config = [['https://other-app.test', 'https://other-ccdp.test'], 'https://other-ccdp.test'] + startCallback() + await Promise.resolve() + expect(accept).toHaveBeenCalledWith(undefined, { + fallback: undefined, + connectionId: id, + allowedApplicationOrigins: ['https://other-app.test', 'https://other-ccdp.test'], + }) + expect(navigate).toHaveBeenCalledWith( + 'https://other-ccdp.test/ccdp/v1/prover', + new URLSearchParams({ + ceremonyId: id, + applicationOrigin: 'https://other-app.test', + oauthQuery: '', + oauthFragment: original, + }), + ) + expect(send).toHaveBeenCalledExactlyOnceWith({ + type: 'event', + event: 'authorization', + phase: 'finished', + timestamp: expect.any(Number), + }) + expect(send.mock.invocationCallOrder[0]).toBeLessThan(navigate.mock.invocationCallOrder[0]) +}) + +it.each(['2', '99', '99999999999999999999'])( + 'rejects unbundled/retired version %s locally [LIBID-ASSET-015]', + (version) => { + locationInput.hash = `#state=v${version}.${id}` + startCallback() + expect(view).toHaveBeenCalledWith(expect.stringContaining('no longer supported')) + expect(accept).not.toHaveBeenCalled() + expect(current).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + }, +) + +it.each([ + { search: `?state=v1.${id}`, hash: `#state=v1.${id}` }, + { hash: `#state=v01.${id}` }, + { hash: `#state=v0.${id}` }, + { hash: `#state=v1.${id.toUpperCase()}` }, + { hash: '#state=v1.invalid' }, + { hash: `#state=v1.${id}%FF` }, + { hash: '#code=x' }, + { hash: `#${'x'.repeat(32768)}` }, +])('clears malformed or oversized return before fixed local failure [KIT-010]', (input) => { + Object.assign(locationInput, input) + startCallback() + expect(view).toHaveBeenCalledWith(expect.stringMatching(/Return to your application/)) + expect(accept).not.toHaveBeenCalled() +}) + +it.each( + [ + null, + {}, + { versionedInputs: { 1: v1Inputs } }, + [], + [['https://app.test']], + [[], 'https://ccdp.test'], + [['https://app.test'], 'https://ccdp.test'], // Missing effective CCDP admission. + [['https://app.test', 'https://app.test'], 'https://ccdp.test'], + [['https://app.test/path'], 'https://ccdp.test'], + [['https://app.test'], 'https://ccdp.test/path'], + ['https://app.test', 'https://ccdp.test'], + [[null], 'https://ccdp.test'], + [['https://app.test'], null], + ].map((input) => ({ input })), +)('rejects malformed deployment data before connection setup [KIT-010]', ({ input }) => { + config = input + startCallback() + expect(view).toHaveBeenCalledWith(expect.stringMatching(/Return to your application/)) + expect(accept).not.toHaveBeenCalled() +}) + +it('clears a double-slash callback path without treating it as another host [CSP-005]', () => { + locationInput.pathname = '//auth/callback' + startCallback() + expect(history.replaceState).toHaveBeenCalledWith(null, '', 'https://bridge.test//auth/callback') + expect(accept).toHaveBeenCalledOnce() +}) + +it.each([[], [null], [{ optional: { nested: [1, 2] } }]].map((trailing) => ({ trailing })))( + 'ignores optional trailing inputs and deeply freezes the parsed list [LIBID-ASSET-015] [KIT-010]', + async ({ trailing }) => { + config = [...v1Inputs, ...trailing] + const parse = vi.spyOn(JSON, 'parse') + try { + startCallback() + await Promise.resolve() + const inputs = parse.mock.results[0].value + expect(Object.isFrozen(inputs)).toBe(true) + expect(Object.isFrozen(inputs[0])).toBe(true) + if (inputs[2]?.optional) { + expect(Object.isFrozen(inputs[2].optional.nested)).toBe(true) + expect(() => inputs[2].optional.nested.push(3)).toThrow() + } + expect(accept).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + allowedApplicationOrigins: ['https://app.test', 'https://ccdp.test'], + }), + ) + expect(navigate).toHaveBeenCalledWith( + 'https://ccdp.test/ccdp/v1/prover', + expect.any(URLSearchParams), + ) + } finally { + parse.mockRestore() + } + }, +) + +it('does not prevent private navigation when the advisory readiness send fails', async () => { + send.mockImplementationOnce(() => { + throw new Error('transport send failure') + }) + startCallback() + await Promise.resolve() + expect(navigate).toHaveBeenCalledOnce() +}) + +it('takes the selected peer from authentication, never from OAuth fields or allowlist order [TEST-CCDP-04]', async () => { + config = [ + ['https://other-app.test', 'https://app.test', 'https://ccdp.test'], + 'https://ccdp.test', + ] + locationInput.hash += '&applicationOrigin=https%3A%2F%2Fother-app.test' + startCallback() + await Promise.resolve() + const fragment = navigate.mock.calls[0][1] as URLSearchParams + expect(fragment.get('applicationOrigin')).toBe('https://app.test') + expect(fragment.get('oauthFragment')).toContain('applicationOrigin=') +}) + +it.each([null, 'null', 'https://app.test/'])( + 'fails locally when the authenticated peer origin is unavailable or invalid: %s [TEST-CCDP-04]', + async (value) => { + peerOrigin = value + startCallback() + await vi.waitFor(() => expect(console.error).toHaveBeenCalled()) + expect(navigate).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + }, +) + +it.each(['ready-first', 'closed-first'])( + 'keeps the connection failure visible locally when Application is unreachable: %s [TEST-CCDP-08]', + async (order) => { + const error = new PopupError('fallback-unavailable') + let rejectReady!: (error: Error) => void + let close!: (end: ConnectionEnd) => void + accept.mockReturnValueOnce({ + peerOrigin: null, + ready: new Promise((_, reject) => { + rejectReady = reject + }), + closed: new Promise((resolve) => { + close = resolve + }), + navigate, + send, + }) + startCallback() + if (order === 'ready-first') rejectReady(error) + close({ outcome: 'failed', code: error.code }) + if (order === 'closed-first') rejectReady(error) + await vi.waitFor(() => + expect(terminal).toHaveBeenCalledWith({ + status: 'failed', + event: 'authorization', + message: popupErrorMessages[error.code], + timestamp: expect.any(Number), + }), + ) + expect(navigate).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + expect(console.error).toHaveBeenCalledExactlyOnceWith('[ceremony] failure report unavailable') + }, +) diff --git a/ts/packages/ceremony/src/ccdp/documents/callback.ts b/ts/packages/ceremony/src/ccdp/documents/callback.ts new file mode 100644 index 00000000..b4e19257 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/documents/callback.ts @@ -0,0 +1,113 @@ +import { fallback } from 'virtual:ceremony-popup-fallback' +import { type Message, PopupConnection, PopupError, PopupWindow } from '@libid/popup' +import { ceremonyError, reportFailure } from '../../errors.js' +import { Events, now } from '../../events.js' +import { origin } from '../../primitives.js' +import { UUID } from '../index.js' +import { type OAuthReturn, proverFragment, route } from '../navigation.js' +import { messages } from '../ui-messages.js' +import { eventView, view } from './ui.js' + +/** The complete Callback artifact owns clearing and dispatch; the Bridge inserts data only. */ +export function startCallback(): void { + try { + const oversized = location.search.length + location.hash.length > 32768 + const input = oversized + ? undefined + : Object.freeze({ query: location.search, fragment: location.hash }) + history.replaceState(null, '', location.origin + location.pathname) + if (!input) throw new TypeError(messages.oauthReturnTooLarge) + const states = [ + ...new URLSearchParams(input.query).getAll('state'), + ...new URLSearchParams(input.fragment.slice(1)).getAll('state'), + ] + const state = states.length === 1 ? /^v([1-9][0-9]*)\.(.+)$/.exec(states[0]) : null + if (!state || !UUID.test(state[2])) throw new TypeError(messages.invalidOAuthState) + // This closed dispatch retains only implementations supported by this artifact. + if (state[1] !== '1') { + view(messages.unsupportedVersion) + return + } + const inputs: unknown = JSON.parse( + document.getElementById('libid-callback-config')?.textContent ?? '', + (_key, value) => (value && typeof value === 'object' ? Object.freeze(value) : value), + ) + if (!Array.isArray(inputs)) throw new TypeError(messages.invalidCallbackInputs) + callbackV1(input, state[2], inputs) + } catch (error) { + const failure = ceremonyError(error, 'authorization') + view(messages.returnToApplication(failure.message)) + reportFailure(undefined, failure) + } +} + +function callbackV1(input: OAuthReturn, id: string, inputs: readonly unknown[]): void { + const [allowedApplicationOrigins, ccdpOrigin] = inputs + if ( + !Array.isArray(allowedApplicationOrigins) || + !allowedApplicationOrigins.length || + new Set(allowedApplicationOrigins).size !== allowedApplicationOrigins.length || + allowedApplicationOrigins.some((o) => !origin(o)) || + !origin(ccdpOrigin) || + !allowedApplicationOrigins.includes(ccdpOrigin) + ) + throw new TypeError(messages.invalidCallbackInputs) + let connection: PopupConnection | undefined, + ended = false, + retained: OAuthReturn | undefined + const cleanup = () => { + ended = true + retained = undefined + } + const events = new Events() + const ui = eventView(events, '') + const fail = (error?: unknown) => { + if (ended) return + const failure = ceremonyError(error, 'authorization') + cleanup() + events.emit({ + status: 'failed', + event: failure.event, + message: failure.message, + timestamp: now(), + }) + ui.stop() + reportFailure(origin(connection?.peerOrigin) ? connection : undefined, failure) + } + try { + retained = input + ui.message(messages.returning) + connection = PopupConnection.accept(PopupWindow.current(), { + fallback, + connectionId: id, + allowedApplicationOrigins: [...allowedApplicationOrigins], + }) + + void connection.closed.then((end) => { + if (!ended) + fail( + end.outcome === 'failed' ? new PopupError(end.code) : new Error(messages.callbackClosed), + ) + }) + void connection.ready + .then(async () => { + if (ended || !retained) return + const applicationOrigin = connection!.peerOrigin + if (!origin(applicationOrigin)) throw new TypeError(messages.missingApplicationOrigin) + const event = { event: 'authorization', phase: 'finished', timestamp: now() } as const + try { + connection!.send({ type: 'event', ...event }) + } catch { + /* A lost observation does not gate navigation. */ + } + events.emit({ ...event, status: 'active' }) + const fragment = proverFragment(id, applicationOrigin, retained) + await connection!.navigate(ccdpOrigin + route('prover'), fragment) + cleanup() + ui.stop() + }) + .catch(fail) + } catch (error) { + fail(error) + } +} diff --git a/ts/packages/ceremony/src/ccdp/documents/prefetch.test.ts b/ts/packages/ceremony/src/ccdp/documents/prefetch.test.ts new file mode 100644 index 00000000..525ee8b5 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/documents/prefetch.test.ts @@ -0,0 +1,87 @@ +import { afterAll, afterEach, expect, it, vi } from 'vitest' +import { startPrefetch } from './prefetch.js' + +vi.hoisted(() => vi.stubGlobal('document', {})) +afterAll(() => vi.unstubAllGlobals()) +const { connection, rootWorker, dispatchPrefetch } = vi.hoisted(() => ({ + connection: { ready: Promise.resolve(), send: vi.fn() }, + rootWorker: vi.fn(), + dispatchPrefetch: vi.fn(), +})) +vi.mock('virtual:ceremony-assets', () => ({ requestsByProfile: { 'google/1': [] } })) +vi.mock('virtual:ceremony-popup-fallback', () => ({ fallback: undefined })) +vi.mock('@libid/popup', async (original) => ({ + ...(await original()), + PopupConnection: { accept: () => connection }, + PopupWindow: { current: vi.fn() }, +})) +vi.mock('../../assets/registration.js', () => ({ rootWorker, dispatchPrefetch })) +vi.mock('../../assets/worker.js', () => ({ startWorker: vi.fn() })) +vi.mock('./ui.js', () => ({ eventView: () => ({ stop: vi.fn() }) })) +const fragment = new URLSearchParams({ + ceremonyId: '6e171568-54e1-4f0d-aeb5-e8859826476a', + platformId: 'google', + ceremonyVersion: '1', +}).toString() +afterEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() +}) +it('permits OAuth only after authenticated worker dispatch [CSP-013]', async () => { + let elapsed = 25 + vi.spyOn(performance, 'now').mockImplementation(() => elapsed) + let ready!: () => void, activated!: () => void, dispatched!: () => void + connection.ready = new Promise((resolve) => { + ready = resolve + }) + rootWorker.mockReturnValueOnce( + new Promise((resolve) => { + activated = resolve + }), + ) + dispatchPrefetch.mockReturnValueOnce( + new Promise((resolve) => { + dispatched = resolve + }), + ) + const run = startPrefetch(fragment) + expect(connection.send).not.toHaveBeenCalled() + expect(rootWorker).not.toHaveBeenCalled() + elapsed = 2025 + ready() + await vi.waitFor(() => expect(rootWorker).toHaveBeenCalledOnce()) + expect(dispatchPrefetch).not.toHaveBeenCalled() + elapsed = 2100 + activated() + await vi.waitFor(() => expect(dispatchPrefetch).toHaveBeenCalledOnce()) + expect(connection.send).not.toHaveBeenCalled() + elapsed = 2130 + dispatched() + await run + expect(connection.send).toHaveBeenCalledExactlyOnceWith({ + type: 'event', + event: 'prefetch-dispatch', + phase: 'finished', + timestamp: performance.timeOrigin + 2130, + instrumentation: { + attributes: { + 'document-startup-ms': 25, + 'connection-ms': 2000, + 'worker-ready-ms': 75, + 'dispatch-ms': 30, + }, + }, + }) +}) +it('a failed mandatory readiness send reports failure instead of silently continuing', async () => { + connection.send.mockImplementationOnce(() => { + throw new Error('send failed') + }) + await startPrefetch(fragment) + expect(dispatchPrefetch).toHaveBeenCalledOnce() + expect(connection.send).toHaveBeenLastCalledWith({ + type: 'ceremony-failed', + event: 'prefetch-dispatch', + message: 'send failed', + }) +}) diff --git a/ts/packages/ceremony/src/ccdp/documents/prefetch.ts b/ts/packages/ceremony/src/ccdp/documents/prefetch.ts new file mode 100644 index 00000000..60f25fcd --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/documents/prefetch.ts @@ -0,0 +1,69 @@ +import { requestsByProfile } from 'virtual:ceremony-assets' +import { fallback } from 'virtual:ceremony-popup-fallback' +import { type Message, PopupConnection, PopupWindow } from '@libid/popup' +import { dispatchPrefetch, rootWorker } from '../../assets/registration.js' +import { startWorker } from '../../assets/worker.js' +import { ceremonyError, reportFailure } from '../../errors.js' +import { Events, now } from '../../events.js' +import { readPrefetch } from '../navigation.js' +import { messages } from '../ui-messages.js' +import { eventView } from './ui.js' + +/** Authenticate the Prefetch page and acknowledge selected fetch dispatch before OAuth navigation. */ +export async function startPrefetch(fragment: string): Promise { + const started = performance.now() + let connection: PopupConnection | undefined + const events = new Events() + const ui = eventView(events, '') + try { + const input = readPrefetch(fragment), + profile = `${input.platformId}/${input.platformCeremonyVersion}` + if (!Object.hasOwn(requestsByProfile, profile)) throw new Error(messages.unsupportedProfile) + events.emit({ + event: 'prefetch-dispatch', + phase: 'started', + timestamp: now(), + status: 'active', + }) + connection = PopupConnection.accept(PopupWindow.current(fragment, { scope: '/' }), { + fallback, + connectionId: input.ceremonyId, + allowedApplicationOrigins: '*', + }) + await connection.ready + const connected = performance.now() + const registration = await rootWorker() + const workerReady = performance.now() + await dispatchPrefetch(registration, profile) + const dispatched = performance.now() + const event = { + event: 'prefetch-dispatch', + phase: 'finished', + timestamp: performance.timeOrigin + dispatched, + instrumentation: { + attributes: { + // Navigation to entry execution includes document and module loading. + 'document-startup-ms': started, + 'connection-ms': connected - started, + 'worker-ready-ms': workerReady - connected, + 'dispatch-ms': dispatched - workerReady, + }, + }, + } as const + connection.send({ type: 'event', ...event }) + events.emit({ ...event, status: 'active' }) + } catch (error) { + const failure = ceremonyError(error, 'prefetch-dispatch') + events.emit({ + status: 'failed', + event: failure.event, + message: failure.message, + timestamp: now(), + }) + reportFailure(connection, failure) + } finally { + ui.stop() + } +} + +if (typeof document === 'undefined') startWorker(self as unknown as ServiceWorkerGlobalScope) diff --git a/ts/packages/ceremony/src/ccdp/documents/progress.test.ts b/ts/packages/ceremony/src/ccdp/documents/progress.test.ts new file mode 100644 index 00000000..232a98ca --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/documents/progress.test.ts @@ -0,0 +1,77 @@ +import { expect, it } from 'vitest' +import { type PlatformId, platforms } from '../../platforms/index.js' +import { proofProgress } from './progress.js' + +const progressFor = (platform: PlatformId) => + proofProgress(platforms[platform].versions[1].progressWeights) + +const proofOperations = [ + 'proof-worker-bootstrap', + 'proof-circuit-load', + 'proof-wasm-load', + 'proof-backend-initialization', + 'circuit-inputs', + 'witness', + 'proof', +] +const platformOperations = { + google: ['signing-key-fetch'], + x: ['token-fetch', 'token-attestation', 'identity-fetch', 'identity-attestation'], + github: ['token-fetch', 'token-attestation', 'identity-fetch', 'identity-attestation'], +} +const finished = (event: string) => + ({ + event, + phase: 'finished', + timestamp: 1, + status: 'active', + }) as const + +it.each(['google', 'x', 'github'] as const)( + 'counts each %s operation once in different completion orders [LIBID-PROVER-011]', + (platform) => { + const operations = [...proofOperations, ...platformOperations[platform]] + for (const order of [operations, [...operations].reverse()]) { + const progress = progressFor(platform) + let previous = 0 + for (const event of order) { + expect(progress({ ...finished(event), phase: 'started' })).toBeUndefined() + expect( + progress({ ...finished(event), instrumentation: { operationId: 'another-operation' } }), + ).toBeUndefined() + const next = progress(finished(event))! + expect(next).toBeGreaterThan(previous) + expect(next).toBeLessThanOrEqual(1) + expect(progress(finished(event))).toBeUndefined() + previous = next + } + expect(previous).toBe(1) + for (const event of [ + 'prover', + 'zk-proof-preparation', + 'zk-proof-generation', + 'proof-backend-destroy', + 'extension', + ]) + expect(progress(finished(event))).toBeUndefined() + expect( + progress({ status: 'failed', event: 'prover', message: 'failed', timestamp: 2 }), + ).toBeUndefined() + } + }, +) + +it('keeps late attestations separate from finished ZK work [LIBID-BROWSER-024]', () => { + const progress = progressFor('x') + for (const event of [...proofOperations, 'token-fetch']) progress(finished(event)) + const beforeAttestations = progress(finished('identity-fetch'))! + expect(progress(finished('zk-proof-generation'))).toBeUndefined() + const token = progress(finished('token-attestation'))! + const identity = progress(finished('identity-attestation'))! + expect(token).toBeGreaterThan(beforeAttestations) + expect(token).toBeLessThan(1) + expect(identity).toBeGreaterThan(token) + expect(identity).toBe(1) + expect(progressFor('google')(finished('identity-attestation'))).toBeUndefined() + expect(progressFor('github')(finished('token-fetch'))).toBeGreaterThan(0) +}) diff --git a/ts/packages/ceremony/src/ccdp/documents/progress.ts b/ts/packages/ceremony/src/ccdp/documents/progress.ts new file mode 100644 index 00000000..c39dd9f7 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/documents/progress.ts @@ -0,0 +1,23 @@ +import type { CeremonyEvent } from '../../events.js' + +/** Account for a platform's declared UI work without assigning meaning to event names. */ +export function proofProgress( + weights: Readonly>, +): (event: CeremonyEvent) => number | undefined { + const remaining = new Map(Object.entries(weights)) + const total = [...remaining.values()].reduce((sum, weight) => sum + weight, 0) + let completed = 0 + return (event) => { + if ( + event.status !== 'active' || + event.phase !== 'finished' || + event.instrumentation?.operationId !== undefined + ) + return + const weight = remaining.get(event.event) + if (weight === undefined) return + remaining.delete(event.event) + completed += weight + return completed / total + } +} diff --git a/ts/packages/ceremony/src/ccdp/documents/prover.test.ts b/ts/packages/ceremony/src/ccdp/documents/prover.test.ts new file mode 100644 index 00000000..0b5aa7ac --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/documents/prover.test.ts @@ -0,0 +1,292 @@ +import { type ConnectionEnd, PopupError } from '@libid/popup' +import { afterEach, expect, it, vi } from 'vitest' +import type { Events } from '../../events.js' +import type { ProverContext } from '../../platforms/context.js' +import { platforms } from '../../platforms/index.js' +import type { IdentityProof } from '../index.js' +import { popupErrorMessages } from '../ui-messages.js' +import { startProver } from './prover.js' + +const { accept, connection, prove, ui, terminal } = vi.hoisted(() => ({ + accept: vi.fn(), + terminal: vi.fn(), + connection: { + ready: Promise.resolve(), + closed: new Promise(() => {}), + send: vi.fn(), + on: vi.fn(), + }, + prove: vi.fn( + async (_context: ProverContext): Promise | null> => null, + ), + ui: { + stop: vi.fn(), + message: vi.fn(), + trackProof: vi.fn(), + finishProof: vi.fn(), + delivered: vi.fn(), + }, +})) + +vi.mock('@libid/popup', async (original) => ({ + ...(await original()), + PopupConnection: { + accept: (...args: unknown[]) => { + accept(...args) + return connection + }, + }, + PopupWindow: { current: vi.fn() }, +})) + +vi.mock('virtual:ceremony-popup-fallback', () => ({ fallback: undefined })) + +vi.mock('../../assets/registration.js', () => ({ claimRootWorker: vi.fn() })) + +vi.mock('../../platforms/google/1/prover.js', () => ({ prove })) + +vi.mock('../../platforms/x/1/prover.js', () => ({ prove })) + +vi.mock('../../platforms/github/1/prover.js', () => ({ prove })) + +vi.mock('./ui.js', () => ({ + view: vi.fn(), + eventView: (events: Events) => { + events.onEvent(terminal) + return ui + }, +})) + +afterEach(() => { + vi.clearAllMocks() + connection.closed = new Promise(() => {}) + connection.ready = Promise.resolve() + connection.send.mockReset() + ui.trackProof.mockReset() + ui.finishProof.mockReset() + ui.delivered.mockReset() + vi.unstubAllGlobals() +}) + +it.each(['google', 'x', 'github'] as const)( + 'passes validated %s routing to the platform without ledger decoding [LIBID-OAUTH-021]', + async (platformId) => { + vi.stubGlobal('location', { origin: 'https://ccdp.test' }) + vi.stubGlobal('crossOriginIsolated', true) + vi.stubGlobal('Worker', vi.fn()) + await startProver( + new URLSearchParams({ + ceremonyId: '6e171568-54e1-4f0d-aeb5-e8859826476a', + applicationOrigin: 'https://app.test', + oauthQuery: '', + oauthFragment: '#error=access_denied', + }).toString(), + ) + expect(accept).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ allowedApplicationOrigins: ['https://app.test'] }), + ) + expect(connection.on.mock.calls.map(([codec]) => codec.type)).toEqual(['prove-identity']) + const handler = connection.on.mock.calls.find(([codec]) => codec.type === 'prove-identity')?.[1] + expect(connection.send).toHaveBeenCalledWith({ + type: 'event', + event: 'prover', + phase: 'started', + timestamp: expect.any(Number), + }) + handler({ + type: 'prove-identity', + platformId, + platformCeremonyVersion: 1, + clientId: 'client', + redirectUri: 'https://bridge.test/callback', + codeVerifier: null, + notaryAddress: 'https://local-notary.test', + }) + await vi.waitFor(() => expect(prove).toHaveBeenCalledOnce()) + const context = prove.mock.calls[0][0] + expect(ui.trackProof).toHaveBeenCalledExactlyOnceWith( + platforms[platformId].versions[1].progressWeights, + ) + expect(context.request.notaryAddress).toBe('https://local-notary.test') + expect(context).not.toHaveProperty('ledgerId') + expect(context.oauthReturn.fragment).toBe('#error=access_denied') + }, +) + +it('reports retrospective fallback before readiness and preserves producer timestamps [CSP-016]', async () => { + vi.stubGlobal('location', { origin: 'https://ccdp.test', pathname: '/ccdp/v1/prover/fallback' }) + vi.stubGlobal('crossOriginIsolated', true) + vi.stubGlobal('Worker', vi.fn()) + prove.mockImplementationOnce(async (context) => { + context.emit({ event: 'proof-worker-bootstrap', phase: 'started', timestamp: 12 }) + throw new Error('Invalid GitHub id') + }) + await startProver( + new URLSearchParams({ + ceremonyId: '6e171568-54e1-4f0d-aeb5-e8859826476a', + applicationOrigin: 'https://app.test', + oauthQuery: '', + oauthFragment: '#error=access_denied', + }).toString(), + ) + expect(connection.send.mock.calls.slice(0, 2).map(([m]) => m)).toEqual([ + { type: 'event', event: 'prover-fallback', timestamp: performance.timeOrigin }, + { type: 'event', event: 'prover', phase: 'started', timestamp: expect.any(Number) }, + ]) + connection.on.mock.calls.find(([codec]) => codec.type === 'prove-identity')![1]({ + type: 'prove-identity', + platformId: 'github', + platformCeremonyVersion: 1, + }) + await vi.waitFor(() => + expect(connection.send).toHaveBeenCalledWith({ + type: 'ceremony-failed', + event: 'prover', + message: 'Invalid GitHub id', + }), + ) + expect(connection.send).toHaveBeenCalledWith({ + type: 'event', + event: 'proof-worker-bootstrap', + phase: 'started', + timestamp: 12, + }) + expect( + connection.send.mock.calls.some(([m]) => m.event === 'prover' && m.phase === 'finished'), + ).toBe(false) +}) + +it.each(['delivered', 'send-failed', 'ui-failed', 'closed-during-paint'])( + 'gives the UI a paint opportunity before delivery: %s [LIBID-BROWSER-024]', + async (outcome) => { + vi.stubGlobal('location', { origin: 'https://ccdp.test' }) + vi.stubGlobal('crossOriginIsolated', true) + vi.stubGlobal('Worker', vi.fn()) + let close!: () => void + connection.closed = new Promise((resolve) => { + close = () => resolve({ outcome: 'closed' }) + }) + prove.mockResolvedValueOnce({ + identity: { platformId: 'google', oauthClientId: 'client', userId: '1', userName: 'a@b.c' }, + proof: {}, + }) + await startProver( + new URLSearchParams({ + ceremonyId: '6e171568-54e1-4f0d-aeb5-e8859826476a', + applicationOrigin: 'https://app.test', + oauthQuery: '', + oauthFragment: '', + }).toString(), + ) + let painted!: () => void + ui.finishProof.mockImplementation( + () => + new Promise((resolve) => { + painted = resolve + }), + ) + if (outcome === 'send-failed') + connection.send.mockImplementation((message) => { + if (message.type === 'identity-proof') throw new Error('Delivery failed') + }) + if (outcome === 'ui-failed') { + ui.finishProof.mockRejectedValueOnce(new Error('UI unavailable')) + ui.trackProof.mockImplementation(() => { + throw new Error('UI unavailable') + }) + ui.delivered.mockImplementation(() => { + throw new Error('UI unavailable') + }) + } + connection.on.mock.calls.find(([codec]) => codec.type === 'prove-identity')![1]({ + type: 'prove-identity', + platformId: 'google', + platformCeremonyVersion: 1, + }) + await vi.waitFor(() => expect(prove).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(ui.finishProof).toHaveBeenCalledOnce()) + if (outcome !== 'ui-failed') { + expect(connection.send.mock.calls.some(([m]) => m.type === 'identity-proof')).toBe(false) + if (outcome === 'closed-during-paint') close() + await Promise.resolve() + painted() + } + await vi.waitFor(() => expect(ui.stop).toHaveBeenCalled()) + if (outcome === 'closed-during-paint') { + expect(ui.delivered).not.toHaveBeenCalled() + expect(connection.send.mock.calls.some(([m]) => m.type === 'identity-proof')).toBe(false) + } else if (outcome === 'send-failed') { + expect(ui.delivered).not.toHaveBeenCalled() + expect(connection.send).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ceremony-failed' }), + ) + } else { + expect(ui.delivered).toHaveBeenCalledOnce() + const index = connection.send.mock.calls.findIndex( + ([message]) => message.type === 'identity-proof', + ) + expect(connection.send.mock.invocationCallOrder[index]).toBeLessThan( + ui.delivered.mock.invocationCallOrder[0], + ) + expect( + connection.send.mock.calls.some(([message]) => message.type === 'ceremony-failed'), + ).toBe(false) + } + expect( + connection.send.mock.calls.some( + ([message]) => message.event === 'prover' && message.phase === 'finished', + ), + ).toBe(false) + }, +) + +it.each(['before', 'after'])( + 'shows the transport failure locally %s readiness [TEST-CCDP-08]', + async (when) => { + vi.stubGlobal('location', { origin: 'https://ccdp.test' }) + vi.stubGlobal('crossOriginIsolated', true) + vi.stubGlobal('Worker', vi.fn()) + const error = new PopupError('fallback-failed') + let close!: (end: ConnectionEnd) => void + connection.closed = new Promise((resolve) => { + close = resolve + }) + let rejectReady!: (error: Error) => void + if (when === 'before') + connection.ready = new Promise((_, reject) => { + rejectReady = reject + }) + const run = startProver( + new URLSearchParams({ + ceremonyId: '6e171568-54e1-4f0d-aeb5-e8859826476a', + applicationOrigin: 'https://app.test', + oauthQuery: '', + oauthFragment: '', + }).toString(), + ) + if (when === 'after') await run + connection.send.mockImplementation(() => { + throw new Error('unreachable') + }) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + close({ outcome: 'failed', code: error.code }) + if (when === 'before') rejectReady(error) + await run + await vi.waitFor(() => + expect(terminal).toHaveBeenCalledWith({ + status: 'failed', + event: 'prover', + message: popupErrorMessages[error.code], + timestamp: expect.any(Number), + }), + ) + expect(prove).not.toHaveBeenCalled() + expect(ui.stop).toHaveBeenCalledOnce() + expect(log).toHaveBeenCalledExactlyOnceWith('[ceremony] failure report unavailable') + } finally { + log.mockRestore() + } + }, +) diff --git a/ts/packages/ceremony/src/ccdp/documents/prover.ts b/ts/packages/ceremony/src/ccdp/documents/prover.ts new file mode 100644 index 00000000..e322f7d1 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/documents/prover.ts @@ -0,0 +1,150 @@ +import { fallback } from 'virtual:ceremony-popup-fallback' +import { type Message, PopupConnection, PopupError, PopupWindow } from '@libid/popup' +import { claimRootWorker } from '../../assets/registration.js' +import { ceremonyError, reportFailure } from '../../errors.js' +import { type CoreEvent, coreEvents, Events, now, type OperationEvent } from '../../events.js' +import type { ProverContext } from '../../platforms/context.js' +import { implementationFor, type PlatformId } from '../../platforms/index.js' +import { Event as EventMessage, IdentityProof, ProveIdentity } from '../index.js' +import { readProver, route } from '../navigation.js' +import { messages } from '../ui-messages.js' +import { eventView } from './ui.js' + +const implementations: Record< + PlatformId, + () => Promise<{ + prove(context: ProverContext): Promise | null> + }> +> = { + google: () => import('../../platforms/google/1/prover.js'), + x: () => import('../../platforms/x/1/prover.js'), + github: () => import('../../platforms/github/1/prover.js'), +} + +/** Accept the private callback fragment, run the selected pipeline and deliver one terminal result. */ +export async function startProver(fragment: string): Promise { + let connection: PopupConnection | undefined, + retained: ReturnType | undefined, + started = false, + ended = false, + ready = false + const controller = new AbortController() + const events = new Events() + let ui: ReturnType | undefined + const cleanup = () => { + ended = true + retained = undefined + controller.abort() + ui?.stop() + } + const fail = (error?: unknown) => { + if (ended) return + const failure = ceremonyError(error, 'prover') + events.emit({ + status: 'failed', + event: failure.event, + message: failure.message, + timestamp: now(), + }) + cleanup() + reportFailure(connection, failure) + } + function produce(event: OperationEvent): void { + if (ended) return + const message = EventMessage.decode({ type: 'event', ...event }) + if (coreEvents.includes(event.event as CoreEvent)) { + try { + connection!.send(message) + } catch (error) { + fail(error) + return + } + } else + try { + connection!.send(message) + } catch { + /* Observation loss cannot alter proving. */ + } + events.emit({ ...event, status: 'active' }) + } + try { + retained = readProver(fragment) + ui = eventView(events, '') + ui.message(messages.proofPreparation) + connection = PopupConnection.accept(PopupWindow.current(fragment, { scope: '/' }), { + fallback, + connectionId: retained.ceremonyId, + allowedApplicationOrigins: [retained.applicationOrigin], + isolationFallbackUrl: location.origin + route('prover/fallback'), + }) + + connection.on(ProveIdentity, (request) => { + if (ended) return + if ( + !ready || + started || + request.platformCeremonyVersion !== 1 || + !Object.hasOwn(implementations, request.platformId) + ) { + fail(new Error(messages.invalidProvingRequest)) + return + } + started = true + try { + ui!.trackProof(implementationFor(request.platformId as PlatformId, 1).progressWeights) + } catch { + /* Presentation cannot prevent proof execution. */ + } + const context: ProverContext = { + request, + ceremonyId: retained!.ceremonyId, + oauthReturn: retained!.oauthReturn, + signal: controller.signal, + emit: (event) => produce(event), + } + retained = undefined + void implementations[request.platformId as keyof typeof implementations]() + .then((module) => module.prove(context)) + .then(async (result) => { + if (ended) return + if (result === null) { + connection!.send({ type: 'user-denied' }) + events.emit({ status: 'denied', timestamp: now() }) + cleanup() + return + } + const message = IdentityProof.decode({ type: 'identity-proof', ...result }) + try { + await ui!.finishProof() + } catch { + /* Presentation cannot prevent proof delivery. */ + } + if (ended) return + connection!.send(message) + cleanup() + ui!.delivered() + }) + .catch(fail) + }) + void connection.closed.then((end) => { + if (!ended) + fail(end.outcome === 'failed' ? new PopupError(end.code) : new Error(messages.proverClosed)) + }) + await connection.ready + if (ended) return + if ( + !crossOriginIsolated || + typeof SharedArrayBuffer === 'undefined' || + typeof Worker === 'undefined' + ) + throw new Error(messages.isolationUnavailable) + await claimRootWorker() + if (ended) return + ready = true + if (location.pathname === route('prover/fallback')) + produce({ event: 'prover-fallback', timestamp: performance.timeOrigin }) + produce({ event: 'prover', phase: 'started', timestamp: now() }) + } catch (error) { + fail(error) + } +} diff --git a/ts/packages/ceremony/src/ccdp/documents/ui.ts b/ts/packages/ceremony/src/ccdp/documents/ui.ts new file mode 100644 index 00000000..6020491b --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/documents/ui.ts @@ -0,0 +1,113 @@ +import { CeremonyStage, type Events } from '../../events.js' +import { messages } from '../ui-messages.js' +import { proofProgress } from './progress.js' + +/** Package-owned DOM: no remote resources, application markup, or styling inputs. */ +export function view(title: string) { + const root = document.getElementById('libid-root') + if (!root) throw new Error(messages.missingRoot) + root.replaceChildren() + root.style.cssText = + 'max-width:26rem;margin:18vh auto;padding:2rem;font:16px system-ui;color:#242038;text-align:center' + const logo = document.createElement('div') + logo.textContent = messages.brand + logo.setAttribute('aria-label', messages.brand) + logo.style.cssText = 'font-size:2rem;font-weight:750;letter-spacing:-.06em;margin-bottom:2rem' + const label = document.createElement('p') + label.textContent = title + label.setAttribute('role', 'status') + root.append(logo, label) + return { root, label } +} + +/** The same local projection as the Application; subscriptions never mediate wire delivery. */ +export function eventView(events: Events, platform: string) { + const { root, label } = view(messages.preparation) + const bar = document.createElement('progress') + bar.setAttribute('aria-label', messages.progress) + bar.style.cssText = 'width:100%;accent-color:#6556d8' + root.append(bar) + const style = document.createElement('style') + style.textContent = + 'progress::-webkit-progress-value{transition:width .3s}progress::-moz-progress-bar{transition:width .3s}progress[value="1"]::-webkit-progress-value{transition:none}progress[value="1"]::-moz-progress-bar{transition:none}@media(prefers-reduced-motion:reduce){progress::-webkit-progress-value{transition:none}progress::-moz-progress-bar{transition:none}}' + const hint = document.createElement('p') + hint.setAttribute('role', 'status') + let timer: ReturnType | undefined + let offProgress = () => {} + const off = events.onStage((event) => { + if ( + event.status === 'active' && + event.stage !== 'preparation' && + event.stage !== 'authorization' && + timer === undefined + ) + timer = setTimeout(() => { + hint.textContent = messages.slowProving + root.append(hint) + }, 15000) + label.textContent = + event.status === 'active' + ? CeremonyStage.message(event.stage, platform) + : event.status === 'completed' + ? messages.proofReceived + : event.status === 'denied' + ? messages.returnToApplication(messages.authorizationDeclined) + : messages.returnToApplication( + event.message ?? + (event.status === 'closed' ? messages.interrupted : messages.failed), + ) + if (event.status !== 'active') { + clearTimeout(timer) + hint.remove() + bar.remove() + style.remove() + } + }) + return { + /** ProveIdentity supplies the platform before any pipeline events are produced. */ + trackProof(weights: Readonly>) { + offProgress() + bar.max = 1 + bar.value = 0 + root.append(style) + const progress = proofProgress(weights) + offProgress = events.onEvent((event) => { + const value = progress(event) + if (value !== undefined) bar.value = value + }) + }, + /** Give the full bar a paint opportunity before delivery; hidden documents need no wait. */ + async finishProof() { + bar.value = 1 + if (document.hidden) return + await new Promise((resolve) => { + let frame = 0 + const finish = () => { + clearTimeout(timeout) + cancelAnimationFrame(frame) + resolve() + } + // Animation frames can stop if the document becomes hidden. + const timeout = setTimeout(finish, 100) + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(finish) + }) + }) + }, + /** Local delivery updates the label, without emitting or claiming Application acceptance. */ + delivered() { + bar.value = 1 + label.textContent = messages.returnToApplication(messages.proofDelivered) + }, + stop() { + off() + offProgress() + style.remove() + clearTimeout(timer) + hint.remove() + }, + message(text: string) { + label.textContent = text + }, + } +} diff --git a/ts/packages/ceremony/src/ccdp/headers.ts b/ts/packages/ceremony/src/ccdp/headers.ts new file mode 100644 index 00000000..8013f3db --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/headers.ts @@ -0,0 +1,45 @@ +/** Response policy only. The static server owns representation metadata. */ +const base = + "default-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" + +export const csp = { + base, + fetch: "'self' https:", + // Exact loopback hosts only; public notaries still require TLS. + websocket: 'wss: ws://localhost:* ws://127.0.0.1:*', + execution: `${base}; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:`, +} as const + +export const immutable = { + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff', + 'Cross-Origin-Resource-Policy': 'same-origin', +} as const + +export const javascript = { 'Content-Type': 'text/javascript; charset=utf-8' } as const + +export const wasm = { 'Content-Type': 'application/wasm' } as const + +export const json = { 'Content-Type': 'application/json' } as const + +export const document = { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-cache', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Cross-Origin-Opener-Policy': 'unsafe-none', + 'Content-Security-Policy': base, +} as const + +export const executionWorker = { + ...javascript, + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Content-Security-Policy': `${csp.execution}; connect-src ${csp.fetch} blob:`, +} as const + +export const dip = { 'Document-Isolation-Policy': 'isolate-and-require-corp' } as const + +export const isolated = { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', +} as const diff --git a/ts/packages/ceremony/src/ccdp/index.test.ts b/ts/packages/ceremony/src/ccdp/index.test.ts new file mode 100644 index 00000000..052f1455 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/index.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vitest' +import { origin } from '../primitives.js' +import { + CeremonyFailed, + Event, + IdentityProof, + ProveIdentity, + redirect, + UserDenied, +} from './index.js' +import { prefetchFragment, proverFragment, readPrefetch, readProver } from './navigation.js' + +const id = '6e171568-54e1-4f0d-aeb5-e8859826476a' + +describe('CCDP v1 [LIBID-MOD-016] [LIBID-OAUTH-022]', () => { + const samples = [ + [ + CeremonyFailed, + { type: 'ceremony-failed', event: 'proof', message: 'Unexpected proving failure.' }, + ], + [ + ProveIdentity, + { + type: 'prove-identity', + platformId: 'google', + platformCeremonyVersion: 1, + clientId: 'client', + redirectUri: 'https://bridge.test/callback', + codeVerifier: null, + notaryAddress: null, + }, + ], + [UserDenied, { type: 'user-denied' }], + [Event, { type: 'event', event: 'prefetch-dispatch', phase: 'finished', timestamp: 1 }], + [Event, { type: 'event', event: 'prover', phase: 'started', timestamp: 2 }], + [ + Event, + { + type: 'event', + event: 'proof', + phase: 'started', + timestamp: 1, + }, + ], + [ + IdentityProof, + { + type: 'identity-proof', + identity: { platformId: 'google', oauthClientId: 'client', userId: '1', userName: 'a@b.c' }, + proof: { arbitrary: true }, + }, + ], + ] as const + for (const [codec, value] of samples) + it(codec.type, () => { + expect(codec.decode(value)).toBe(value) + expect(() => codec.decode({ ...value, ceremonyId: id })).toThrow() + expect(() => codec.decode({ ...value, type: 'other' })).toThrow() + expect(() => codec.decode(Object.assign(new Date(), value))).toThrow() + }) + it('validates nullable notary routing independently of platform [LIBID-OAUTH-021]', () => { + const message = samples[1][1] + for (const platformId of ['google', 'x', 'github', 'new-platform']) { + for (const notaryAddress of [ + null, + 'https://notary.test', + 'https://localhost:4687', + 'http://localhost:4687', + ]) { + const value = { ...message, platformId, notaryAddress } + expect(ProveIdentity.decode(value)).toBe(value) + expect(() => + ProveIdentity.decode({ ...value, tokenExchangeCredential: 'retired' }), + ).toThrow() + } + for (const notaryAddress of [ + undefined, + 0, + {}, + 'http://notary.test', + 'https://notary.test/', + 'https://user@notary.test', + 'https://notary.test?x=1', + 'https://notary.test#x', + ]) + expect(() => ProveIdentity.decode({ ...message, platformId, notaryAddress })).toThrow() + } + for (const extra of [ + { ledgerId: 'test:mainnet' }, + { isTestnet: false }, + { chainId: new Uint8Array(32) }, + ]) + expect(() => ProveIdentity.decode({ ...message, ...extra })).toThrow() + }) + it('validates nullable code verifiers without deciding platform applicability [LIBID-OAUTH-021]', () => { + for (const platformId of ['google', 'x', 'github', 'new-platform']) { + for (const codeVerifier of [null, 'A'.repeat(43)]) { + const value = { ...samples[1][1], platformId, codeVerifier } + expect(ProveIdentity.decode(value)).toBe(value) + expect(() => + ProveIdentity.decode({ ...value, tokenExchangeCredential: 'retired' }), + ).toThrow() + } + for (const codeVerifier of [ + undefined, + '', + 'A'.repeat(42), + '+'.repeat(43), + `${'A'.repeat(43)}=`, + ]) + expect(() => ProveIdentity.decode({ ...samples[1][1], platformId, codeVerifier })).toThrow() + } + }) + it('validates the optional public credential independently of platform [TEST-CCDP-05]', () => { + for (const platformId of ['google', 'x', 'github', 'new-platform']) { + const base = { ...samples[1][1], platformId } + const value = { ...base, clientCredential: 'public&credential=1' } + expect(ProveIdentity.decode(value)).toBe(value) + expect(() => ProveIdentity.decode({ ...value, tokenExchangeCredential: 'retired' })).toThrow() + expect(ProveIdentity.decode(base)).toBe(base) + for (const clientCredential of [ + undefined, + null, + '', + 1, + [], + 'has space', + 'tail\n', + '\t', + 'é', + '\x7f', + ]) + expect(() => ProveIdentity.decode({ ...base, clientCredential })).toThrow() + } + }) + + it('rejects malformed event records and terminal claims without coercion', () => { + const message = samples[5][1] + for (const timestamp of [NaN, Infinity, -1, '0']) + expect(() => Event.decode({ ...message, timestamp })).toThrow() + for (const extra of [ + { status: 'completed' }, + { stage: 'zk-proving' }, + { phase: 'failed' }, + { proof: 'secret' }, + { attributes: { bytes: Infinity } }, + ]) + expect(() => Event.decode({ ...message, ...extra })).toThrow() + }) + it('preserves private return components with one outer encoding [LIBID-OAUTH-026]', () => { + const input = { query: `?code=a%2Bb&state=v1.${id}`, fragment: '' } + expect(readProver(String(proverFragment(id, 'https://app.test', input)))).toEqual({ + ceremonyId: id, + applicationOrigin: 'https://app.test', + oauthReturn: input, + }) + expect(readPrefetch(String(prefetchFragment(id, 'x', 1))).platformId).toBe('x') + for (const extra of [`&ceremonyId=${id}`, '&other=1']) + expect(() => + readProver(String(proverFragment(id, 'https://app.test', input)) + extra), + ).toThrow() + expect(() => + readProver( + `ceremonyId=${id}&applicationOrigin=https%3A%2F%2Fapp.test&oauthQuery=%FF&oauthFragment=`, + ), + ).toThrow() + }) +}) + +it.each([ + null, + {}, + { platformId: 'google', oauthClientId: 'client', userId: 1, userName: 'a' }, + { platformId: 'google', oauthClientId: 'client', userId: '1', userName: 'a', extra: true }, + { platformId: 'google', oauthClientId: 'client', userId: '1', userName: '\n' }, +])('rejects malformed shared identities [LIBID-MOD-016]', (identity) => { + expect(() => IdentityProof.decode({ type: 'identity-proof', identity, proof: null })).toThrow() +}) + +it('rejects the retired delivery message and embedded-identity shape', () => { + expect(() => IdentityProof.decode({ type: 'prover-deliver-proof', proof: {} })).toThrow() + expect(() => IdentityProof.decode({ type: 'identity-proof', proof: { identity: {} } })).toThrow() +}) + +it('admits explicit loopback HTTP without widening public URL validation [LIBID-OAUTH-021]', () => { + for (const suffix of ['?', '#', '?x=1', '#x']) { + const redirectUri = `https://bridge.test/callback${suffix}` + expect(redirect(redirectUri)).toBe(false) + expect(() => + ProveIdentity.decode({ + type: 'prove-identity', + platformId: 'google', + platformCeremonyVersion: 1, + clientId: 'client', + redirectUri, + codeVerifier: null, + notaryAddress: null, + }), + ).toThrow() + } + for (const value of ['http://localhost:4682', 'http://127.0.0.1:4682']) { + expect(origin(value)).toBe(true) + expect(redirect(`${value}/auth/callback`)).toBe(true) + } + for (const value of [ + 'http://bridge.test', + 'http://localhost.evil.test', + 'http://192.168.1.1', + 'http://localtest.me', + 'http://localhost.', + 'http://user@localhost', + 'http://LOCALHOST', + 'http://127.1', + ]) { + expect(origin(value), value).toBe(false) + expect(redirect(`${value}/auth/callback`), value).toBe(false) + } +}) + +it('supports bounded extension observations and disambiguated operations [LIBID-MOD-016]', () => { + for (const event of [ + { + type: 'event', + event: 'resource-request', + timestamp: 1, + instrumentation: { attributes: { bytes: 1024, cache: 'hit' } }, + }, + { + type: 'event', + event: 'tls-session', + phase: 'started', + instrumentation: { operationId: 'identity' }, + timestamp: 1, + }, + { + type: 'event', + event: 'tls-session', + phase: 'finished', + instrumentation: { operationId: 'identity' }, + timestamp: 2, + }, + { type: 'event', event: 'prover-fallback', timestamp: 3 }, + ]) + expect(Event.decode(event)).toBe(event) + for (const event of [ + { event: 'prover-fallback', phase: 'started' }, + { event: 'prover' }, + { event: 'prover', phase: 'started', instrumentation: { operationId: 'extra' } }, + { event: 'some-event', phase: 'unknown' }, + { event: 'unknown', instrumentation: { operationId: '' } }, + { event: 'unknown', instrumentation: { attributes: { data: {} } } }, + { + event: 'unknown', + instrumentation: { + attributes: Object.fromEntries(Array.from({ length: 17 }, (_, i) => [`field-${i}`, i])), + }, + }, + ]) + expect(() => Event.decode({ type: 'event', timestamp: 1, ...event })).toThrow() +}) + +it.each(['https://app.test', 'http://localhost:4681', 'http://127.0.0.1:4681'])( + 'preserves the exact Application origin %s in the private fragment [TEST-CCDP-03]', + (applicationOrigin) => { + const fragment = proverFragment(id, applicationOrigin, { + query: '?code=a%2Bb', + fragment: '#state=x', + }) + expect([...fragment.keys()]).toEqual([ + 'ceremonyId', + 'applicationOrigin', + 'oauthQuery', + 'oauthFragment', + ]) + expect(readProver(String(fragment)).applicationOrigin).toBe(applicationOrigin) + fragment.append('applicationOrigin', applicationOrigin) + expect(() => readProver(String(fragment))).toThrow() + fragment.delete('applicationOrigin') + expect(() => readProver(String(fragment))).toThrow() + }, +) + +it.each([ + '', + '*', + 'null', + 'http://app.test', + 'https://app.test/', + 'https://app.test:443', + 'https://u@app.test', +])( + 'rejects invalid Application origin %s before accepting Prover [TEST-CCDP-04]', + (applicationOrigin) => { + expect(() => + readProver(String(proverFragment(id, applicationOrigin, { query: '', fragment: '' }))), + ).toThrow() + }, +) + +it('accepts only the current outcome names [TEST-CCDP-05]', () => { + for (const type of ['cancel', 'denied', 'abort']) { + const value = + type === 'abort' ? { type, event: 'prover', message: 'Retired message' } : { type } + for (const codec of [UserDenied, CeremonyFailed, ProveIdentity, IdentityProof, Event]) + expect(() => codec.decode(value)).toThrow() + } +}) + +it('validates the exact optional instrumentation record [TEST-CCDP-06]', () => { + const value = { type: 'event', event: 'session', phase: 'started', timestamp: 1 } + for (const instrumentation of [ + {}, + { operationId: 'first' }, + { attributes: {} }, + { operationId: 'first', attributes: { bytes: 1, cached: true, source: 'worker' } }, + ]) + expect(Event.decode({ ...value, instrumentation })).toMatchObject({ instrumentation }) + expect(Event.decode(value)).not.toHaveProperty('instrumentation') + for (const instrumentation of [ + null, + undefined, + [], + new Date(), + { unknown: true }, + { operationId: null }, + { operationId: undefined }, + { operationId: '' }, + { operationId: 'x'.repeat(65) }, + { attributes: null }, + { attributes: undefined }, + { attributes: [] }, + { attributes: { bytes: Infinity } }, + { attributes: { bytes: NaN } }, + { attributes: { text: 'x'.repeat(129) } }, + { attributes: Object.fromEntries(Array.from({ length: 17 }, (_, i) => [`field-${i}`, i])) }, + ]) + expect(() => Event.decode({ ...value, instrumentation })).toThrow() + for (const extra of [{ operationId: 'first' }, { attributes: {} }]) + expect(() => Event.decode({ ...value, ...extra })).toThrow() +}) diff --git a/ts/packages/ceremony/src/ccdp/index.ts b/ts/packages/ceremony/src/ccdp/index.ts new file mode 100644 index 00000000..5942e073 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/index.ts @@ -0,0 +1,142 @@ +import type { MessageType } from '@libid/popup' +import { eventName, type OperationEvent, validateEvent } from '../events.js' +import { b64urlDecode, hasExactKeys, isRecord, origin, text, uint, webUrl } from '../primitives.js' + +/** Pure CCDP codecs: shape and bounds validation only; transport authentication belongs to popup. */ +export const CCDP_VERSION = 1 + +export const MAX_REDIRECT_URI_BYTES = 2048 + +export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +/** Public OAuth application credential; no whitespace or control bytes. */ +export const isClientCredential = (value: unknown): value is string => + typeof value === 'string' && /^[\x21-\x7e]+$/.test(value) + +export const PLATFORM = /^[a-z][a-z0-9-]{0,63}$/ + +export function redirect(value: unknown): value is string { + return text(value, MAX_REDIRECT_URI_BYTES) && webUrl(value) && !/[?#]/.test(value) +} + +export function assertMessage( + value: unknown, + type: T, + fields: readonly string[], +): asserts value is Record & { type: T } { + if (!isRecord(value) || !hasExactKeys(value, ['type', ...fields]) || value.type !== type) + throw new TypeError('Invalid CCDP record') +} + +export interface UserDenied { + type: 'user-denied' +} + +export const UserDenied = { + type: 'user-denied', + decode(value: unknown): UserDenied { + assertMessage(value, this.type, []) + return value + }, +} as const satisfies MessageType + +/** Opaque display text and the failed operation; neither grants authority. */ +export interface CeremonyFailed { + type: 'ceremony-failed' + event: string + message: string +} + +export const CeremonyFailed = { + type: 'ceremony-failed', + decode(value: unknown): CeremonyFailed { + assertMessage(value, this.type, ['event', 'message']) + if (!eventName(value.event) || !text(value.message, 2048)) + throw new TypeError('Invalid ceremony failure') + return value as unknown as CeremonyFailed + }, +} as const satisfies MessageType + +/** Application-owned inputs only; raw OAuth returns remain private to Callback and Prover. */ +export interface ProveIdentity { + type: 'prove-identity' + platformId: string + platformCeremonyVersion: number + clientId: string + redirectUri: string + codeVerifier: string | null + notaryAddress: string | null + clientCredential?: string +} + +export const ProveIdentity = { + type: 'prove-identity', + decode(value: unknown): ProveIdentity { + assertMessage(value, this.type, [ + 'platformId', + 'platformCeremonyVersion', + 'clientId', + 'redirectUri', + 'codeVerifier', + 'notaryAddress', + ...(isRecord(value) && Object.hasOwn(value, 'clientCredential') ? ['clientCredential'] : []), + ]) + if ( + typeof value.platformId !== 'string' || + !PLATFORM.test(value.platformId) || + !uint(value.platformCeremonyVersion, 65535) || + !text(value.clientId, 512) || + !redirect(value.redirectUri) || + (Object.hasOwn(value, 'clientCredential') && !isClientCredential(value.clientCredential)) || + !(value.notaryAddress === null || origin(value.notaryAddress)) || + !( + value.codeVerifier === null || + (typeof value.codeVerifier === 'string' && + value.codeVerifier.length === 43 && + b64urlDecode(value.codeVerifier)?.length === 32) + ) + ) + throw new TypeError('Invalid proving request') + return value as unknown as ProveIdentity + }, +} as const satisfies MessageType + +/** One event envelope for coordination and observations; it never declares ceremony success. */ +export type Event = { type: 'event' } & OperationEvent + +export const Event = { + type: 'event', + decode(value: unknown): Event { + if (!isRecord(value) || value.type !== this.type) throw new TypeError('Invalid event') + validateEvent(value) + return value as unknown as Event + }, +} as const satisfies MessageType + +/** Final pipeline output, including all required attestations; the ledger verifier remains authoritative. */ +export interface IdentityProof { + type: 'identity-proof' + identity: { platformId: string; oauthClientId: string; userId: string; userName: string } + proof: unknown +} + +export const IdentityProof = { + type: 'identity-proof', + decode(value: unknown): IdentityProof { + assertMessage(value, this.type, ['identity', 'proof']) + const identity = value.identity + if ( + !isRecord(identity) || + !hasExactKeys(identity, ['platformId', 'oauthClientId', 'userId', 'userName']) || + typeof identity.platformId !== 'string' || + !PLATFORM.test(identity.platformId) || + !text(identity.oauthClientId, 512) || + !text(identity.userId, 255) || + !text(identity.userName, 255) + ) + throw new TypeError('Invalid identity') + return value as unknown as IdentityProof + }, +} as const satisfies MessageType + +export type CCDPMessage = ProveIdentity | IdentityProof | UserDenied | CeremonyFailed | Event diff --git a/ts/packages/ceremony/src/ccdp/navigation.ts b/ts/packages/ceremony/src/ccdp/navigation.ts new file mode 100644 index 00000000..9c3de6c8 --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/navigation.ts @@ -0,0 +1,75 @@ +import { origin, uint } from '../primitives.js' +import { CCDP_VERSION, PLATFORM, UUID } from './index.js' + +export interface OAuthReturn { + query: string + fragment: string +} + +export const route = (name: 'prefetch' | 'prover' | 'prover/fallback' | 'worker.js') => + `/ccdp/v${CCDP_VERSION}/${name}` + +export const oauthState = (ceremonyId: string) => `v${CCDP_VERSION}.${ceremonyId}` + +function fields(fragment: string, keys: string[]): URLSearchParams { + const raw = fragment.startsWith('#') ? fragment.slice(1) : fragment + if (raw.length > 65536) throw new TypeError('Navigation input too large') + // URLSearchParams is deliberately forgiving; reject malformed UTF-8/escapes first. + decodeURIComponent(raw.replace(/\+/g, ' ')) + const p = new URLSearchParams(raw) + if (p.size !== keys.length || keys.some((k) => p.getAll(k).length !== 1)) + throw new TypeError('Invalid navigation fields') + return p +} + +export function prefetchFragment( + ceremonyId: string, + platformId: string, + version: number, +): URLSearchParams { + return new URLSearchParams({ ceremonyId, platformId, ceremonyVersion: String(version) }) +} + +export function readPrefetch(fragment: string) { + const p = fields(fragment, ['ceremonyId', 'platformId', 'ceremonyVersion']) + const ceremonyId = p.get('ceremonyId')!, + platformId = p.get('platformId')!, + version = p.get('ceremonyVersion')! + if ( + !UUID.test(ceremonyId) || + !PLATFORM.test(platformId) || + !/^(0|[1-9][0-9]*)$/.test(version) || + !uint(Number(version), 65535) + ) + throw new TypeError('Invalid Prefetch input') + return { ceremonyId, platformId, platformCeremonyVersion: Number(version) } +} + +export function proverFragment( + ceremonyId: string, + applicationOrigin: string, + input: OAuthReturn, +): URLSearchParams { + return new URLSearchParams({ + ceremonyId, + applicationOrigin, + oauthQuery: input.query, + oauthFragment: input.fragment, + }) +} + +export function readProver(fragment: string) { + const p = fields(fragment, ['ceremonyId', 'applicationOrigin', 'oauthQuery', 'oauthFragment']) + const ceremonyId = p.get('ceremonyId')!, + applicationOrigin = p.get('applicationOrigin')!, + query = p.get('oauthQuery')!, + hash = p.get('oauthFragment')! + if ( + !UUID.test(ceremonyId) || + !origin(applicationOrigin) || + (query !== '' && !query.startsWith('?')) || + (hash !== '' && !hash.startsWith('#')) + ) + throw new TypeError('Invalid Prover input') + return { ceremonyId, applicationOrigin, oauthReturn: { query, fragment: hash } } +} diff --git a/ts/packages/ceremony/src/ccdp/ui-messages.ts b/ts/packages/ceremony/src/ccdp/ui-messages.ts new file mode 100644 index 00000000..298af8bc --- /dev/null +++ b/ts/packages/ceremony/src/ccdp/ui-messages.ts @@ -0,0 +1,57 @@ +import type { PopupErrorCode } from '@libid/popup' + +/** Package-owned CCDP display text, shared by documents and the client stage projection. */ +export const messages = { + brand: 'libID', + preparation: 'Preparing your ceremony', + authorization: (platform: string) => `Authorize with ${platform}`, + proofPreparation: 'Preparing your identity proof', + notarization: 'Notarizing your identity data', + zkProving: 'Creating your identity proof with ZK', + progress: 'Ceremony in progress', + slowProving: 'Still proving. In Vanadium, enabling JavaScript JIT in site controls may help.', + proofReceived: 'Proof received', + proofDelivered: 'Proof delivered.', + authorizationDeclined: 'Authorization declined.', + interrupted: 'Ceremony interrupted.', + failed: 'Ceremony failed.', + returning: 'Returning to your application', + returnToApplication: (message: string) => `${message} Return to your application.`, + unsupportedVersion: + 'This ceremony version is no longer supported. Update the application and try again.', + unableToContinue: 'Unable to continue.', + notFoundTitle: 'Not found', + notFound: 'Not found.', + missingRoot: 'Missing ceremony root', + oauthReturnTooLarge: 'OAuth return too large', + invalidOAuthState: 'Invalid OAuth state', + invalidCallbackInputs: 'Invalid Callback inputs', + missingApplicationOrigin: 'Authenticated Application origin unavailable', + callbackClosed: 'Callback connection closed', + proverClosed: 'Prover connection closed', + connectionEnded: 'Popup connection ended', + connectionInitializationFailed: 'Unable to initialize ceremony connection', + invalidProvingRequest: 'Invalid proving request', + isolationUnavailable: 'Prover isolation unavailable', + unsupportedProfile: 'Unsupported profile', +} as const + +/** Translate transport codes here; popup owns codes, CCDP owns their presentation. */ +export const popupErrorMessages = { + 'popup-unavailable': + 'Popup is unavailable. It may have been closed or isolated by the provider (COOP). If you did not close it, the popup connection was lost.', + 'fallback-unavailable': + 'Unable to reconnect to the application. The sign-in provider may have isolated this window, preventing the ceremony from continuing.', + 'fallback-failed': 'This popup could not establish a fallback connection to the application.', + 'handshake-rejected': + 'The popup connection failed authentication. Check the application and popup origins and connection configuration.', + 'opener-timeout': 'The application did not respond to the popup connection request.', + 'decode-rejected': 'The popup connection received an invalid message.', + 'control-rejected': 'The popup connection received an invalid control message.', + 'continuity-unsupported': 'This browser cannot preserve the popup connection across navigation.', + 'keep-failed': 'Unable to preserve the popup connection before navigation.', + 'claim-failed': 'Unable to restore the popup connection after navigation.', + 'isolation-unavailable': 'The browser isolation required for proving is unavailable.', + 'send-unavailable': 'Unable to send a message because the popup connection is unavailable.', + 'connection-closed': messages.connectionEnded, +} satisfies Record diff --git a/ts/packages/ceremony/src/errors.test.ts b/ts/packages/ceremony/src/errors.test.ts new file mode 100644 index 00000000..31bd86cf --- /dev/null +++ b/ts/packages/ceremony/src/errors.test.ts @@ -0,0 +1,71 @@ +import { type Message, type PopupConnection, PopupError } from '@libid/popup' +import { expect, it, vi } from 'vitest' +import { CeremonyFailed } from './ccdp/index.js' +import { popupErrorMessages } from './ccdp/ui-messages.js' +import { CeremonyError, ceremonyError, errorMessage, reportFailure } from './errors.js' + +it('preserves unexpected error text and context without serializing the exception [LIBID-OAUTH-022]', () => { + const cause = new Error('Invalid GitHub id') + const error = ceremonyError(cause, 'identity-fetch') + expect(error.cause).toBe(cause) + expect(ceremonyError(error, 'prover')).toBe(error) + const send = vi.fn() + reportFailure({ send } as unknown as PopupConnection, error) + const message = send.mock.calls[0][0] + expect(CeremonyFailed.decode(message)).toBe(message) + expect(message).toEqual({ + type: 'ceremony-failed', + event: 'identity-fetch', + message: 'Invalid GitHub id', + }) + expect(CeremonyFailed.decode({ ...message, message: 'A new dependency error' }).message).toBe( + 'A new dependency error', + ) + for (const extra of [{ cause }, { stack: cause.stack }, { code: 'fixed' }]) + expect(() => CeremonyFailed.decode({ ...message, ...extra })).toThrow() +}) + +it('bounds display text and rejects arbitrary objects instead of stringifying their contents', () => { + expect(errorMessage({ secret: 'value' })).toBe('Ceremony failed.') + expect(errorMessage(new Error('bad\nvalue\0'))).toBe('bad value') + const long = errorMessage(new Error('💥'.repeat(2048))) + expect(new TextEncoder().encode(long).length).toBeLessThanOrEqual(2048) + expect(long.length).toBeGreaterThan(0) +}) + +it('records undeliverable failures without logging opaque text or changing outcomes', () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const error = new CeremonyError('proof', 'synthetic-secret') + reportFailure(undefined, error) + expect(log).toHaveBeenCalledExactlyOnceWith('[ceremony] failure report unavailable') + log.mockClear() + reportFailure( + { + send() { + throw new Error('connection closed') + }, + } as unknown as PopupConnection, + error, + ) + expect(log).toHaveBeenCalledOnce() + log.mockImplementation(() => { + throw new Error('logger failed') + }) + expect(() => reportFailure(undefined, error)).not.toThrow() + } finally { + log.mockRestore() + } +}) + +it('translates popup codes into CCDP copy while preserving the transport cause', () => { + const cause = new PopupError('fallback-unavailable') + const error = ceremonyError(cause, 'authorization') + expect(cause.message).toBe('fallback-unavailable') + expect(error.cause).toBe(cause) + expect(error.event).toBe('authorization') + expect(error.message).toBe(popupErrorMessages['fallback-unavailable']) + expect(error.message).toContain('The sign-in provider may have isolated this window') + // Matching text in an ordinary exception is not a transport code. + expect(errorMessage(new Error('fallback-unavailable'))).toBe('fallback-unavailable') +}) diff --git a/ts/packages/ceremony/src/errors.ts b/ts/packages/ceremony/src/errors.ts new file mode 100644 index 00000000..abffff4c --- /dev/null +++ b/ts/packages/ceremony/src/errors.ts @@ -0,0 +1,60 @@ +import { type Message, type PopupConnection, PopupError } from '@libid/popup' +import { messages, popupErrorMessages } from './ccdp/ui-messages.js' +import { text } from './primitives.js' + +/** Opaque display text only: never serialize an exception object, stack, or nested causes. */ +export function errorMessage(error: unknown): string { + let message = + error instanceof PopupError + ? popupErrorMessages[error.code] + : error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : messages.failed + message = message.replace(/\p{Cc}/gu, ' ').trim() + while (new TextEncoder().encode(message).length > 2048) + message = message.slice(0, Math.floor(message.length * 0.9)) + return text(message, 2048) ? message : messages.failed +} + +/** A failed operation and its displayable explanation; no stable error-code catalog. */ +export class CeremonyError extends Error { + /** Local connection closure is an interruption, distinct from a technical failure. */ + readonly status: 'failed' | 'closed' + + constructor( + readonly event: string, + message: string, + options?: ErrorOptions & { status?: 'failed' | 'closed' }, + ) { + super(errorMessage(message), options) + this.name = 'CeremonyError' + this.status = options?.status ?? 'failed' + } +} + +export function ceremonyError(error: unknown, event: string): CeremonyError { + return error instanceof CeremonyError + ? error + : new CeremonyError(event, errorMessage(error), { cause: error }) +} + +/** Failure to deliver an CeremonyFailed is recorded locally without exposing its opaque text to telemetry. */ +export function reportFailure( + connection: PopupConnection | undefined, + error: CeremonyError, +): void { + try { + if (connection) { + const message = { type: 'ceremony-failed', event: error.event, message: error.message } + connection.send(message) + return + } + } catch { + /* Reporting cannot replace the original failure. */ + } + try { + console.error('[ceremony] failure report unavailable') + } catch {} +} diff --git a/ts/packages/ceremony/src/events.test.ts b/ts/packages/ceremony/src/events.test.ts new file mode 100644 index 00000000..6199f8ca --- /dev/null +++ b/ts/packages/ceremony/src/events.test.ts @@ -0,0 +1,109 @@ +import { expect, it, vi } from 'vitest' +import { + type CeremonyEvent, + Events, + type OperationEvent, + operation, + type StageEvent, +} from './events.js' + +it('projects monotonic presentation while preserving overlapping occurrence timestamps [LIBID-BROWSER-006]', () => { + const feed = new Events(), + events: CeremonyEvent[] = [], + stages: StageEvent[] = [] + feed.onEvent((e) => events.push(e)) + feed.onStage((e) => stages.push(e)) + const emit = (event: string, phase: 'started' | 'finished', timestamp: number) => + feed.emit({ event, phase, timestamp, status: 'active' }) + emit('prefetch-dispatch', 'started', 1) + emit('authorization', 'started', 2) + emit('authorization', 'finished', 3) + emit('zk-proof-preparation', 'started', 4) + emit('token-fetch', 'started', 5) + emit('zk-proof-generation', 'started', 6) + emit('token-attestation', 'started', 5.5) + emit('zk-proof-preparation', 'finished', 7) + emit('zk-proof-generation', 'finished', 8) + expect(stages.map((e) => e.stage)).toEqual([ + 'preparation', + 'authorization', + 'proof-preparation', + 'notarization', + 'zk-proving', + ]) + expect(events.at(-4)?.timestamp).toBe(6) + expect(events.at(-1)?.status).toBe('active') + feed.emit({ + status: 'failed', + event: 'identity-attestation', + message: 'Notary disconnected', + timestamp: 9, + }) + expect(stages.at(-1)).toEqual({ + stage: 'zk-proving', + status: 'failed', + message: 'Notary disconnected', + timestamp: 9, + }) + feed.emit({ event: 'prover', phase: 'finished', status: 'completed', timestamp: 10 }) + expect(events.at(-1)?.status).toBe('failed') +}) + +it('isolates throwing observers and mutations, and unsubscribes locally', () => { + const feed = new Events(), + listen = vi.fn(), + stage = vi.fn() + feed.onEvent(() => { + throw new Error('observer') + }) + feed.onEvent((e) => { + if ('instrumentation' in e && e.instrumentation?.attributes) + Reflect.set(e.instrumentation.attributes, 'bytes', 99) + }) + const off = feed.onEvent(listen) + feed.onStage(stage) + feed.emit({ + event: 'prefetch-dispatch', + phase: 'started', + timestamp: 1, + status: 'active', + instrumentation: { attributes: { bytes: 1 } }, + }) + expect(listen.mock.calls[0][0].instrumentation.attributes.bytes).toBe(1) + expect(stage).toHaveBeenCalledOnce() + off() + feed.emit({ status: 'denied', timestamp: 2 }) + expect(listen).toHaveBeenCalledOnce() + expect(stage).toHaveBeenCalledTimes(2) +}) + +it('pairs concurrent repeated operations by identifier and leaves interrupted spans unfinished', async () => { + const events: OperationEvent[] = [] + let finish!: () => void + const work = operation( + (e) => events.push(e), + 'session', + () => + new Promise((r) => { + finish = r + }), + 'first', + ) + await expect( + operation( + (e) => events.push(e), + 'session', + () => { + throw new Error('TLS failed') + }, + 'second', + ), + ).rejects.toMatchObject({ event: 'session', message: 'TLS failed' }) + finish() + await work + expect(events.map((e) => [e.instrumentation?.operationId, e.phase])).toEqual([ + ['first', 'started'], + ['second', 'started'], + ['first', 'finished'], + ]) +}) diff --git a/ts/packages/ceremony/src/events.ts b/ts/packages/ceremony/src/events.ts new file mode 100644 index 00000000..eca0d494 --- /dev/null +++ b/ts/packages/ceremony/src/events.ts @@ -0,0 +1,230 @@ +import { messages } from './ccdp/ui-messages.js' +import { ceremonyError } from './errors.js' +import { isRecord, text } from './primitives.js' + +/** Core operations have protocol-owned meanings; extension events grant no protocol authority. */ +export const coreEvents = [ + 'prefetch-dispatch', + 'authorization', + 'prover', + 'prover-fallback', + 'token-fetch', + 'token-attestation', + 'identity-fetch', + 'identity-attestation', + 'zk-proof-preparation', + 'zk-proof-generation', +] as const + +export type CoreEvent = (typeof coreEvents)[number] + +/** Timestamps record occurrence in the producing document or worker, in epoch milliseconds. */ +export interface OperationEvent { + event: string + phase?: 'started' | 'finished' + timestamp: number + /** Optional tracing metadata; never credentials, identity values, or raw errors. */ + instrumentation?: { + operationId?: string + attributes?: Readonly> + } +} + +export type CeremonyStatus = 'active' | 'completed' | 'denied' | 'failed' | 'closed' + +export type CeremonyEvent = + | (OperationEvent & { status: 'active' }) + | { event: 'prover'; phase: 'finished'; timestamp: number; status: 'completed' } + | { status: 'denied'; timestamp: number } + | { status: 'failed' | 'closed'; event: string; message: string; timestamp: number } + +export const now = () => performance.timeOrigin + performance.now() + +export function eventName(value: unknown): value is string { + return typeof value === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(value) +} + +/** Exact bounded records are validated at the transport boundary, independently of subscriptions. */ +export function validateEvent(value: unknown): asserts value is OperationEvent { + if ( + !isRecord(value) || + !eventName(value.event) || + typeof value.timestamp !== 'number' || + !Number.isFinite(value.timestamp) || + value.timestamp < 0 || + Object.keys(value).some( + (key) => !['event', 'phase', 'timestamp', 'instrumentation', 'type'].includes(key), + ) || + ('phase' in value && value.phase !== 'started' && value.phase !== 'finished') + ) + throw new TypeError('Invalid operation event') + if (coreEvents.includes(value.event as CoreEvent)) { + if (value.event === 'prover-fallback' ? 'phase' in value : !('phase' in value)) + throw new TypeError('Invalid core event phase') + } + if ('instrumentation' in value) { + const metadata = value.instrumentation + if ( + !isRecord(metadata) || + Object.keys(metadata).some((key) => !['operationId', 'attributes'].includes(key)) || + ('operationId' in metadata && + (!text(metadata.operationId, 64) || coreEvents.includes(value.event as CoreEvent))) + ) + throw new TypeError('Invalid event instrumentation') + if ( + 'attributes' in metadata && + (!isRecord(metadata.attributes) || + Object.keys(metadata.attributes).length > 16 || + Object.entries(metadata.attributes).some( + ([key, value]) => + !eventName(key) || + !( + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) || + text(value, 128) + ), + )) + ) + throw new TypeError('Invalid event attributes') + } +} + +export const stages = [ + 'preparation', + 'authorization', + 'proof-preparation', + 'notarization', + 'zk-proving', +] as const + +export type CeremonyStage = (typeof stages)[number] + +export interface StageEvent { + stage: CeremonyStage + status: CeremonyStatus + timestamp: number + message?: string +} + +/** Package-owned presentation; stage intervals do not describe exclusive execution time. */ +export const CeremonyStage = { + message(stage: CeremonyStage, platform: string): string { + return { + preparation: messages.preparation, + authorization: messages.authorization(platform), + 'proof-preparation': messages.proofPreparation, + notarization: messages.notarization, + 'zk-proving': messages.zkProving, + }[stage] + }, +} + +function projectedStage(event: CeremonyEvent): CeremonyStage | undefined { + if (!('phase' in event)) return + if (event.event === 'prefetch-dispatch' && event.phase === 'started') return 'preparation' + if (event.event === 'prover' && event.phase === 'started') return 'proof-preparation' + if (event.event === 'authorization') + return event.phase === 'started' ? 'authorization' : 'proof-preparation' + if ( + (event.event === 'token-fetch' || event.event === 'token-attestation') && + event.phase === 'started' + ) + return 'notarization' + if (event.event === 'zk-proof-generation' && event.phase === 'started') return 'zk-proving' +} + +/** A local feed shared by the client and popup documents; observers never control its producer. */ +export class Events { + private readonly listeners = new Set<(event: CeremonyEvent) => void>() + private readonly stageListeners = new Set<(event: StageEvent) => void>() + private stage: CeremonyStage = 'preparation' + private stageSeen = false + private ended = false + + onEvent(listener: (event: CeremonyEvent) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + onStage(listener: (event: StageEvent) => void): () => void { + this.stageListeners.add(listener) + return () => this.stageListeners.delete(listener) + } + + emit(event: CeremonyEvent): void { + if (this.ended) return + const terminal = event.status !== 'active' + if (terminal) this.ended = true + const projected = projectedStage(event) + const changed = + projected !== undefined && + (!this.stageSeen || stages.indexOf(projected) > stages.indexOf(this.stage)) + if (changed) { + this.stage = projected + this.stageSeen = true + } + const update = Object.freeze({ + ...event, + ...('instrumentation' in event && event.instrumentation + ? { + instrumentation: Object.freeze({ + ...event.instrumentation, + ...(event.instrumentation.attributes + ? { attributes: Object.freeze({ ...event.instrumentation.attributes }) } + : {}), + }), + } + : {}), + }) + const stageUpdate = Object.freeze({ + stage: this.stage, + status: event.status, + timestamp: event.timestamp, + ...('message' in event ? { message: event.message } : {}), + }) + for (const listener of [...this.listeners]) { + if (this.ended && !terminal) break + try { + listener(update) + } catch { + /* Observers cannot affect protocol processing. */ + } + } + if (changed || terminal) + for (const listener of [...this.stageListeners]) { + if (this.ended && !terminal) break + try { + listener(stageUpdate) + } catch { + /* Rendering cannot affect protocol processing. */ + } + } + if (terminal) this.clear() + } + + clear(): void { + this.listeners.clear() + this.stageListeners.clear() + } +} + +/** Interruptions preserve the original error and never fabricate a finished operation. */ +export async function operation( + emit: (event: OperationEvent) => void, + event: string, + work: () => T | Promise, + operationId?: string, +): Promise { + const context = { + event, + ...(operationId === undefined ? {} : { instrumentation: { operationId } }), + } + emit({ ...context, phase: 'started', timestamp: now() }) + try { + const result = await work() + emit({ ...context, phase: 'finished', timestamp: now() }) + return result + } catch (error) { + throw ceremonyError(error, event) + } +} diff --git a/ts/packages/ceremony/src/index.ts b/ts/packages/ceremony/src/index.ts new file mode 100644 index 00000000..402223cb --- /dev/null +++ b/ts/packages/ceremony/src/index.ts @@ -0,0 +1,11 @@ +export { CeremonyError } from './errors.js' +export type { NotaryAttestation } from './notary/decode.js' + +export { + type Identity, + type IdentityResult, + type OAuthProof, + type PlatformId, + type SupportedCeremonyVersion, + supportedPlatforms, +} from './platforms/index.js' diff --git a/ts/packages/ceremony/src/json.test.ts b/ts/packages/ceremony/src/json.test.ts new file mode 100644 index 00000000..d48f3a3a --- /dev/null +++ b/ts/packages/ceremony/src/json.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from 'vitest' +import { parseJson } from './json.js' + +it('rejects duplicate/escaped keys without restricting valid JSON [LIBID-PROVER-004]', () => { + for (const source of [ + '{"a":1,"a":2}', + '{"a":1,"\\u0061":2}'.replace('\\\\', '\\'), + '[{"a":0,"a":1}]', + ]) + expect(() => parseJson(source)).toThrow() + const source = ' { "a" : [{"x": 1}, {"x": 2}], "b": "comma, colon: braces{}" } ' + expect(parseJson(source)).toEqual(JSON.parse(source)) +}) diff --git a/ts/packages/ceremony/src/json.ts b/ts/packages/ceremony/src/json.ts new file mode 100644 index 00000000..fb003dfa --- /dev/null +++ b/ts/packages/ceremony/src/json.ts @@ -0,0 +1,23 @@ +/** Native JSON parsing plus duplicate-key detection, including escaped aliases. */ +export function parseJson(source: string): unknown { + const value: unknown = JSON.parse(source) + const stack: ({ keys: Set; key: boolean } | null)[] = [] + for (const [token] of source.matchAll(/"(?:[^"\\]|\\.)*"|[{}[\]:,]/g)) { + const top = stack.at(-1) + if (token === '{') stack.push({ keys: new Set(), key: true }) + else if (token === '[') stack.push(null) + else if (token === '}' || token === ']') stack.pop() + else if (token === ',') { + if (top) top.key = true + } else if (token === ':') { + if (top) top.key = false + } else if (top?.key) { + const key = JSON.parse(token) as string + if (top.keys.has(key)) throw new TypeError('Duplicate JSON member') + top.keys.add(key) + top.key = false + } + if (stack.length > 32) throw new TypeError('JSON nesting limit exceeded') + } + return value +} diff --git a/ts/packages/ceremony/src/notary/decode.test.ts b/ts/packages/ceremony/src/notary/decode.test.ts new file mode 100644 index 00000000..7e6288af --- /dev/null +++ b/ts/packages/ceremony/src/notary/decode.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs' +import { keccak_256 } from '@noble/hashes/sha3.js' +import { describe, expect, it } from 'vitest' +import { decodeAttestedData, MAX_ATTESTED_DATA_BYTES } from './decode.js' + +// libid-org/libid-rs@239a4bb426ac72591fe30006f22660e164a98d96, +// crates/libid-ceremony/src/attestation.rs::CROSS_LANGUAGE_FIXTURE. +const FIXTURE = Uint8Array.from( + Buffer.from( + readFileSync( + new URL('./libid-rs-239a4bb-attested-data.fixture.hex', import.meta.url), + 'utf8', + ).trim(), + 'hex', + ), +) + +const changed = (offset: number, value: number): Uint8Array => { + const bytes = FIXTURE.slice() + bytes[offset] = value + return bytes +} + +const withU32 = (offset: number, value: number): Uint8Array => { + const bytes = FIXTURE.slice() + new DataView(bytes.buffer).setUint32(offset, value) + return bytes +} + +const withU64 = (offset: number, value: bigint): Uint8Array => { + const bytes = FIXTURE.slice() + new DataView(bytes.buffer).setBigUint64(offset, value) + return bytes +} + +describe('decodeAttestedData', () => { + it('[LIBID-PROVER-010] matches the pinned libid-rs fixture completely', () => { + expect(Buffer.from(keccak_256(FIXTURE)).toString('hex')).toBe( + '48162f05bdb27b19b3544bf2aae608745861bf357bb31e07f536b6fb50e95936', + ) + expect(decodeAttestedData(FIXTURE)).toEqual({ + authorityId: Uint8Array.from( + Buffer.from('4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d', 'hex'), + ), + createdAt: '1770000000', + sentTranscriptLength: 60, + receivedTranscriptLength: 40, + sent: { + revealed: [ + { start: 0, bytes: new Uint8Array(20).fill('a'.charCodeAt(0)) }, + { start: 40, bytes: new Uint8Array(20).fill('b'.charCodeAt(0)) }, + ], + commitments: [{ start: 20, end: 40, commitment: new Uint8Array(32).fill(7) }], + }, + received: { + revealed: [{ start: 0, bytes: new Uint8Array(10).fill('c'.charCodeAt(0)) }], + commitments: [{ start: 10, end: 40, commitment: new Uint8Array(32).fill(9) }], + }, + }) + }) + + it('decodes one-byte changes to valid fields instead of normalizing them', () => { + expect(decodeAttestedData(changed(0, FIXTURE[0] ^ 1)).authorityId[0]).toBe(FIXTURE[0] ^ 1) + expect(decodeAttestedData(changed(39, FIXTURE[39] + 1)).createdAt).toBe('1770000001') + expect(decodeAttestedData(withU32(40, 61)).sentTranscriptLength).toBe(61) + expect(decodeAttestedData(withU32(44, 41)).receivedTranscriptLength).toBe(41) + expect(decodeAttestedData(changed(68, 'd'.charCodeAt(0))).sent.revealed[0].bytes[0]).toBe( + 'd'.charCodeAt(0), + ) + expect(decodeAttestedData(changed(136, 8)).sent.commitments[0].commitment[0]).toBe(8) + }) + + it.each<[string, Uint8Array, RegExp]>([ + ['oversize input', new Uint8Array(MAX_ATTESTED_DATA_BYTES + 1), /size limit/], + ['truncation', FIXTURE.slice(0, -1), /collection count/], + ['trailing bytes', Uint8Array.from([...FIXTURE, 0]), /trailing bytes/], + ['collection-count overflow', withU64(48, 0xffff_ffff_ffff_ffffn), /collection count/], + ['byte-length overflow', withU64(60, 0xffff_ffff_ffff_ffffn), /range length/], + ['empty revealed range', withU64(60, 0n), /empty revealed range/], + ['unordered revealed ranges', withU32(88, 10), /revealed ranges/], + ['revealed/commitment overlap', withU32(128, 19), /revealed and committed/], + ['empty commitment', withU32(132, 20), /commitment ranges/], + ['out-of-bounds commitment', withU32(132, 61), /commitment ranges/], + [ + 'varint collection count', + Uint8Array.from([...FIXTURE.slice(0, 48), 2, ...FIXTURE.slice(56)]), + /collection count/, + ], + ])('rejects %s', (_name, bytes, reason) => { + expect(() => decodeAttestedData(bytes)).toThrow(reason) + }) +}) diff --git a/ts/packages/ceremony/src/notary/decode.ts b/ts/packages/ceremony/src/notary/decode.ts new file mode 100644 index 00000000..4d684b17 --- /dev/null +++ b/ts/packages/ceremony/src/notary/decode.ts @@ -0,0 +1,214 @@ +import { fixedBytes, hasExactKeys, isRecord, uint } from '../primitives.js' + +export const MAX_ATTESTED_DATA_BYTES = 2 * 1024 * 1024 + +export interface DecodedRevealedRange { + start: number + bytes: Uint8Array +} + +export interface DecodedRangeCommitment { + start: number + end: number + commitment: Uint8Array +} + +export interface DecodedDirection { + revealed: readonly DecodedRevealedRange[] + commitments: readonly DecodedRangeCommitment[] +} + +export interface DecodedAttestedData { + authorityId: Uint8Array + /** Signed Unix seconds as canonical decimal text, preserving the complete u64 range. */ + createdAt: string + sentTranscriptLength: number + receivedTranscriptLength: number + sent: DecodedDirection + received: DecodedDirection +} + +/** Original signed bytes and their decoded projection; ledger verification remains authoritative. */ +export interface NotaryAttestation { + attestedData: Uint8Array + signature: Uint8Array + decoded: DecodedAttestedData +} + +function invalid(reason: string): never { + throw new Error(`invalid attested data: ${reason}`) +} + +class Cursor { + private offset = 0 + private readonly view: DataView + + constructor(private readonly input: Uint8Array) { + this.view = new DataView(input.buffer, input.byteOffset, input.byteLength) + } + + get remaining(): number { + return this.input.length - this.offset + } + + u32(): number { + if (this.remaining < 4) invalid('truncated integer') + const value = this.view.getUint32(this.offset) + this.offset += 4 + return value + } + + u64(): bigint { + if (this.remaining < 8) invalid('truncated integer') + const value = this.view.getBigUint64(this.offset) + this.offset += 8 + return value + } + + count(minimumItemBytes: number): number { + const value = this.u64() + if (value > BigInt(Math.floor(this.remaining / minimumItemBytes))) { + invalid('collection count exceeds remaining input') + } + return Number(value) + } + + bytes(length: number): Uint8Array { + if (!Number.isSafeInteger(length) || length < 0 || length > this.remaining) { + invalid('truncated byte string') + } + const value = this.input.slice(this.offset, this.offset + length) + this.offset += length + return value + } +} + +function readDirection(cursor: Cursor, transcriptLength: number): DecodedDirection { + const revealed: DecodedRevealedRange[] = [] + let previousEnd = 0 + for (let count = cursor.count(13); count > 0; count--) { + const start = cursor.u32() + const length = cursor.u64() + if (length === 0n) invalid('empty revealed range') + if (length > BigInt(cursor.remaining) || length > BigInt(transcriptLength)) { + invalid('revealed range length is out of bounds') + } + const byteLength = Number(length) + if (start < previousEnd || start > transcriptLength - byteLength) { + invalid('revealed ranges are unordered, overlapping, or out of bounds') + } + revealed.push({ start, bytes: cursor.bytes(byteLength) }) + previousEnd = start + byteLength + } + + const commitments: DecodedRangeCommitment[] = [] + previousEnd = 0 + for (let count = cursor.count(40); count > 0; count--) { + const start = cursor.u32() + const end = cursor.u32() + if (start < previousEnd || end <= start || end > transcriptLength) { + invalid('commitment ranges are empty, unordered, overlapping, or out of bounds') + } + commitments.push({ start, end, commitment: cursor.bytes(32) }) + previousEnd = end + } + + let revealedIndex = 0 + let commitmentIndex = 0 + while (revealedIndex < revealed.length && commitmentIndex < commitments.length) { + const reveal = revealed[revealedIndex] + const commitment = commitments[commitmentIndex] + const revealEnd = reveal.start + reveal.bytes.length + if (revealEnd <= commitment.start) revealedIndex++ + else if (commitment.end <= reveal.start) commitmentIndex++ + else invalid('revealed and committed ranges overlap') + } + + return { revealed, commitments } +} + +/** + * Decode the signed libid-rs bincode 2.0.1 fixed-int big-endian preimage, without re-encoding. + * Collection/byte-string lengths are u64; transcript lengths and offsets are u32. + * Bounds are checked before allocation or conversion to number; the cross-language + * fixture and its digest are pinned in decode.test.ts. + */ +export function decodeAttestedData(bytes: Uint8Array): DecodedAttestedData { + if (bytes.length > MAX_ATTESTED_DATA_BYTES) invalid('input exceeds size limit') + const cursor = new Cursor(bytes) + const authorityId = cursor.bytes(32) + const createdAt = cursor.u64().toString() + const sentTranscriptLength = cursor.u32() + const receivedTranscriptLength = cursor.u32() + const sent = readDirection(cursor, sentTranscriptLength) + const received = readDirection(cursor, receivedTranscriptLength) + if (cursor.remaining !== 0) invalid('trailing bytes') + return { + authorityId, + createdAt, + sentTranscriptLength, + receivedTranscriptLength, + sent, + received, + } +} + +function direction(v: unknown): v is DecodedDirection { + return ( + isRecord(v) && + hasExactKeys(v, ['revealed', 'commitments']) && + Array.isArray(v.revealed) && + v.revealed.length <= 65536 && + Array.isArray(v.commitments) && + v.commitments.length <= 65536 && + v.revealed.every( + (r) => + isRecord(r) && + hasExactKeys(r, ['start', 'bytes']) && + uint(r.start, 0xffffffff) && + r.bytes instanceof Uint8Array && + r.bytes.length <= 32768, + ) && + v.commitments.every( + (r) => + isRecord(r) && + hasExactKeys(r, ['start', 'end', 'commitment']) && + uint(r.start, 0xffffffff) && + uint(r.end, 0xffffffff) && + fixedBytes(r.commitment, 32), + ) + ) +} + +/** Validate the delivered projection shape without reparsing bytes or verifying signatures. */ +export function isAttestation(v: unknown): v is NotaryAttestation { + if ( + !isRecord(v) || + !hasExactKeys(v, ['attestedData', 'signature', 'decoded']) || + !(v.attestedData instanceof Uint8Array) || + !v.attestedData.length || + v.attestedData.length > 2 * 1024 * 1024 || + !fixedBytes(v.signature, 65) + ) + return false + const d = v.decoded + return ( + isRecord(d) && + hasExactKeys(d, [ + 'authorityId', + 'createdAt', + 'sentTranscriptLength', + 'receivedTranscriptLength', + 'sent', + 'received', + ]) && + fixedBytes(d.authorityId, 32) && + typeof d.createdAt === 'string' && + /^(0|[1-9][0-9]{0,19})$/.test(d.createdAt) && + BigInt(d.createdAt) <= 0xffffffffffffffffn && + uint(d.sentTranscriptLength, 4096) && + uint(d.receivedTranscriptLength, 32768) && + direction(d.sent) && + direction(d.received) + ) +} diff --git a/ts/packages/ceremony/src/notary/http.test.ts b/ts/packages/ceremony/src/notary/http.test.ts new file mode 100644 index 00000000..c25d7390 --- /dev/null +++ b/ts/packages/ceremony/src/notary/http.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'vitest' +import { responseJson } from './http.js' + +const response = (headers: string, body: string) => ({ + sent: new Uint8Array(), + received: new TextEncoder().encode(`HTTP/1.1 200 OK\r\n${headers}\r\n${body}`), +}) + +it('parses chunked JSON without altering the transcript used for range commitments', () => { + const transcript = response( + 'Transfer-Encoding: chunked\r\n', + '6\r\n{"id":\r\n2\r\n1}\r\n0\r\n\r\n', + ), + original = transcript.received.slice() + expect(responseJson(transcript)).toEqual({ id: 1 }) + expect(transcript.received).toEqual(original) +}) + +it('rejects ambiguous framing, truncated chunks, compressed bodies, and duplicate JSON members', () => { + for (const transcript of [ + response('Transfer-Encoding: chunked\r\nContent-Length: 2\r\n', '{}'), + response('Transfer-Encoding: chunked\r\n', '5\r\n{}\r\n0\r\n\r\n'), + response('Content-Encoding: gzip\r\n', '{}'), + response('Content-Length: 0\r\n', '{}'), + response('', '{"id":1,"id":2}'), + ]) + expect(() => responseJson(transcript)).toThrow() +}) + +it('preserves numeric root identity IDs without accepting quoted or rounded aliases', () => { + const parseJsonNumbersAsText = (body: string) => responseJson(response('', body), true) + expect(parseJsonNumbersAsText('{"id":18446744073709551615}')).toEqual({ + id: 18446744073709551615n, + }) + expect(parseJsonNumbersAsText('{"\\u0069d":"1","nested":{"id":1}}')).toEqual({ + id: '1', + nested: { id: '1' }, + }) + expect( + parseJsonNumbersAsText('{"\\u0069d":9007199254740992,"nested":{"id":9007199254740993}}'), + ).toEqual({ id: 9007199254740992n, nested: { id: '9007199254740993' } }) + expect(() => parseJsonNumbersAsText('{"id":01}')).toThrow() + expect(() => parseJsonNumbersAsText('{"id":1,2:3}')).toThrow() +}) diff --git a/ts/packages/ceremony/src/notary/http.ts b/ts/packages/ceremony/src/notary/http.ts new file mode 100644 index 00000000..2bfd97f3 --- /dev/null +++ b/ts/packages/ceremony/src/notary/http.ts @@ -0,0 +1,107 @@ +import { parseJson } from '../json.js' +import type { Transcript } from './session.js' + +/** Decode a successful HTTP response without altering transcript bytes; numeric root IDs can retain bigint precision. */ +export function responseJson(transcript: Transcript, numbersAsText = false): unknown { + const bytes = transcript.received, + decoder = new TextDecoder('utf-8', { fatal: true }), + text = decoder.decode(bytes), + end = text.indexOf('\r\n\r\n') + if (end < 0 || !/^HTTP\/1\.[01] 200(?: |\r)/.test(text)) + throw new Error('Platform request failed') + const headerText = text.slice(0, end) + if (/[^\t\x20-\x7e\r\n]/.test(headerText)) throw new Error('Invalid HTTP headers') + const headers = new Map() + for (const line of headerText.split('\r\n').slice(1)) { + const i = line.indexOf(':') + if (i <= 0) throw new Error('Invalid HTTP header') + const name = line.slice(0, i).toLowerCase() + if ( + headers.has(name) && + ['content-length', 'transfer-encoding', 'content-encoding'].includes(name) + ) + throw new Error('Duplicate framing header') + headers.set(name, line.slice(i + 1).trim()) + } + const encoding = headers.get('content-encoding') + if (encoding && encoding !== 'identity') throw new Error('Unsupported response encoding') + const length = headers.get('content-length'), + transfer = headers.get('transfer-encoding') + let body = bytes.subarray(end + 4) + if (transfer) { + if (transfer.toLowerCase() !== 'chunked' || length !== undefined) + throw new Error('Ambiguous HTTP framing') + const chunks: Uint8Array[] = [] + let offset = 0, + total = 0 + for (;;) { + let lineEnd = offset + while (lineEnd < body.length - 1 && (body[lineEnd] !== 13 || body[lineEnd + 1] !== 10)) + lineEnd++ + const sizeText = decoder.decode(body.subarray(offset, lineEnd)) + if (!/^[0-9a-fA-F]{1,8}$/.test(sizeText)) throw new Error('Invalid chunk size') + const size = Number.parseInt(sizeText, 16) + offset = lineEnd + 2 + if (size === 0) { + if (offset + 2 !== body.length || body[offset] !== 13 || body[offset + 1] !== 10) + throw new Error('Invalid final chunk') + break + } + if ( + offset + size + 2 > body.length || + body[offset + size] !== 13 || + body[offset + size + 1] !== 10 + ) + throw new Error('Truncated chunk') + chunks.push(body.subarray(offset, offset + size)) + total += size + offset += size + 2 + } + const decoded = new Uint8Array(total) + offset = 0 + for (const chunk of chunks) { + decoded.set(chunk, offset) + offset += chunk.length + } + body = decoded + } else if (length !== undefined && (!/^[0-9]+$/.test(length) || Number(length) !== body.length)) + throw new Error('HTTP length mismatch') + return (numbersAsText ? parseJsonNumbersAsText : parseJson)(decoder.decode(body)) +} + +/** Preserve numeric root fields as bigint, without ever rounding an identity ID. */ +function parseJsonNumbersAsText(source: string): unknown { + const numericRoots = new Map() + let depth = 0 + for (const match of source.matchAll(/"(?:[^"\\]|\\.)*"|[{}[\]]/g)) { + const token = match[0] + if (token === '{' || token === '[') depth++ + else if (token === '}' || token === ']') depth-- + else if (depth === 1) { + const number = /^\s*:\s*(-?(?:0|[1-9][0-9]*))\s*[,}]/.exec( + source.slice(match.index + token.length), + ) + if (number) numericRoots.set(JSON.parse(token), number[1]) + } + } + const value = parseJson( + source.replace( + /"(?:[^"\\]|\\.)*"|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/g, + (token, offset) => { + if (token.startsWith('"')) return token + if (/^\s*:/.test(source.slice(offset + token.length))) + throw new TypeError('Invalid JSON object key') + return JSON.stringify(token) + }, + ), + ) + if (value && typeof value === 'object' && !Array.isArray(value)) + for (const [key, number] of numericRoots) + Object.defineProperty(value, key, { + value: BigInt(number), + enumerable: true, + writable: true, + configurable: true, + }) + return value +} diff --git a/ts/packages/ceremony/src/notary/libid-rs-239a4bb-attested-data.fixture.hex b/ts/packages/ceremony/src/notary/libid-rs-239a4bb-attested-data.fixture.hex new file mode 100644 index 00000000..b2e7ce36 --- /dev/null +++ b/ts/packages/ceremony/src/notary/libid-rs-239a4bb-attested-data.fixture.hex @@ -0,0 +1 @@ +4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d0000000069800e800000003c00000028000000000000000200000000000000000000001461616161616161616161616161616161616161610000002800000000000000146262626262626262626262626262626262626262000000000000000100000014000000280707070707070707070707070707070707070707070707070707070707070707000000000000000100000000000000000000000a6363636363636363636300000000000000010000000a000000280909090909090909090909090909090909090909090909090909090909090909 diff --git a/ts/packages/ceremony/src/notary/notarize.test.ts b/ts/packages/ceremony/src/notary/notarize.test.ts new file mode 100644 index 00000000..79def9c6 --- /dev/null +++ b/ts/packages/ceremony/src/notary/notarize.test.ts @@ -0,0 +1,432 @@ +import { sha256 } from '@noble/hashes/sha2.js' +import { keccak_256 } from '@noble/hashes/sha3.js' +import { describe, expect, it } from 'vitest' +import { + type ByteRange, + correlateAttestation, + type HashOpening, + type NotarizationPlan, + planNotarization, + type Transcript, +} from './notarize.js' + +const encoder = new TextEncoder() + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((length, part) => length + part.length, 0)) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.length + } + return out +} + +function u32(value: number): Uint8Array { + const bytes = new Uint8Array(4) + new DataView(bytes.buffer).setUint32(0, value) + return bytes +} + +function u64(value: number): Uint8Array { + const bytes = new Uint8Array(8) + new DataView(bytes.buffer).setBigUint64(0, BigInt(value)) + return bytes +} + +function encodeDirection( + transcript: Uint8Array, + revealed: readonly ByteRange[], + commitments: readonly ByteRange[], + hashes: readonly Uint8Array[], +): Uint8Array { + return concat( + u64(revealed.length), + ...revealed.flatMap((range) => [ + u32(range.start), + u64(range.end - range.start), + transcript.slice(range.start, range.end), + ]), + u64(commitments.length), + ...commitments.flatMap((range, index) => [u32(range.start), u32(range.end), hashes[index]]), + ) +} + +function encodeAttestation( + transcript: Transcript, + plan: NotarizationPlan, + hashes: { sent: readonly Uint8Array[]; recv: readonly Uint8Array[] }, + commitments = plan.commit, +): Uint8Array { + return concat( + keccak_256(encoder.encode('api.x.com')), + u64(1_770_000_000), + u32(transcript.sent.length), + u32(transcript.recv.length), + encodeDirection(transcript.sent, plan.reveal.sent, commitments.sent, hashes.sent), + encodeDirection(transcript.recv, plan.reveal.recv, commitments.recv, hashes.recv), + ) +} + +function opening(transcript: Uint8Array, range: ByteRange, byte: number): HashOpening { + const blinder = new Uint8Array(16).fill(byte) + return { + hash: sha256(concat(transcript.slice(range.start, range.end), blinder)), + blinder, + } +} + +const transcript: Transcript = { + sent: encoder.encode('abcdefghij'), + recv: encoder.encode('0123456789ab'), +} + +const ranges = { + sent: [ + { start: 0, end: 2 }, + { start: 4, end: 7 }, + ], + recv: [ + { start: 2, end: 5 }, + { start: 8, end: 12 }, + ], +} + +describe('planNotarization', () => { + it('validates and tiles both directions with the exact TLSNotary input shape', () => { + expect(planNotarization(transcript, ranges)).toEqual({ + reveal: { sent: ranges.sent, recv: ranges.recv, server_identity: true }, + commit: { + sent: [ + { start: 2, end: 4, algorithm: 'SHA256' }, + { start: 7, end: 10, algorithm: 'SHA256' }, + ], + recv: [ + { start: 0, end: 2, algorithm: 'SHA256' }, + { start: 5, end: 8, algorithm: 'SHA256' }, + ], + }, + }) + }) + + it('accepts full reveal and full commitment without empty complement ranges', () => { + expect( + planNotarization( + { sent: new Uint8Array(), recv: encoder.encode('abc') }, + { sent: [], recv: [{ start: 0, end: 3 }] }, + ).commit, + ).toEqual({ sent: [], recv: [] }) + expect( + planNotarization( + { sent: encoder.encode('abc'), recv: new Uint8Array() }, + { sent: [], recv: [] }, + ).commit.sent, + ).toEqual([{ start: 0, end: 3, algorithm: 'SHA256' }]) + }) + + it.each([ + [ + 'sent ceiling', + { sent: new Uint8Array(4097), recv: new Uint8Array() }, + { sent: [], recv: [] }, + ], + [ + 'recv ceiling', + { sent: new Uint8Array(), recv: new Uint8Array(32769) }, + { sent: [], recv: [] }, + ], + ])('rejects the %s', (_name, bytes, reveal) => { + expect(() => planNotarization(bytes, reveal)).toThrow(/exceeds/) + }) + + it.each<[string, readonly ByteRange[]]>([ + ['empty', [{ start: 1, end: 1 }]], + ['negative', [{ start: -1, end: 1 }]], + ['fractional', [{ start: 0, end: 1.5 }]], + ['out of bounds', [{ start: 0, end: 11 }]], + [ + 'unordered', + [ + { start: 4, end: 5 }, + { start: 2, end: 3 }, + ], + ], + [ + 'overlapping', + [ + { start: 1, end: 4 }, + { start: 3, end: 5 }, + ], + ], + [ + 'duplicate', + [ + { start: 1, end: 4 }, + { start: 1, end: 4 }, + ], + ], + ])('rejects %s reveal ranges', (_name, sent) => { + expect(() => planNotarization(transcript, { sent, recv: [] })).toThrow(/reveal ranges/) + }) +}) + +describe('correlateAttestation', () => { + const plan = planNotarization(transcript, ranges) + const sent = plan.commit.sent.map((range, index) => opening(transcript.sent, range, index + 1)) + const recv = plan.commit.recv.map((range, index) => opening(transcript.recv, range, index + 3)) + const attestedData = encodeAttestation(transcript, plan, { + sent: sent.map(({ hash }) => hash), + recv: recv.map(({ hash }) => hash), + }) + + it('rejects a signed authority differing from the session target', () => { + const changed = attestedData.slice() + changed[0] ^= 1 + expect(() => + correlateAttestation('api.x.com', transcript, plan, { sent, recv }, changed), + ).toThrow(/authority/) + }) + + it('matches unordered TLSNotary openings to signed ranges in each direction', () => { + const result = correlateAttestation( + 'api.x.com', + transcript, + plan, + { sent: [...sent].reverse(), recv: [...recv].reverse() }, + attestedData, + ) + expect(result.sent).toEqual([ + { start: 2, end: 4, ...sent[0] }, + { start: 7, end: 10, ...sent[1] }, + ]) + expect(result.recv.map(({ start, end }) => ({ start, end }))).toEqual([ + { start: 0, end: 2 }, + { start: 5, end: 8 }, + ]) + expect(result.decoded.createdAt).toBe('1770000000') + }) + + it('pins SHA-256 input order to hidden bytes followed by the 16-byte blinder', () => { + const blinder = Uint8Array.from(Array.from({ length: 16 }, (_, index) => index)) + expect(Buffer.from(sha256(concat(transcript.sent.slice(2, 4), blinder))).toString('hex')).toBe( + '2f83109b942b986213e9047e756ea35e066537f7a1297e9b368e7a481b53794f', + ) + const onePlan = planNotarization(transcript, { + sent: [ + { start: 0, end: 2 }, + { start: 4, end: transcript.sent.length }, + ], + recv: [{ start: 0, end: transcript.recv.length }], + }) + const hash = Uint8Array.from( + Buffer.from('2f83109b942b986213e9047e756ea35e066537f7a1297e9b368e7a481b53794f', 'hex'), + ) + expect( + correlateAttestation( + 'api.x.com', + transcript, + onePlan, + { sent: [{ hash, blinder }], recv: [] }, + encodeAttestation(transcript, onePlan, { sent: [hash], recv: [] }), + ).sent[0], + ).toEqual({ start: 2, end: 4, hash, blinder }) + }) + + it.each([ + ['missing opening', { sent: sent.slice(1), recv }, /opening count/], + ['extra opening', { sent: [...sent, sent[0]], recv }, /opening count/], + [ + 'short hash', + { sent: [{ ...sent[0], hash: new Uint8Array(31) }, sent[1]], recv }, + /hash must be exactly 32/, + ], + [ + 'long blinder', + { sent: [{ ...sent[0], blinder: new Uint8Array(17) }, sent[1]], recv }, + /blinder must be exactly 16/, + ], + [ + 'changed hash', + { sent: [{ ...sent[0], hash: new Uint8Array(32) }, sent[1]], recv }, + /one hidden/, + ], + ['cross-direction opening', { sent: recv, recv: sent }, /one hidden/], + ] as const)('rejects %s', (_name, output, reason) => { + expect(() => correlateAttestation('api.x.com', transcript, plan, output, attestedData)).toThrow( + reason, + ) + }) + + it('rejects a plan changed after complement derivation', () => { + const changed: NotarizationPlan = { + ...plan, + commit: { ...plan.commit, sent: [plan.commit.sent[0], plan.commit.sent[0]] }, + } + expect(() => + correlateAttestation('api.x.com', transcript, changed, { sent, recv }, attestedData), + ).toThrow(/not the reveal complement/) + }) + + it('requires the TLSNotary server-identity reveal', () => { + const changed = structuredClone(plan) as NotarizationPlan + Object.assign(changed.reveal, { server_identity: false }) + expect(() => + correlateAttestation('api.x.com', transcript, changed, { sent, recv }, attestedData), + ).toThrow(/server identity/) + }) + + it('rejects an opening that ambiguously matches equal hidden plaintext ranges', () => { + const repeated: Transcript = { sent: encoder.encode('xAxA'), recv: new Uint8Array() } + const repeatedPlan = planNotarization(repeated, { + sent: [ + { start: 0, end: 1 }, + { start: 2, end: 3 }, + ], + recv: [], + }) + const duplicate = opening(repeated.sent, repeatedPlan.commit.sent[0], 7) + const signed = encodeAttestation(repeated, repeatedPlan, { + sent: [duplicate.hash, duplicate.hash], + recv: [], + }) + expect(() => + correlateAttestation( + 'api.x.com', + repeated, + repeatedPlan, + { sent: [duplicate, duplicate], recv: [] }, + signed, + ), + ).toThrow(/does not identify one hidden range/) + }) + + it.each([ + [ + 'signed transcript length', + (() => { + const changed = attestedData.slice() + new DataView(changed.buffer).setUint32(40, transcript.sent.length + 1) + return changed + })(), + /transcript length/, + ], + [ + 'revealed byte', + (() => { + const changed = transcript.sent.slice() + changed[0] ^= 1 + return encodeAttestation({ ...transcript, sent: changed }, plan, { + sent: sent.map(({ hash }) => hash), + recv: recv.map(({ hash }) => hash), + }) + })(), + /revealed range changed/, + ], + [ + 'signed hash', + encodeAttestation(transcript, plan, { + sent: [new Uint8Array(32), sent[1].hash], + recv: recv.map(({ hash }) => hash), + }), + /signed commitment hash changed/, + ], + [ + 'duplicate signed hash', + encodeAttestation(transcript, plan, { + sent: [sent[0].hash, sent[0].hash], + recv: recv.map(({ hash }) => hash), + }), + /signed commitment hash changed/, + ], + [ + 'missing signed commitment', + encodeAttestation( + transcript, + plan, + { sent: [sent[0].hash], recv: recv.map(({ hash }) => hash) }, + { ...plan.commit, sent: [plan.commit.sent[0]] }, + ), + /commitment count changed/, + ], + [ + 'extra signed commitment', + encodeAttestation( + transcript, + plan, + { + sent: [sent[0].hash, sent[0].hash, sent[1].hash], + recv: recv.map(({ hash }) => hash), + }, + { + ...plan.commit, + sent: [ + { start: 2, end: 3, algorithm: 'SHA256' }, + { start: 3, end: 4, algorithm: 'SHA256' }, + plan.commit.sent[1], + ], + }, + ), + /commitment count changed/, + ], + [ + 'signed commitment range', + encodeAttestation( + transcript, + plan, + { sent: sent.map(({ hash }) => hash), recv: recv.map(({ hash }) => hash) }, + { + ...plan.commit, + sent: [{ start: 2, end: 3, algorithm: 'SHA256' }, plan.commit.sent[1]], + }, + ), + /commitment range changed/, + ], + ] as const)('rejects a changed %s', (_name, signed, reason) => { + expect(() => + correlateAttestation('api.x.com', transcript, plan, { sent, recv }, signed), + ).toThrow(reason) + }) +}) + +it('coalesces adjacent disclosures in both directions before signing [LIBID-PROVER-009]', () => { + const selected = { + sent: [ + { start: 0, end: 2 }, + { start: 2, end: 4 }, + { start: 4, end: 7 }, + ], + recv: [ + { start: 2, end: 5 }, + { start: 5, end: 8 }, + ], + } + const original = structuredClone(selected) + const plan = planNotarization(transcript, selected) + // Expected native RangeSet serialization, independent of the planner's partition. + const native = { + ...plan, + reveal: { + sent: [{ start: 0, end: 7 }], + recv: [{ start: 2, end: 8 }], + server_identity: true as const, + }, + } + expect(plan.reveal).toEqual(native.reveal) + expect(selected).toEqual(original) + const openings = { + sent: plan.commit.sent.map((range, i) => opening(transcript.sent, range, i + 1)), + recv: plan.commit.recv.map((range, i) => opening(transcript.recv, range, i + 4)), + } + const signed = encodeAttestation(transcript, native, { + sent: openings.sent.map((o) => o.hash), + recv: openings.recv.map((o) => o.hash), + }) + expect( + correlateAttestation('api.x.com', transcript, plan, openings, signed).decoded.received.revealed, + ).toHaveLength(1) + const changed = { ...transcript, recv: transcript.recv.slice() } + changed.recv[3] ^= 1 + expect(() => correlateAttestation('api.x.com', changed, plan, openings, signed)).toThrow( + /revealed range changed/, + ) +}) diff --git a/ts/packages/ceremony/src/notary/notarize.ts b/ts/packages/ceremony/src/notary/notarize.ts new file mode 100644 index 00000000..2aaf7ff8 --- /dev/null +++ b/ts/packages/ceremony/src/notary/notarize.ts @@ -0,0 +1,286 @@ +import { sha256 } from '@noble/hashes/sha2.js' +import { keccak_256 } from '@noble/hashes/sha3.js' +import { bytesEqual } from '../primitives.js' +import { type DecodedAttestedData, type DecodedDirection, decodeAttestedData } from './decode.js' +import type { CommitmentOpening } from './session.js' + +const MAX_SENT_BYTES = 4 * 1024 + +const MAX_RECV_BYTES = 32 * 1024 + +export interface ByteRange { + start: number + end: number +} + +export interface Transcript { + sent: Uint8Array + recv: Uint8Array +} + +export interface RevealRanges { + sent: readonly ByteRange[] + recv: readonly ByteRange[] +} + +export interface CommitRange extends ByteRange { + algorithm: 'SHA256' +} + +export interface NotarizationPlan { + reveal: { + sent: ByteRange[] + recv: ByteRange[] + server_identity: true + } + commit: { + sent: CommitRange[] + recv: CommitRange[] + } +} + +export interface HashOpening { + hash: Uint8Array + blinder: Uint8Array +} + +export interface RevealOutput { + sent: readonly HashOpening[] + recv: readonly HashOpening[] +} + +export interface CorrelatedCommitment extends ByteRange, HashOpening {} + +export interface CorrelatedAttestation { + decoded: DecodedAttestedData + sent: readonly CorrelatedCommitment[] + recv: readonly CorrelatedCommitment[] +} + +function invalid(reason: string): never { + throw new Error(`invalid notarization: ${reason}`) +} + +function validateRanges(ranges: readonly ByteRange[], length: number, direction: string): void { + let previousEnd = 0 + for (const range of ranges) { + if ( + !Number.isSafeInteger(range.start) || + !Number.isSafeInteger(range.end) || + range.start < previousEnd || + range.end <= range.start || + range.end > length + ) { + invalid(`${direction} reveal ranges must be sorted, nonempty, nonoverlapping, and in bounds`) + } + previousEnd = range.end + } +} + +function complement(ranges: readonly ByteRange[], length: number): CommitRange[] { + const hidden: CommitRange[] = [] + let start = 0 + for (const range of ranges) { + if (start < range.start) hidden.push({ start, end: range.start, algorithm: 'SHA256' }) + start = range.end + } + if (start < length) hidden.push({ start, end: length, algorithm: 'SHA256' }) + return hidden +} + +// TLSNotary stores disclosed bytes as a range set, merging adjacent intervals. +function mergeAdjacent(ranges: readonly ByteRange[]): ByteRange[] { + const merged: ByteRange[] = [] + for (const { start, end } of ranges) { + const previous = merged.at(-1) + if (previous?.end === start) previous.end = end + else merged.push({ start, end }) + } + return merged +} + +/** Build the exact range objects accepted by TLSNotary's browser `reveal`. */ +export function planNotarization(transcript: Transcript, ranges: RevealRanges): NotarizationPlan { + if (transcript.sent.length > MAX_SENT_BYTES) invalid('sent transcript exceeds 4 KiB') + if (transcript.recv.length > MAX_RECV_BYTES) invalid('received transcript exceeds 32 KiB') + validateRanges(ranges.sent, transcript.sent.length, 'sent') + validateRanges(ranges.recv, transcript.recv.length, 'received') + + const sent = mergeAdjacent(ranges.sent) + const recv = mergeAdjacent(ranges.recv) + return { + reveal: { sent, recv, server_identity: true }, + commit: { + sent: complement(sent, transcript.sent.length), + recv: complement(recv, transcript.recv.length), + }, + } +} + +function sameRange(a: ByteRange, b: ByteRange): boolean { + return a.start === b.start && a.end === b.end +} + +function requireExactPlan(transcript: Transcript, plan: NotarizationPlan): void { + if (plan.reveal.server_identity !== true) invalid('server identity must be revealed') + const expected = planNotarization(transcript, plan.reveal) + for (const direction of ['sent', 'recv'] as const) { + const actual = plan.commit[direction] + const wanted = expected.commit[direction] + if ( + actual.length !== wanted.length || + actual.some( + (range, index) => range.algorithm !== 'SHA256' || !sameRange(range, wanted[index]), + ) + ) { + invalid(`${direction} commitment plan is not the reveal complement`) + } + } +} + +function requireRevealed( + transcript: Uint8Array, + ranges: readonly ByteRange[], + signed: DecodedDirection, + direction: string, +): void { + if (signed.revealed.length !== ranges.length) invalid(`${direction} revealed range count changed`) + for (let index = 0; index < ranges.length; index++) { + const range = ranges[index] + const revealed = signed.revealed[index] + if ( + revealed.start !== range.start || + revealed.bytes.length !== range.end - range.start || + !bytesEqual(revealed.bytes, transcript.subarray(range.start, range.end)) + ) { + invalid(`${direction} revealed range changed`) + } + } +} + +function commitmentHash(transcript: Uint8Array, range: ByteRange, blinder: Uint8Array): Uint8Array { + const input = new Uint8Array(range.end - range.start + blinder.length) + input.set(transcript.subarray(range.start, range.end)) + input.set(blinder, range.end - range.start) + return sha256(input) +} + +function correlateDirection( + transcript: Uint8Array, + planned: readonly CommitRange[], + openings: readonly HashOpening[], + signed: DecodedDirection, + direction: string, +): CorrelatedCommitment[] { + if (signed.commitments.length !== planned.length) { + invalid(`${direction} signed commitment count changed`) + } + for (let index = 0; index < planned.length; index++) { + if (!sameRange(planned[index], signed.commitments[index])) { + invalid(`${direction} signed commitment range changed`) + } + } + const correlated = correlateOpenings(transcript, planned, openings, direction) + for (let index = 0; index < correlated.length; index++) { + if (!bytesEqual(signed.commitments[index].commitment, correlated[index].hash)) { + invalid(`${direction} signed commitment hash changed`) + } + } + return correlated +} + +/** Match unordered provisional openings without treating them as signed evidence. */ +export function correlateOpenings( + transcript: Uint8Array, + planned: readonly CommitRange[], + openings: readonly HashOpening[], + direction: string, +): CorrelatedCommitment[] { + if (openings.length !== planned.length) invalid(`${direction} opening count changed`) + + // ponytail: quadratic scan over the small, fixed profile range sets. + const unmatched = new Set(planned.keys()) + const correlated: CorrelatedCommitment[] = [] + for (const opening of openings) { + if (!(opening.hash instanceof Uint8Array) || opening.hash.length !== 32) { + invalid(`${direction} opening hash must be exactly 32 bytes`) + } + if (!(opening.blinder instanceof Uint8Array) || opening.blinder.length !== 16) { + invalid(`${direction} opening blinder must be exactly 16 bytes`) + } + + const matches = [...unmatched].filter((index) => + bytesEqual(commitmentHash(transcript, planned[index], opening.blinder), opening.hash), + ) + if (matches.length !== 1) invalid(`${direction} opening does not identify one hidden range`) + const index = matches[0] + unmatched.delete(index) + correlated[index] = { + start: planned[index].start, + end: planned[index].end, + hash: opening.hash.slice(), + blinder: opening.blinder.slice(), + } + } + return correlated +} + +/** Validate TLSNotary openings against both the transcript and signed record. */ +export function correlateAttestation( + authority: string, + transcript: Transcript, + plan: NotarizationPlan, + openings: RevealOutput, + attestedData: Uint8Array, +): CorrelatedAttestation { + requireExactPlan(transcript, plan) + const decoded = decodeAttestedData(attestedData) + if (!bytesEqual(decoded.authorityId, keccak_256(new TextEncoder().encode(authority)))) + invalid('attested authority changed') + if ( + decoded.sentTranscriptLength !== transcript.sent.length || + decoded.receivedTranscriptLength !== transcript.recv.length + ) { + invalid('signed transcript length changed') + } + requireRevealed(transcript.sent, plan.reveal.sent, decoded.sent, 'sent') + requireRevealed(transcript.recv, plan.reveal.recv, decoded.received, 'received') + return { + decoded, + sent: correlateDirection( + transcript.sent, + plan.commit.sent, + openings.sent, + decoded.sent, + 'sent', + ), + recv: correlateDirection( + transcript.recv, + plan.commit.recv, + openings.recv, + decoded.received, + 'received', + ), + } +} + +/** Select one provisional opening and reconstruct SHA256(bearer || blinder) for the link witness. */ +export function bearerOpening( + openings: readonly CommitmentOpening[], + direction: 'sent' | 'received', + range: ByteRange, + bearer: string, +) { + const matches = openings.filter( + (o) => o.direction === direction && o.start === range.start && o.end === range.end, + ) + if (matches.length !== 1) throw new Error('Bearer opening is not unique') + const opening = matches[0], + bytes = new TextEncoder().encode(bearer), + preimage = new Uint8Array(bytes.length + 16) + if (opening.blinder.length !== 16 || opening.end - opening.start !== bytes.length) + throw new Error('Invalid bearer opening') + preimage.set(bytes) + preimage.set(opening.blinder, bytes.length) + return { ...opening, hash: sha256(preimage) } +} diff --git a/ts/packages/ceremony/src/notary/notary.assets.ts b/ts/packages/ceremony/src/notary/notary.assets.ts new file mode 100644 index 00000000..8155f448 --- /dev/null +++ b/ts/packages/ceremony/src/notary/notary.assets.ts @@ -0,0 +1,24 @@ +import * as assets from '../assets/index.js' + +const release = assets.archive( + 'https://github.com/libid-org/notary/releases/download/v0.3.0-rc.3/tlsn-wasm-0.3.0-rc.3.tar.gz', + // Preserve the original release mount's immutable worker policy. + 'tlsn/v0.3.0-rc.3-csp2', +) + +export const tlsnModule = release.member('tlsn_wasm.js', { + ...assets.headers.immutable, + ...assets.headers.javascript, +}) + +export const tlsnWasm = release.member('tlsn_wasm_bg.wasm', { + ...assets.headers.immutable, + ...assets.headers.wasm, +}) + +export const tlsnSpawn = release.member('snippets/web-spawn-*/js/spawn.js', { + ...assets.headers.immutable, + ...assets.headers.executionWorker, +}) + +export const notaryAssets = [tlsnModule, tlsnWasm, tlsnSpawn] as const diff --git a/ts/packages/ceremony/src/notary/session.test.ts b/ts/packages/ceremony/src/notary/session.test.ts new file mode 100644 index 00000000..90c1f94c --- /dev/null +++ b/ts/packages/ceremony/src/notary/session.test.ts @@ -0,0 +1,262 @@ +import { readFileSync } from 'node:fs' +import { afterEach, expect, it, vi } from 'vitest' +import { type OperationEvent, validateEvent } from '../events.js' +import { decodeAttestedData } from './decode.js' +import { type ExactHttpRequest, Notarization } from './session.js' + +vi.mock('virtual:ceremony-assets', () => ({ urls: {} })) + +vi.mock('../assets/index.js', async (original) => ({ + ...(await original()), + resolve: () => 'https://ccdp.test/asset', +})) + +const ports: MessagePort[] = [] + +afterEach(() => { + for (const port of ports.splice(0)) port.close() + vi.unstubAllGlobals() +}) + +function runtime() { + const messages: { type: string; url: string; notaryAddress: string; port: MessagePort }[] = [] + const terminate = vi.fn() + const worker = vi.fn(() => ({ + terminate, + postMessage(message: (typeof messages)[number]) { + messages.push(message) + ports.push(message.port) + }, + })) + vi.stubGlobal('Worker', worker) + return { messages, terminate, worker } +} + +const target = 'https://api.x.com/2/users/me' + +const request: ExactHttpRequest = { + url: target, + method: 'GET', + headers: {}, + body: new Uint8Array(), +} + +it.each(['https://notary.lib.id', 'https://testnet.notary.lib.id', 'https://localhost:4687'])( + 'starts the selected notary only, without retrying another network: %s [LIBID-PROVER-008]', + async (notaryAddress) => { + const { messages, terminate } = runtime() + const pending = new Notarization(notaryAddress, new AbortController().signal).prepare(target) + messages[0].port.postMessage({ type: 'error' }) + await expect(pending).rejects.toThrow('Notarization failed') + expect(messages).toHaveLength(1) + expect(messages[0]).toMatchObject({ type: 'prepare', notaryAddress }) + expect(terminate).toHaveBeenCalledOnce() + }, +) + +it('rejects invalid origins, targets and pre-aborted work before creating a worker', async () => { + const { worker } = runtime() + for (const address of ['http://notary.test', 'https://notary.test/', 'https://notary.test/path']) + expect(() => new Notarization(address, new AbortController().signal)).toThrow() + const notary = new Notarization('https://notary.test', new AbortController().signal) + for (const url of ['http://api.x.com/', 'https://api.x.com/#fragment', 'https://api.x.com:444/']) + await expect(notary.prepare(url)).rejects.toThrow('Invalid notarization target') + expect(() => new Notarization('https://notary.test', AbortSignal.abort())).toThrow() + expect(worker).not.toHaveBeenCalled() +}) + +it('shares one worker, routes overlapping replies per session and keeps it alive until ceremony cleanup', async () => { + const { messages, worker, terminate } = runtime() + const abort = new AbortController() + const notary = new Notarization('https://notary.test', abort.signal) + const first = notary.prepare(target), + second = notary.prepare(target) + expect(worker).toHaveBeenCalledOnce() + const [one, two] = messages.map((message) => message.port) + two.postMessage({ type: 'prepared' }) + one.postMessage({ type: 'prepared' }) + const [token, identity] = await Promise.all([first, second]) + const a = token.send(request), + b = identity.send(request) + const transcript = (value: number) => ({ + sent: new Uint8Array([value]), + received: new Uint8Array(), + }) + two.postMessage({ type: 'sent', transcript: transcript(2) }) + one.postMessage({ type: 'sent', transcript: transcript(1) }) + await expect(a).resolves.toEqual(transcript(1)) + await expect(b).resolves.toEqual(transcript(2)) + const reveal = token.reveal({ sent: [], received: [] }) + one.postMessage({ type: 'revealed', openings: [] }) + const result = await reveal + one.postMessage({ type: 'attestation', attestation: { fixture: 'token' } }) + await expect(result.attestation).resolves.toEqual({ fixture: 'token' }) + expect(terminate).not.toHaveBeenCalled() + // The other session still works after the first releases its message channel. + const other = identity.reveal({ sent: [], received: [] }) + two.postMessage({ type: 'revealed', openings: [] }) + const final = await other + two.postMessage({ type: 'attestation', attestation: { fixture: 'identity' } }) + await expect(final.attestation).resolves.toEqual({ fixture: 'identity' }) + expect(terminate).not.toHaveBeenCalled() + abort.abort() + expect(terminate).toHaveBeenCalledOnce() + await expect(notary.prepare(target)).rejects.toThrow() +}) + +it.each(['abort', 'session-error'])( + '%s rejects sibling preparation and pending attestations and terminates the shared worker', + async (failure) => { + const { messages, terminate } = runtime() + const abort = new AbortController() + const events: OperationEvent[] = [] + const notary = new Notarization('https://notary.test', abort.signal, (event) => + events.push(event), + ) + const ready = notary.prepare(target, 'token-attestation') + const port = messages[0].port + port.postMessage({ type: 'prepared' }) + const session = await ready + const sent = session.send(request) + port.postMessage({ + type: 'sent', + transcript: { sent: new Uint8Array(), received: new Uint8Array() }, + }) + await sent + const revealing = session.reveal({ sent: [], received: [] }) + port.postMessage({ type: 'revealed', openings: [] }) + const { attestation } = await revealing + const pending = notary.prepare(target) + const checks = Promise.all([ + expect(attestation).rejects.toThrow(), + expect(pending).rejects.toThrow(), + ]) + if (failure === 'abort') abort.abort() + else port.postMessage({ type: 'error' }) + await checks + expect(events).toEqual([ + expect.objectContaining({ event: 'token-attestation', phase: 'started' }), + ]) + expect(terminate).toHaveBeenCalledOnce() + await expect(session.send(request)).rejects.toThrow() + }, +) + +it('exposes late worker failure after preparation through the runtime signal [LIBID-PROVER-018]', async () => { + const { messages, worker, terminate } = runtime() + const parent = new AbortController() + const notary = new Notarization('https://notary.test', parent.signal) + const prepared = notary.prepare(target) + messages[0].port.postMessage({ type: 'prepared' }) + const session = await prepared + expect(notary.signal.aborted).toBe(false) + worker.mock.results[0].value.onerror({ message: 'Notary worker failed' }) + expect(notary.signal.aborted).toBe(true) + expect(notary.signal.reason).toMatchObject({ message: 'Notary worker failed' }) + expect(parent.signal.aborted).toBe(false) + expect(terminate).toHaveBeenCalledOnce() + await expect(session.send(request)).rejects.toMatchObject({ message: 'Notary worker failed' }) +}) + +it.each([ + { event: 'token-attestation', response: 'HTTP/1.1 200 OK\r\n\r\n\r\n\r\n', headerBytes: 19 }, + { event: 'identity-attestation', response: 'HTTP/1.1 200 OK\r\nX: é\r\n\r\n', headerBytes: 26 }, + { event: 'token-attestation', response: 'No header boundary', headerBytes: undefined }, +])( + 'measures $event response $response and openings separately from finalization without exposing evidence or blocking delivery [LIBID-PROVER-007]', + async ({ event, response, headerBytes }) => { + let clock = 0 + vi.stubGlobal('performance', { timeOrigin: 10000, now: () => clock }) + const { messages } = runtime() + const events: OperationEvent[] = [] + const abort = new AbortController() + const notary = new Notarization('https://notary.test', abort.signal, (event) => { + events.push(event) + throw new Error('Broken diagnostic observer') + }) + try { + const ready = notary.prepare(target, event) + const port = messages[0].port + port.postMessage({ type: 'prepared' }) + const session = await ready + const sent = session.send(request) + const received = new Uint8Array(40) + received.set(new TextEncoder().encode(response)) + port.postMessage({ + type: 'sent', + transcript: { sent: new Uint8Array(60), received }, + }) + await sent + clock = 10 + const pending = session.reveal({ sent: [], received: [] }) + clock = 160 + port.postMessage({ type: 'revealed', openings: [] }) + const revealed = await pending + expect(events).toEqual([{ event, phase: 'started', timestamp: 10010 }]) + const attestedData = Uint8Array.from( + Buffer.from( + readFileSync( + new URL('./libid-rs-239a4bb-attested-data.fixture.hex', import.meta.url), + 'utf8', + ).trim(), + 'hex', + ), + ) + const attestation = { + attestedData, + signature: new Uint8Array(65), + decoded: decodeAttestedData(attestedData), + } + clock = 190 + port.postMessage({ type: 'attestation', attestation }) + await expect(revealed.attestation).resolves.toEqual(attestation) + expect(events).toEqual([ + { event, phase: 'started', timestamp: 10010 }, + { + event, + phase: 'finished', + timestamp: 10190, + instrumentation: { + attributes: { + 'openings-ms': 150, + 'finalization-ms': 30, + 'sent-bytes': 60, + 'received-bytes': 40, + ...(headerBytes === undefined + ? {} + : { + 'response-header-bytes': headerBytes, + 'response-body-bytes': 40 - headerBytes, + }), + 'committed-sent-bytes': 20, + 'committed-received-bytes': 30, + 'commitment-count': 2, + }, + }, + }, + ]) + for (const emitted of events) expect(() => validateEvent(emitted)).not.toThrow() + } finally { + abort.abort() + } + }, +) + +it('rejects reveal when its start event synchronously aborts the session', async () => { + const { messages, terminate } = runtime() + const abort = new AbortController() + const reason = new Error('Connection ended during event forwarding') + const notary = new Notarization('https://notary.test', abort.signal, () => abort.abort(reason)) + const ready = notary.prepare(target, 'token-attestation') + const port = messages[0].port + port.postMessage({ type: 'prepared' }) + const session = await ready + const sent = session.send(request) + port.postMessage({ + type: 'sent', + transcript: { sent: new Uint8Array(), received: new Uint8Array() }, + }) + await sent + await expect(session.reveal({ sent: [], received: [] })).rejects.toBe(reason) + expect(terminate).toHaveBeenCalledOnce() +}) diff --git a/ts/packages/ceremony/src/notary/session.ts b/ts/packages/ceremony/src/notary/session.ts new file mode 100644 index 00000000..a63df3cc --- /dev/null +++ b/ts/packages/ceremony/src/notary/session.ts @@ -0,0 +1,252 @@ +import { resolve as resolveAsset } from '../assets/index.js' +import { now, type OperationEvent } from '../events.js' +import { origin, webUrl } from '../primitives.js' +import type { NotaryAttestation } from './decode.js' +import type { ByteRange } from './notarize.js' +import { tlsnModule, tlsnWasm } from './notary.assets.js' + +export interface ExactHttpRequest { + url: string + method: 'GET' | 'POST' + headers: Readonly> + body: Uint8Array +} + +export interface Transcript { + sent: Uint8Array + received: Uint8Array +} + +export interface Reveals { + sent: readonly ByteRange[] + received: readonly ByteRange[] +} + +export interface CommitmentOpening extends ByteRange { + direction: 'sent' | 'received' + blinder: Uint8Array +} + +/** Correlated provisional openings plus a separate promise for the final attestation. */ +export interface RevealResult { + openings: readonly CommitmentOpening[] + attestation: Promise +} + +export interface NotarizationSession { + /** Send one exact request after setup and return its original transcript bytes. */ + send(request: ExactHttpRequest): Promise + /** Reveal once after send; proof preparation may use openings before attestation completes. */ + reveal(reveals: Reveals): Promise +} + +/** + * One ceremony-owned WASM runtime and thread pool, with a separate TLS session per prepare. + * The supplied abort signal releases the worker; any session failure also aborts sibling work. + * Final outputs preserve signed bytes and correlate openings without verifying notary signatures. + */ +export class Notarization { + #worker?: Worker + #failure = new AbortController() + /** Caller cancellation combined with runtime failure, including failures after prepare resolves. */ + readonly signal: AbortSignal + + constructor( + private readonly notaryAddress: string, + signal: AbortSignal, + private readonly emit?: (event: OperationEvent) => void, + ) { + signal.throwIfAborted() + if (!origin(notaryAddress)) throw new TypeError('Invalid notary origin') + this.signal = AbortSignal.any([signal, this.#failure.signal]) + } + + /** Start target-specific setup without a bearer; event names the later reveal/attestation operation. */ + async prepare(url: string, event?: string): Promise { + const emit = this.emit + const responseSizes: Record = {} + function report( + phase: 'started' | 'finished', + timestamp: number, + attributes?: Record, + ) { + if (!emit || !event) return + try { + emit({ + event, + phase, + timestamp, + ...(attributes ? { instrumentation: { attributes } } : {}), + }) + } catch { + // Observers cannot change the session outcome. + } + } + const signal = this.signal + signal.throwIfAborted() + if ( + !webUrl(url) || + new URL(url).protocol !== 'https:' || + new URL(url).hash || + new URL(url).port + ) + throw new TypeError('Invalid notarization target') + if (!this.#worker) { + const worker = new Worker(new URL('./session.worker.ts', import.meta.url), { type: 'module' }) + this.#worker = worker + signal.addEventListener('abort', () => worker.terminate(), { once: true }) + worker.onerror = (event) => + this.#failure.abort(new Error(event.message || 'Notary worker failed')) + } + const worker = this.#worker + const { port1: port, port2 } = new MessageChannel() + const failRuntime = (error: unknown) => this.#failure.abort(error) + let stage = 'preparing', + ended = false + const waiters = new Map< + string, + { resolve: (v: unknown) => void; reject: (e: unknown) => void } + >() + function wait(type: string): Promise { + const promise = new Promise((resolve, reject) => + waiters.set(type, { resolve: (v) => resolve(v as T), reject }), + ) + void promise.catch(() => {}) + return promise + } + function cleanup() { + ended = true + port.close() + signal.removeEventListener('abort', abort) + } + function fail(error: unknown) { + if (ended) return + for (const w of waiters.values()) w.reject(error) + waiters.clear() + cleanup() + failRuntime(error) + } + function abort() { + fail(signal.reason) + } + signal.addEventListener('abort', abort, { once: true }) + port.onmessageerror = () => fail(new Error('Invalid notarization message')) + port.onmessage = (event) => { + if (ended) return + if (event.data.type === 'error') { + fail( + new Error( + typeof event.data.message === 'string' ? event.data.message : 'Notarization failed', + ), + ) + return + } + const waiter = waiters.get(event.data.type) + if (!waiter) { + fail(new Error('Unexpected notarization result')) + return + } + waiters.delete(event.data.type) + waiter.resolve(event.data) + if (event.data.type === 'attestation') cleanup() + } + const prepared = wait('prepared') + try { + worker.postMessage( + { + type: 'prepare', + url, + moduleUrl: resolveAsset(tlsnModule), + wasmUrl: resolveAsset(tlsnWasm), + notaryAddress: this.notaryAddress, + port: port2, + }, + [port2], + ) + await prepared + signal.throwIfAborted() + } catch (error) { + port2.close() + fail(error) + throw error + } + stage = 'prepared' + return { + async send(request) { + signal.throwIfAborted() + if (ended || stage !== 'prepared' || request.url !== url) + throw new Error('Invalid notarization send') + stage = 'sending' + const result = wait<{ transcript: Transcript }>('sent') + try { + port.postMessage({ type: 'send', request }) + const value = await result + if (emit && event) { + const bytes = value.transcript.received + const end = bytes.findIndex( + (byte, i) => + byte === 13 && bytes[i + 1] === 10 && bytes[i + 2] === 13 && bytes[i + 3] === 10, + ) + // Raw wire sizes: headers include status/separator; body includes any chunk framing. + if (end >= 0) { + responseSizes['response-header-bytes'] = end + 4 + responseSizes['response-body-bytes'] = bytes.length - end - 4 + } + } + stage = 'sent' + return value.transcript + } catch (error) { + fail(error) + throw error + } + }, + async reveal(reveals) { + signal.throwIfAborted() + if (ended || stage !== 'sent') throw new Error('Invalid notarization reveal') + stage = 'revealing' + const started = now() + report('started', started) + signal.throwIfAborted() + const result = wait<{ openings: CommitmentOpening[] }>('revealed') + const attestation = wait<{ attestation: NotaryAttestation }>('attestation').then( + (v) => v.attestation, + ) + void attestation.catch(() => {}) + try { + port.postMessage({ type: 'reveal', reveals }) + const openings = (await result).openings + const opened = now() + // Parent-side intervals include worker delivery/correlation, not just TLSN execution. + if (emit && event) + void attestation.then( + ({ decoded }) => { + const timestamp = now() + report('finished', timestamp, { + 'openings-ms': opened - started, + 'finalization-ms': timestamp - opened, + 'sent-bytes': decoded.sentTranscriptLength, + 'received-bytes': decoded.receivedTranscriptLength, + ...responseSizes, + 'committed-sent-bytes': decoded.sent.commitments.reduce( + (sum, r) => sum + r.end - r.start, + 0, + ), + 'committed-received-bytes': decoded.received.commitments.reduce( + (sum, r) => sum + r.end - r.start, + 0, + ), + 'commitment-count': + decoded.sent.commitments.length + decoded.received.commitments.length, + }) + }, + () => {}, + ) + return { openings, attestation } + } catch (error) { + fail(error) + throw error + } + }, + } + } +} diff --git a/ts/packages/ceremony/src/notary/session.worker.test.ts b/ts/packages/ceremony/src/notary/session.worker.test.ts new file mode 100644 index 00000000..856cf662 --- /dev/null +++ b/ts/packages/ceremony/src/notary/session.worker.test.ts @@ -0,0 +1,292 @@ +import { afterEach, expect, it, vi } from 'vitest' + +class Socket extends EventTarget { + static OPEN = 1 + static CONNECTING = 0 + readyState = 1 + binaryType = '' + + send() {} + + close() { + this.readyState = 3 + this.dispatchEvent(new Event('close')) + } +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.resetModules() +}) + +async function worker(sent: number, recv: number) { + let receive!: (event: { data: unknown }) => void + const postMessage = vi.fn(), + close = vi.fn() + const port = { postMessage, close, onmessage: null as null | typeof receive } + vi.stubGlobal('self', { + postMessage, + close, + addEventListener: (_type: string, handler: typeof receive) => { + receive = handler + }, + }) + vi.stubGlobal('navigator', { hardwareConcurrency: 4 }) + vi.stubGlobal('WebSocket', Socket) + await import('./session.worker.js') + const source = `export default async()=>{};export async function initialize(){};export class Prover {async setup(){}async send_request(){}transcript(){return{sent:new Uint8Array(${sent}),recv:new Uint8Array(${recv})}}async reveal(){return{sent:[],recv:[]}}async finish(){}}` + receive({ + data: { + type: 'prepare', + port, + url: 'https://api.x.com/2/users/me', + moduleUrl: `data:text/javascript,${encodeURIComponent(source)}`, + wasmUrl: 'unused', + notaryAddress: 'https://notary.test', + }, + }) + await expect.poll(() => postMessage.mock.calls.some(([m]) => m.type === 'prepared')).toBe(true) + port.onmessage!({ + data: { + type: 'send', + request: { + url: 'https://api.x.com/2/users/me', + method: 'GET', + headers: {}, + body: new Uint8Array(), + }, + }, + }) + return { postMessage, close, receive: port.onmessage! } +} + +it.each([ + [4096, 32768, true], + [4097, 0, false], + [0, 32769, false], +])( + 'checks actual SDK transcript lengths %i/%i before resolving send [LIBID-PROVER-008]', + async (sent, recv, accepted) => { + const result = await worker(sent, recv) + await expect + .poll(() => + result.postMessage.mock.calls.some(([m]) => m.type === (accepted ? 'sent' : 'error')), + ) + .toBe(true) + if (!accepted) { + expect(result.postMessage.mock.calls.some(([m]) => m.type === 'sent')).toBe(false) + expect(result.close).toHaveBeenCalledOnce() + } + }, +) + +it('missing final EOF terminates rather than hanging indefinitely', async () => { + const result = await worker(0, 0) + await expect.poll(() => result.postMessage.mock.calls.some(([m]) => m.type === 'sent')).toBe(true) + vi.useFakeTimers() + result.receive({ data: { type: 'reveal', reveals: { sent: [], received: [] } } }) + await vi.advanceTimersByTimeAsync(30001) + expect(result.postMessage.mock.calls.some(([m]) => m.type === 'error')).toBe(true) + expect(result.postMessage.mock.calls.some(([m]) => m.type === 'attestation')).toBe(false) + expect(result.close).toHaveBeenCalledOnce() +}) + +it('initializes one WASM pool and overlaps setup while keeping per-session transcripts', async () => { + let receive!: (event: { data: unknown }) => void + const close = vi.fn() + vi.stubGlobal('self', { + close, + addEventListener: (_: string, handler: typeof receive) => { + receive = handler + }, + }) + vi.stubGlobal('navigator', { hardwareConcurrency: 4 }) + vi.stubGlobal('WebSocket', Socket) + let finishSetup!: () => void + const gate = new Promise((resolve) => { + finishSetup = resolve + }) + const hooks = { init: vi.fn(), initialize: vi.fn(), setup: vi.fn(() => gate) } + vi.stubGlobal('tlsnTest', hooks) + await import('./session.worker.js') + const source = `export default async()=>globalThis.tlsnTest.init();export async function initialize(){globalThis.tlsnTest.initialize()}let id=0;export class Prover {constructor(){this.id=++id}async setup(){await globalThis.tlsnTest.setup()}async send_request(){}transcript(){return{sent:[this.id],recv:[]}}}` + const ports = [0, 1].map(() => ({ + postMessage: vi.fn(), + close: vi.fn(), + onmessage: null as null | typeof receive, + })) + for (const port of ports) + receive({ + data: { + type: 'prepare', + port, + url: 'https://api.x.com/2/users/me', + moduleUrl: `data:text/javascript,${encodeURIComponent(source)}`, + wasmUrl: 'unused', + notaryAddress: 'https://notary.test', + }, + }) + // Both setups enter before either is allowed to finish; no global session lock. + await expect.poll(() => hooks.setup.mock.calls.length).toBe(2) + expect(hooks.init).toHaveBeenCalledOnce() + expect(hooks.initialize).toHaveBeenCalledOnce() + expect(ports.every((port) => port.postMessage.mock.calls.length === 0)).toBe(true) + finishSetup() + await expect + .poll(() => ports.every((port) => port.postMessage.mock.calls.length === 1)) + .toBe(true) + for (const port of ports) + port.onmessage!({ + data: { + type: 'send', + request: { + url: 'https://api.x.com/2/users/me', + method: 'GET', + headers: {}, + body: new Uint8Array(), + }, + }, + }) + await expect + .poll(() => ports.every((port) => port.postMessage.mock.calls.length === 2)) + .toBe(true) + expect(ports.map((port) => port.postMessage.mock.calls[1][0].transcript.sent[0])).toEqual([1, 2]) + expect(close).not.toHaveBeenCalled() +}) + +async function preparing() { + let receive!: (event: { data: unknown }) => void + let socket!: Socket + let resolve!: () => void + let reject!: (error: Error) => void + const gate = new Promise((ok, fail) => { + resolve = ok + reject = fail + }) + const hooks = { + init: vi.fn(() => gate), + setup: vi.fn<(io: { write(data: Uint8Array): Promise }) => void>(), + } + const port = { postMessage: vi.fn(), close: vi.fn() } + vi.stubGlobal('self', { + addEventListener: (_: string, handler: typeof receive) => { + receive = handler + }, + }) + vi.stubGlobal('navigator', { hardwareConcurrency: 4 }) + vi.stubGlobal('tlsnConnectTest', hooks) + vi.stubGlobal( + 'WebSocket', + class extends Socket { + constructor() { + super() + this.readyState = Socket.CONNECTING + socket = this + } + }, + ) + await import('./session.worker.js') + const source = `export default async()=>globalThis.tlsnConnectTest.init();export async function initialize(){};export class Prover {async setup(io){globalThis.tlsnConnectTest.setup(io)}}` + receive({ + data: { + type: 'prepare', + port, + url: 'https://api.x.com/2/users/me', + moduleUrl: `data:text/javascript,${encodeURIComponent(source)}`, + wasmUrl: 'unused', + notaryAddress: 'https://notary.test', + }, + }) + await expect.poll(() => hooks.init.mock.calls.length).toBe(1) + expect(socket).toBeDefined() + return { + hooks, + port, + socket, + resolve, + reject, + open() { + socket.readyState = Socket.OPEN + socket.dispatchEvent(new Event('open')) + }, + } +} + +it.each(['runtime', 'socket'])( + 'overlaps runtime and socket startup when %s finishes first [LIBID-PROVER-018]', + async (first) => { + const w = await preparing() + if (first === 'runtime') w.resolve() + else w.open() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(w.hooks.setup).not.toHaveBeenCalled() + expect(w.port.postMessage).not.toHaveBeenCalled() + if (first === 'runtime') w.open() + else w.resolve() + await expect.poll(() => w.port.postMessage.mock.calls.length).toBe(1) + expect(w.port.postMessage).toHaveBeenCalledWith({ type: 'prepared' }) + expect(w.hooks.setup).toHaveBeenCalledOnce() + }, +) + +it.each(['runtime', 'socket-error', 'socket-close'])( + '%s failure retires preparation without waiting for its sibling [LIBID-PROVER-018]', + async (failure) => { + const w = await preparing() + if (failure === 'runtime') w.reject(new Error('WASM failed')) + else if (failure === 'socket-error') w.socket.dispatchEvent(new Event('error')) + else w.socket.close() + await expect.poll(() => w.port.close.mock.calls.length).toBe(1) + expect(w.port.postMessage).toHaveBeenCalledExactlyOnceWith({ + type: 'error', + message: expect.any(String), + }) + expect(w.socket.readyState).toBe(3) + w.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(w.hooks.setup).not.toHaveBeenCalled() + expect(w.port.postMessage).toHaveBeenCalledOnce() + }, +) + +it('rejects a socket closed while runtime initialization was pending [LIBID-PROVER-018]', async () => { + const w = await preparing() + w.open() + w.socket.close() + w.resolve() + await expect.poll(() => w.port.close.mock.calls.length).toBe(1) + expect(w.port.postMessage).toHaveBeenCalledExactlyOnceWith({ + type: 'error', + message: expect.any(String), + }) + expect(w.hooks.setup).not.toHaveBeenCalled() +}) + +it.each(['closed', 'send throws'])( + 'surfaces %s socket writes to the SDK without awaiting its discarded Promise', + async (failure) => { + const w = await preparing() + w.open() + w.resolve() + await expect.poll(() => w.hooks.setup.mock.calls.length).toBe(1) + const [io] = w.hooks.setup.mock.calls[0] + const expected = new Error( + failure === 'closed' ? 'notary WebSocket is not open' : 'Socket send failed', + ) + if (failure === 'closed') w.socket.readyState = 3 + else + w.socket.send = () => { + throw expected + } + let caught: unknown + try { + // The pinned SDK catches synchronous throws but does not await write promises. + // Consume a rejected promise only to keep the failing regression test handled. + void io.write(new Uint8Array([1])).catch(() => {}) + } catch (error) { + caught = error + } + expect(caught).toEqual(expected) + }, +) diff --git a/ts/packages/ceremony/src/notary/session.worker.ts b/ts/packages/ceremony/src/notary/session.worker.ts new file mode 100644 index 00000000..aa47d5ea --- /dev/null +++ b/ts/packages/ceremony/src/notary/session.worker.ts @@ -0,0 +1,305 @@ +import { errorMessage } from '../errors.js' +import { + correlateAttestation, + correlateOpenings, + type HashOpening, + planNotarization, + type Transcript, +} from './notarize.js' +import type { ExactHttpRequest, Reveals } from './session.js' +import { + decodeAttestationFrame, + deriveNotaryWebSocketUrl, + MAX_FRAME_PAYLOAD_BYTES, +} from './transport.js' + +interface Io { + read(): Promise + write(data: Uint8Array): Promise + close(): Promise +} + +export interface NotaryHttpRequest { + uri: string + method: 'GET' | 'POST' | 'PUT' | 'DELETE' + headers: Record + body: unknown +} + +export interface NotaryHttpResponse { + status: number + headers: [string, number[]][] +} + +interface TlsnModule { + default(options: { module_or_path: string }): Promise + initialize(logging: null, threads: number): Promise + Prover: new (config: { + server_name: string + mode: 'Proxy' + max_sent_data: number + max_recv_data: number + network: 'Bandwidth' + }) => { + setup(io: Io): Promise + send_request(session: null, request: NotaryHttpRequest): Promise + transcript(): Transcript + reveal( + reveal: ReturnType['reveal'], + commit: ReturnType['commit'], + ): Promise<{ sent: HashOpening[]; recv: HashOpening[] }> + finish(): Promise + free(): void + } +} + +function waitForOpen(socket: WebSocket): Promise { + if (socket.readyState === WebSocket.OPEN) return Promise.resolve() + return new Promise((resolve, reject) => { + const cleanup = () => { + socket.removeEventListener('open', opened) + socket.removeEventListener('error', failed) + socket.removeEventListener('close', failed) + } + const opened = () => { + cleanup() + resolve() + } + const failed = () => { + cleanup() + reject(new Error('notary WebSocket failed to open')) + } + socket.addEventListener('open', opened) + socket.addEventListener('error', failed) + socket.addEventListener('close', failed) + }) +} + +function socketIo(socket: WebSocket): Io { + const chunks: Uint8Array[] = [] + const readers: Array<{ + resolve(value: Uint8Array | null): void + reject(reason: Error): void + }> = [] + let ended: null | Error = null + let closed = false + + const settle = (error: Error | null) => { + if (closed || ended) return + if (error) ended = error + else closed = true + while (readers.length) { + const reader = readers.shift()! + if (error) reader.reject(error) + else reader.resolve(null) + } + } + + socket.binaryType = 'arraybuffer' + socket.addEventListener('message', (event) => { + if (!(event.data instanceof ArrayBuffer)) + return settle(new Error('notary sent non-binary data')) + const chunk = new Uint8Array(event.data) + const reader = readers.shift() + if (reader) reader.resolve(chunk) + else chunks.push(chunk) + }) + socket.addEventListener('error', () => settle(new Error('notary WebSocket failed'))) + socket.addEventListener('close', () => settle(null)) + + return { + read() { + const chunk = chunks.shift() + if (chunk) return Promise.resolve(chunk) + if (ended) return Promise.reject(ended) + if (closed) return Promise.resolve(null) + return new Promise((resolve, reject) => readers.push({ resolve, reject })) + }, + write(data) { + // The pinned SDK catches synchronous throws but discards write promises. + if (socket.readyState !== WebSocket.OPEN) { + throw new Error('notary WebSocket is not open') + } + socket.send(data) + return Promise.resolve() + }, + close() { + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { + socket.close() + } + return Promise.resolve() + }, + } +} + +async function readFinalFrame(io: Io): Promise { + const chunks: Uint8Array[] = [] + let length = 0 + for (;;) { + const chunk = await io.read() + if (chunk === null) break + length += chunk.length + if (length > MAX_FRAME_PAYLOAD_BYTES + 4) { + throw new Error('notary attestation frame exceeds size limit') + } + chunks.push(chunk) + } + const frame = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + frame.set(chunk, offset) + offset += chunk.length + } + return frame +} + +// The module and its thread pool are initialized once for this ceremony's sessions. +let runtime: Promise | undefined + +function initialize(data: Record): Promise { + runtime ??= (async () => { + const tlsn = (await import(/* @vite-ignore */ String(data.moduleUrl))) as TlsnModule + await tlsn.default({ module_or_path: String(data.wasmUrl) }) + await tlsn.initialize(null, Math.min(navigator.hardwareConcurrency || 1, 4)) + return tlsn + })() + return runtime +} + +function session(port: MessagePort, initial: Record) { + let prover: InstanceType | undefined + let io: Io | undefined + let transcript: Transcript | undefined + let target = '' + let stage = 'new' + function reply(value: unknown) { + port.postMessage(value) + } + async function work(data: Record) { + if (data.type === 'prepare' && stage === 'new') { + stage = 'preparing' + target = String(data.url) + // Socket connection and shared WASM initialization overlap; TLS setup needs both. + const socket = new WebSocket(deriveNotaryWebSocketUrl(String(data.notaryAddress))) + io = socketIo(socket) + const [tlsn] = await Promise.all([initialize(data), waitForOpen(socket)]) + // Opening earlier is insufficient if the peer disconnected while WASM initialized. + if (socket.readyState !== WebSocket.OPEN) throw new Error('notary WebSocket closed') + prover = new tlsn.Prover({ + server_name: new URL(target).hostname, + mode: 'Proxy', + max_sent_data: 4096, + max_recv_data: 32768, + network: 'Bandwidth', + }) + await prover.setup(io) + stage = 'prepared' + reply({ type: 'prepared' }) + return + } + if (data.type === 'send' && stage === 'prepared' && prover) { + stage = 'sending' + const request = data.request as ExactHttpRequest + if (request.url !== target) throw new Error('Request target changed') + const url = new URL(target) + await prover.send_request(null, { + uri: url.pathname + url.search, + method: request.method, + headers: Object.fromEntries( + Object.entries(request.headers).map(([k, v]) => [k, Array.from(v)]), + ), + body: request.body.length + ? new TextDecoder('utf-8', { fatal: true }).decode(request.body) + : null, + }) + const raw = prover.transcript() + // Proxy setup limits are not enforced by the pinned SDK. This bounds acceptance + // before parsing/reveal, not memory or traffic consumed while receiving. + if (raw.sent.length > 4096 || raw.recv.length > 32768) + throw new Error('Transcript acceptance limit exceeded') + transcript = { sent: Uint8Array.from(raw.sent), recv: Uint8Array.from(raw.recv) } + stage = 'sent' + reply({ type: 'sent', transcript: { sent: transcript.sent, received: transcript.recv } }) + return + } + if (data.type === 'reveal' && stage === 'sent' && prover && transcript && io) { + stage = 'revealing' + const reveals = data.reveals as Reveals + const plan = planNotarization(transcript, { sent: reveals.sent, recv: reveals.received }) + const raw = await prover.reveal(plan.reveal, plan.commit) + const openings = [] + for (const direction of ['sent', 'recv'] as const) { + raw[direction] = raw[direction].map((o) => ({ + hash: Uint8Array.from(o.hash), + blinder: Uint8Array.from(o.blinder), + })) + for (const { start, end, blinder } of correlateOpenings( + transcript[direction], + plan.commit[direction], + raw[direction], + direction, + )) { + openings.push({ + direction: direction === 'recv' ? 'received' : 'sent', + start, + end, + blinder, + }) + } + } + reply({ type: 'revealed', openings }) + // Final channel completion has a deadline independent of proof generation. + const finalIo = io + let timer: ReturnType | undefined + let frame: Uint8Array + try { + frame = await Promise.race([ + (async () => { + await prover!.finish() + return readFinalFrame(finalIo) + })(), + new Promise((_, reject) => { + timer = setTimeout(() => { + void finalIo.close() + reject(new Error('Notary finalization timed out')) + }, 30000) + }), + ]) + } finally { + clearTimeout(timer) + } + const wire = decodeAttestationFrame(frame) + const { decoded } = correlateAttestation( + new URL(target).hostname, + transcript, + plan, + raw, + wire.attestedData, + ) + await io.close() + prover.free() + prover = undefined + transcript = undefined + io = undefined + stage = 'done' + reply({ type: 'attestation', attestation: { ...wire, decoded } }) + port.close() + return + } + throw new Error('Invalid notarization sequence') + } + function dispatch(data: Record) { + void work(data).catch(async (error) => { + reply({ type: 'error', message: errorMessage(error) }) + stage = 'done' + await io?.close() + port.close() + }) + } + port.onmessage = (event) => dispatch(event.data) + dispatch(initial) +} + +self.addEventListener('message', (event: MessageEvent>) => { + session(event.data.port as MessagePort, event.data) +}) diff --git a/ts/packages/ceremony/src/notary/transcript.test.ts b/ts/packages/ceremony/src/notary/transcript.test.ts new file mode 100644 index 00000000..00835a32 --- /dev/null +++ b/ts/packages/ceremony/src/notary/transcript.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest' +import { identityRequest, selectIdentity } from '../platforms/github/1/transcript.js' +import { + buildIdentityRequest, + buildTokenRequest, + selectIdentityReveals, + selectTokenReveals, +} from '../platforms/x/1/transcript.js' +import { planNotarization } from './notarize.js' +import type { ExactHttpRequest } from './session.js' +import { quotedRange } from './transcript.js' + +const utf8 = (value: string) => new TextEncoder().encode(value) + +const text = (value: Uint8Array) => new TextDecoder().decode(value) + +function serialize(line: string, request: ExactHttpRequest): Uint8Array { + return utf8( + `${line}\r\n${Object.entries(request.headers) + .reverse() + .map(([name, value]) => `${name.toLowerCase()}: ${text(value)}`) + .join('\r\n')}\r\n\r\n${text(request.body)}`, + ) +} + +const input = { + clientId: 'client', + code: 'code+with/slash', + redirectUri: 'https://bridge.example/callback', + codeVerifier: 'a'.repeat(43), +} + +const request = buildTokenRequest(input) + +const transcript = { + sent: serialize('POST /2/oauth2/token HTTP/1.1', request), + recv: utf8('HTTP/1.1 200 OK\r\n\r\n{"access_token":"token","token_type":"bearer"}'), +} + +describe('X token disclosure [LIBID-PROVER-003, REQ-PLAT-56A/B/C]', () => { + it('reveals the entire request, accepts reordered headers, and keeps the bearer committed', () => { + expect(text(request.headers['Content-Length'])).toBe(String(request.body.length)) + const selected = selectTokenReveals(transcript, input) + const plan = planNotarization(transcript, selected.ranges) + expect(plan.reveal.sent).toEqual([{ start: 0, end: transcript.sent.length }]) + expect(plan.commit.sent).toEqual([]) + expect(plan.commit.recv).toContainEqual({ ...selected.bearerRange, algorithm: 'SHA256' }) + }) + it('admits additional headers and normalizes required names and HTTP whitespace', () => { + for (const sent of [ + text(transcript.sent) + .replace('accept: application/json\r\n', '') + .replace('connection: close\r\n', ''), + text(transcript.sent).replace( + 'accept: application/json', + 'accept: text/plain\r\naccept: application/json', + ), + text(transcript.sent).replace('host: api.x.com', 'HOST \t:\tapi.x.com \t'), + text(transcript.sent).replace('content-type: ', 'CONTENT_TYPE:\t'), + text(transcript.sent).replace('accept:', 'x-extra: café 😀\r\nx-extra:\r\naccept:'), + ]) { + const changed = { ...transcript, sent: utf8(sent) } + const selected = selectTokenReveals(changed, input) + expect(selected.accessToken).toBe('token') + expect(selected.ranges.sent).toEqual([{ start: 0, end: changed.sent.length }]) + } + }) + it.each([ + 'Authorization: Basic other', + 'Cookie: session=other', + 'Content_Encoding: gzip', + 'Transfer-Encoding: chunked', + 'X_HTTP_Method_Override: POST', + 'X-Http-Method: POST', + 'X-Method-Override: POST', + ])('rejects forbidden token header %s [REQ-PLAT-56A]', (header) => { + const sent = utf8(text(transcript.sent).replace('accept:', `${header}\r\naccept:`)) + expect(() => selectTokenReveals({ ...transcript, sent }, input)).toThrow() + }) + it.each([ + ['host: api.x.com', 'host: other.com'], + ['application/x-www-form-urlencoded', 'text/plain'], + ['host: api.x.com\r\n', ''], + ['host: api.x.com', 'host: api.x.com\r\nHOST: api.x.com'], + ['content-type: ', 'content-type: application/x-www-form-urlencoded\r\ncontent_type: '], + ['content-length: ', 'content-length: 3\r\ncontent_length: '], + ['content-length: ', 'content-length: 000'], + ['accept: application/json', 'accept: application/json\r\ntransfer-encoding: chunked'], + [`content-length: ${request.body.length}`, `content-length: ${request.body.length - 1}`], + ['content-length: ', 'content-length: +'], + ['\r\nhost:', '\nhost:'], + ['host: api.x.com\r\n', 'host: api.x.com\n\r\n'], + ['\r\nhost:', '\r\n host:'], + ['\r\nhost:', '\r\n\thost:'], + ['grant_type=authorization_code', 'grant_type=refresh_token'], + ['client_id=client', 'client_id=otherx'], + ['code=code%2Bwith%2Fslash', 'code=code%2bwith%2fslash'], + ])('rejects altered framing or binding: %s', (from, to) => { + expect(() => + selectTokenReveals( + { ...transcript, sent: utf8(text(transcript.sent).replace(from, to)) }, + input, + ), + ).toThrow() + }) +}) + +describe('GitHub identity disclosure [LIBID-PROVER-004, REQ-PLAT-60]', () => { + const request = identityRequest('token') + const sent = serialize('GET /user HTTP/1.1', request) + it.each(['{"id":123,"login":"alice"}', '{"login":"alice","id":123}'])( + 'accepts field order %s', + (body) => { + const received = utf8(`HTTP/1.1 200 OK\r\n\r\n${body}`) + const selected = selectIdentity({ sent, received }, 'token') + const plan = planNotarization( + { sent, recv: received }, + { sent: selected.ranges.sent, recv: selected.ranges.received }, + ) + expect(selected.userId).toBe('123') + expect(selected.userName).toBe('alice') + expect(plan.reveal.recv.map((r) => text(received.slice(r.start, r.end)))).toEqual( + body.startsWith('{"id"') ? ['"id":123,"login":"alice"'] : ['"login":"alice"', '"id":123}'], + ) + expect(plan.commit.sent).toEqual([{ ...selected.bearerRange, algorithm: 'SHA256' }]) + }, + ) + it.each([' ', '\t', '\r', '\n', ' \t\r\n'])( + 'preserves original whitespace in revealed GitHub fields: %j', + (space) => { + const id = `"id"${space}:${space}123${space},` + const login = `"login"${space}:${space}"alice"` + const received = utf8(`HTTP/1.1 200 OK\r\n\r\n{${id}\n${login}}`) + const selected = selectIdentity({ sent, received }, 'token') + expect(selected.userId).toBe('123') + expect(selected.userName).toBe('alice') + expect( + selected.ranges.received.map(({ start, end }) => text(received.slice(start, end))), + ).toEqual([id, login]) + }, + ) + it.each(['"123"', '0123', '123.4', '123e2', '123 4', '18446744073709551616'])( + 'still rejects invalid spaced IDs: %s', + (id) => { + const received = utf8(`{"id": ${id}, "login": "alice"}`) + expect(() => selectIdentity({ sent, received }, 'token')).toThrow() + }, + ) + it('pins the API version and forwards the browser User-Agent, rejecting missing or duplicate headers', () => { + expect(text(request.headers['X-GitHub-Api-Version'])).toBe('2022-11-28') + expect(text(request.headers['User-Agent'])).toBe(navigator.userAgent) + const received = utf8('{"id":123,"login":"alice"}') + for (const header of [ + 'x-github-api-version: 2022-11-28', + `user-agent: ${navigator.userAgent}`, + ]) { + for (const replacement of ['', `${header}\r\n${header}\r\n`]) { + expect(() => + selectIdentity( + { sent: utf8(text(sent).replace(`${header}\r\n`, replacement)), received }, + 'token', + ), + ).toThrow() + } + } + }) +}) + +for (const platform of ['x', 'github'] as const) { + describe(`${platform} additional identity headers [LIBID-PROVER-003/004]`, () => { + const request = platform === 'x' ? buildIdentityRequest('token') : identityRequest('token') + const line = platform === 'x' ? 'GET /2/users/me HTTP/1.1' : 'GET /user HTTP/1.1' + const original = text(serialize(line, request)) + const received = utf8( + platform === 'x' ? '{"id":"123","username":"alice"}' : '{"id":123,"login":"alice"}', + ) + const select = (sent: Uint8Array) => + platform === 'x' + ? selectIdentityReveals({ sent, recv: received }, 'token').sent + : selectIdentity({ sent, received }, 'token').ranges.sent + it.each(['x-extra: value', 'x-extra: café 😀', 'x-extra:', 'x-extra:\tvalue'])( + 'reveals extra headers before and after Authorization without shifting its bearer: %s', + (extra) => { + const sent = utf8( + original.replace( + 'authorization: Bearer token\r\n', + `${extra}\r\nauthorization: Bearer token\r\n${extra}\r\n`, + ), + ) + const ranges = select(sent) + const plan = planNotarization({ sent, recv: received }, { sent: ranges, recv: [] }) + expect(plan.commit.sent).toHaveLength(1) + const hole = plan.commit.sent[0] + expect(text(sent.slice(hole.start, hole.end))).toBe('token') + expect(ranges).toEqual([ + { start: 0, end: hole.start }, + { start: hole.end, end: sent.length }, + ]) + }, + ) + it.each([ + 'Cookie: session=other', + 'Content_Encoding: gzip', + 'Transfer-Encoding: chunked', + 'X_HTTP_Method_Override: POST', + 'X-Http-Method: POST', + 'X-Method-Override: POST', + ])('rejects forbidden identity header %s [REQ-COMMON-39B]', (header) => { + expect(() => select(utf8(original.replace('host:', `${header}\r\nhost:`)))).toThrow() + }) + it.each([ + ['authorization: Bearer token', 'authorization: Bearer token\r\nAuthorization: Bearer token'], + ['authorization: Bearer token', 'authorization: Bearer token\r\nAuthorization: Basic other'], + ['authorization: Bearer token', 'authorization: Bearer other'], + ['authorization: Bearer token\r\n', ''], + ['host: ', ' host: '], + ['host: ', 'extra: x\nhost: '], + ['host: ', 'extra: x\rhost: '], + ['host: ', '\thost: '], + ['host: ', 'extra: x\u0000\r\nhost: '], + [line, line.replace(' HTTP', '?extra=1 HTTP')], + ['\r\n\r\n', '\r\n\r\nbody'], + ])('rejects ambiguous framing or changed required headers: %s', (from, to) => { + expect(() => select(utf8(original.replace(from, to)))).toThrow() + }) + }) +} + +describe('JSON field whitespace [LIBID-PROVER-003/004]', () => { + it.each([' ', '\t', '\r', '\n', ' \t\r\n'])('keeps X bearer offsets with %j', (ws) => { + const prefix = `"access_token"${ws}:${ws}"` + const recv = utf8(`HTTP/1.1 200 OK\r\n\r\n{${prefix}token"}`) + const selected = selectTokenReveals({ ...transcript, recv }, input) + expect(text(recv.slice(selected.bearerRange.start, selected.bearerRange.end))).toBe('token') + expect(selected.ranges.recv.map(({ start, end }) => text(recv.slice(start, end)))).toEqual([ + prefix, + '"', + ]) + const request = buildIdentityRequest('token') + const identity = utf8(`{"id"${ws}:${ws}"123", "username"${ws}:${ws}"alice"}`) + const reveals = selectIdentityReveals( + { sent: serialize('GET /2/users/me HTTP/1.1', request), recv: identity }, + 'token', + ) + expect(reveals.recv.map(({ start, end }) => text(identity.slice(start, end)))).toEqual([ + `"id"${ws}:${ws}"123"`, + `"username"${ws}:${ws}"alice"`, + ]) + }) + it.each(['\v', '\f', '\u00a0'])('rejects non-JSON whitespace %j', (ws) => { + expect(() => quotedRange(utf8(`{"login":${ws}"alice"}`), 'login')).toThrow() + expect(() => quotedRange(utf8(`{"login"${ws}:"alice"}`), 'login')).toThrow() + }) + it('rejects duplicates with different whitespace and incomplete values', () => { + for (const body of ['{"login":"alice","login" : "bob"}', '{"login": "alice', '{"login" : ']) + expect(() => quotedRange(utf8(body), 'login')).toThrow() + }) +}) diff --git a/ts/packages/ceremony/src/notary/transcript.ts b/ts/packages/ceremony/src/notary/transcript.ts new file mode 100644 index 00000000..f0fd59d1 --- /dev/null +++ b/ts/packages/ceremony/src/notary/transcript.ts @@ -0,0 +1,156 @@ +import type { ByteRange } from './notarize.js' + +const decoder = new TextDecoder('utf-8', { fatal: true }) + +// REQ-COMMON-39B applies to both request types; tokens also forbid Authorization. +const FORBIDDEN_HEADERS = new Set([ + 'cookie', + 'content-encoding', + 'transfer-encoding', + 'x-http-method-override', + 'x-http-method', + 'x-method-override', +]) + +function invalid(reason: string): never { + throw new Error(`Invalid transcript: ${reason}`) +} + +function findFrom(haystack: Uint8Array, needle: Uint8Array, start = 0): number { + outer: for (let i = start; i <= haystack.length - needle.length; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer + } + return i + } + return -1 +} + +export function findUnique(haystack: Uint8Array, needle: Uint8Array, name: string): number { + const start = findFrom(haystack, needle) + if (start < 0) return invalid(`${name} is missing`) + if (findFrom(haystack, needle, start + 1) >= 0) return invalid(`${name} is duplicated`) + return start +} + +/** JSON whitespace only; offsets always remain relative to the original bytes. */ +export function skipJsonWhitespace(bytes: Uint8Array, start: number): number { + while ([32, 9, 10, 13].includes(bytes[start])) start++ + return start +} + +export function jsonField( + transcript: Uint8Array, + name: string, +): { start: number; valueStart: number } { + const key = new TextEncoder().encode(`"${name}"`) + let found: { start: number; valueStart: number } | undefined + for ( + let start = findFrom(transcript, key); + start >= 0; + start = findFrom(transcript, key, start + 1) + ) { + const colon = skipJsonWhitespace(transcript, start + key.length) + if (transcript[colon] !== 58) continue + if (found) return invalid(`${name} is duplicated`) + found = { start, valueStart: skipJsonWhitespace(transcript, colon + 1) } + } + return found ?? invalid(`${name} is missing`) +} + +export function quotedRange( + transcript: Uint8Array, + name: string, +): { range: ByteRange; valueStart: number; value: Uint8Array } { + const field = jsonField(transcript, name) + if (transcript[field.valueStart] !== 34) return invalid(`${name} is not a string`) + const valueStart = field.valueStart + 1 + let end = valueStart + while (end < transcript.length && transcript[end] !== 0x22) end++ + if (end === transcript.length) return invalid(`${name} is unterminated`) + return { + range: { start: field.start, end: end + 1 }, + valueStart, + value: transcript.slice(valueStart, end), + } +} + +export function decodePrintable(value: Uint8Array, name: string, maximum: number): string { + if (value.length === 0 || value.length > maximum) return invalid(`${name} length is invalid`) + for (const byte of value) + if (byte < 0x20 || byte > 0x7e) invalid(`${name} is not printable ASCII`) + try { + return decoder.decode(value) + } catch { + return invalid(`${name} is not ASCII`) + } +} + +/** Read the fully disclosed request, with Content-Length bound to its complete body. */ +export function tokenRequestBody( + request: Uint8Array, + requestLine: string, + host: string, +): Uint8Array { + const bodyStart = + findUnique(request, new Uint8Array([13, 10, 13, 10]), 'token head terminator') + 4 + const head = request.subarray(0, bodyStart - 4) + const [line, ...headers] = new TextDecoder('latin1').decode(head).split('\r\n') + if (line !== requestLine) invalid('token request framing') + const expected = new Map([ + ['host', host], + ['content-type', 'application/x-www-form-urlencoded'], + ['content-length', String(request.length - bodyStart)], + ]) + const seen = new Set() + for (const header of headers) { + const match = /^([!#$%&'*+.^_`|~0-9a-z-]+)[ \t]*:([\t\x20-\x7e\u0080-\uffff]*)$/i.exec(header) + if (!match) invalid('token header framing') + const name = match[1].toLowerCase().replaceAll('_', '-') + if (name === 'authorization' || FORBIDDEN_HEADERS.has(name)) invalid('forbidden token header') + if (expected.has(name)) { + const value = match[2].replace(/^[ \t]+|[ \t]+$/g, '') + if (seen.has(name) || expected.get(name) !== value) invalid('token header value or duplicate') + seen.add(name) + } + } + if (seen.size !== expected.size) invalid('missing token header') + return request.subarray(bodyStart) +} + +/** Locate the sole bearer hole while admitting additional identity headers. */ +export function identityBearerRange( + sent: Uint8Array, + requestLine: string, + required: Record, + bearer: string, +): ByteRange { + // Latin-1 decoding produces one code unit per wire byte, including UTF-8 header values. + const bytes = new TextDecoder('latin1') + const text = bytes.decode(sent) + const headEnd = text.indexOf('\r\n\r\n') + if (headEnd < 0 || headEnd !== text.length - 4) invalid('identity request framing') + const [line, ...headers] = text.slice(0, headEnd).split('\r\n') + if (line !== requestLine) invalid('identity request line') + const expected = new Map( + Object.entries(required).map(([name, value]) => [name.toLowerCase(), bytes.decode(value)]), + ) + const seen = new Set() + let offset = line.length + 2, + start = -1 + for (const header of headers) { + const match = /^([!#$%&'*+.^_`|~0-9a-z-]+):([\t\x20-\x7e\u0080-\uffff]*)$/i.exec(header) + if (!match) invalid('identity header framing') + const name = match[1].toLowerCase().replaceAll('_', '-') + if (FORBIDDEN_HEADERS.has(name)) invalid('forbidden identity header') + if (expected.has(name)) { + if (seen.has(name) || match[2] !== ` ${expected.get(name)}`) + invalid('identity header value or duplicate') + seen.add(name) + } + if (name === 'authorization') start = offset + match[1].length + ': Bearer '.length + offset += header.length + 2 + } + if (seen.size !== expected.size || start < 0) invalid('missing identity header') + return { start, end: start + bearer.length } +} diff --git a/ts/packages/ceremony/src/notary/transport.test.ts b/ts/packages/ceremony/src/notary/transport.test.ts new file mode 100644 index 00000000..32c75b57 --- /dev/null +++ b/ts/packages/ceremony/src/notary/transport.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { decodeAttestationFrame, deriveNotaryWebSocketUrl } from './transport.js' + +// Exact `write_msg` output for the smoke vector in libid-org/notary PR #6 at +// d449da1e380a70c04d291d34bf0fe2bcf876d9ba (176-byte compact serde JSON). +const CANONICAL_FRAME = Uint8Array.from( + Buffer.from( + '000000b07b2261747465737465645f64617461223a5b312c322c335d2c226e6f746172795f7369676e6174757265223a5b342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c342c345d7d', + 'hex', + ), +) + +function framePayload(payload: Uint8Array): Uint8Array { + const frame = new Uint8Array(payload.length + 4) + new DataView(frame.buffer).setUint32(0, payload.length) + frame.set(payload, 4) + return frame +} + +function frameJson(value: unknown): Uint8Array { + return framePayload(new TextEncoder().encode(JSON.stringify(value))) +} + +const validPayload = (signature: unknown = new Array(65).fill(4)) => ({ + attested_data: [1, 2, 3], + notary_signature: signature, +}) + +describe('deriveNotaryWebSocketUrl', () => { + it.each([ + ['https://notary.testnet.lib.id', 'wss://notary.testnet.lib.id/notarize-proxy'], + ['http://localhost:4687', 'ws://localhost:4687/notarize-proxy'], + ['http://127.0.0.1:4687', 'ws://127.0.0.1:4687/notarize-proxy'], + ['https://notary.example:7048', 'wss://notary.example:7048/notarize-proxy'], + ])('derives the fixed proxy route from %s', (address, expected) => { + expect(deriveNotaryWebSocketUrl(address)).toBe(expected) + }) + + it.each([ + 'http://notary.example', + 'http://localhost.evil.test', + 'http://192.168.1.1', + 'http://localhost:4687/', + 'http://user@localhost:4687', + 'ws://notary.example', + 'wss://notary.example', + 'ftp://notary.example', + 'https://user@notary.example', + 'https://notary.example/', + 'https://notary.example/notarize-proxy', + 'https://notary.example/other', + 'https://notary.example?', + 'https://notary.example?sessionId=1', + 'https://notary.example#', + 'https://notary.example#fragment', + 'notary.example', + ])('rejects %s', (address) => { + expect(() => deriveNotaryWebSocketUrl(address)).toThrow( + /canonical HTTPS or localhost HTTP origin/, + ) + }) +}) + +describe('decodeAttestationFrame', () => { + it('decodes the canonical upstream frame and nothing else', () => { + expect(decodeAttestationFrame(CANONICAL_FRAME)).toEqual({ + attestedData: Uint8Array.from([1, 2, 3]), + signature: new Uint8Array(65).fill(4), + }) + }) + + it.each<[string, Uint8Array, RegExp]>([ + ['truncated length', new Uint8Array(3), /truncated length/], + ['truncated payload', CANONICAL_FRAME.slice(0, -1), /truncated payload/], + ['trailing byte', Uint8Array.from([...CANONICAL_FRAME, 0]), /trailing bytes/], + ['second frame', Uint8Array.from([...CANONICAL_FRAME, ...CANONICAL_FRAME]), /trailing bytes/], + ['oversize declaration', Uint8Array.from([0, 160, 0, 1]), /payload exceeds size limit/], + ['invalid UTF-8', framePayload(Uint8Array.from([0xff])), /malformed JSON/], + ['invalid JSON', framePayload(new TextEncoder().encode('{')), /malformed JSON/], + ['unknown field', frameJson({ ...validPayload(), other: [] }), /exactly/], + ['missing field', frameJson({ attested_data: [1] }), /exactly/], + [ + 'duplicate field', + framePayload( + new TextEncoder().encode( + `{"attested_data":[1],"attested_data":[2],"notary_signature":[${new Array(65).fill(4).join(',')}]}`, + ), + ), + /malformed JSON/, + ], + ['short signature', frameJson(validPayload(new Array(64).fill(4))), /exactly 65/], + ['long signature', frameJson(validPayload(new Array(66).fill(4))), /exactly 65/], + ['negative byte', frameJson({ ...validPayload(), attested_data: [-1] }), /byte element/], + ['large byte', frameJson({ ...validPayload(), attested_data: [256] }), /byte element/], + ['fractional byte', frameJson({ ...validPayload(), attested_data: [1.5] }), /byte element/], + ['string byte', frameJson({ ...validPayload(), attested_data: ['1'] }), /byte element/], + [ + 'oversize attested data', + frameJson({ ...validPayload(), attested_data: new Array(2 * 1024 * 1024 + 1).fill(0) }), + /invalid byte array/, + ], + ])('rejects %s', (_name, frame, reason) => { + expect(() => decodeAttestationFrame(frame)).toThrow(reason) + }) +}) diff --git a/ts/packages/ceremony/src/notary/transport.ts b/ts/packages/ceremony/src/notary/transport.ts new file mode 100644 index 00000000..12d59e37 --- /dev/null +++ b/ts/packages/ceremony/src/notary/transport.ts @@ -0,0 +1,64 @@ +import { parseJson } from '../json.js' +import { hasExactKeys, isRecord, origin } from '../primitives.js' +import { MAX_ATTESTED_DATA_BYTES } from './decode.js' + +export const MAX_FRAME_BYTES = 10 * 1024 * 1024 + +export const MAX_FRAME_PAYLOAD_BYTES = MAX_FRAME_BYTES - 4 + +export interface NotaryAttestation { + attestedData: Uint8Array + signature: Uint8Array +} + +function invalid(reason: string): never { + throw new Error(`invalid notary transport: ${reason}`) +} + +export function deriveNotaryWebSocketUrl(notaryAddress: string): string { + if (!origin(notaryAddress)) invalid('address must be a canonical HTTPS or localhost HTTP origin') + const url = new URL(notaryAddress) + return `${url.protocol === 'https:' ? 'wss:' : 'ws:'}//${url.host}/notarize-proxy` +} + +function validateByteArray(value: unknown, maximum: number): asserts value is number[] { + if (!Array.isArray(value) || value.length > maximum) invalid('invalid byte array') + for (const byte of value) { + if (!Number.isInteger(byte) || byte < 0 || byte > 255) invalid('invalid byte element') + } +} + +/** + * Decode the reclaimed-channel record: u32 big-endian JSON byte length, then UTF-8 JSON. + * The channel reader requires EOF; trailing bytes, extra records and malformed byte arrays reject. + */ +export function decodeAttestationFrame(frame: Uint8Array): NotaryAttestation { + if (frame.length < 4) invalid('truncated length') + const length = new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0) + if (length > MAX_FRAME_PAYLOAD_BYTES) invalid('payload exceeds size limit') + if (frame.length < length + 4) invalid('truncated payload') + if (frame.length > length + 4) invalid('trailing bytes') + + let value: unknown + let text: string + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(frame.subarray(4)) + value = parseJson(text) + } catch { + return invalid('malformed JSON payload') + } + if (!isRecord(value) || !hasExactKeys(value, ['attested_data', 'notary_signature'])) { + invalid('payload must contain exactly attested_data and notary_signature') + } + if (!Array.isArray(value.notary_signature) || value.notary_signature.length !== 65) { + invalid('notary signature must be exactly 65 bytes') + } + const attestedData = value.attested_data + const signature = value.notary_signature + validateByteArray(attestedData, MAX_ATTESTED_DATA_BYTES) + validateByteArray(signature, 65) + return { + attestedData: Uint8Array.from(attestedData), + signature: Uint8Array.from(signature), + } +} diff --git a/ts/packages/ceremony/src/platforms/authorization.test.ts b/ts/packages/ceremony/src/platforms/authorization.test.ts new file mode 100644 index 00000000..45cb4332 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/authorization.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { b64urlEncode } from '../primitives.js' +import { + deriveAuthorizationDigest, + deriveCodeChallenge, + deriveCodeVerifier, + operationDomainFromString, +} from './authorization.js' + +const hex = (bytes: Uint8Array) => + Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + +// The ceremony-common §5 conformance vector (TEST-COMMON-01). +const operationDomain = operationDomainFromString('libid.claim-identity') + +const chainId = operationDomainFromString('example:1') + +const authorizationNonce = new Uint8Array(32).fill(0x55) + +const transactionData = new Uint8Array([0x00, 0x01, 0x02, 0x03]) + +describe('[TEST-COMMON-01] authorization digest', () => { + it('reproduces the §5 conformance vector exactly', () => { + expect(hex(operationDomain)).toBe( + 'cb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b', + ) + expect(hex(chainId)).toBe('38064d82f31db40935cc75f2a0d07dcfb448d7c08e7484fc30f5de95484a4066') + const digest = deriveAuthorizationDigest({ + operationDomain, + platformCeremonyVersion: 1, + chainId, + authorizationNonce, + transactionData, + }) + expect(hex(digest)).toBe('b318fb559e16a179b853ed2853576cda16032d93b0839bb81a55135d334c0af5') + }) + + it('[TEST-COMMON-04] distinct nonces over identical transaction data yield distinct digests', () => { + const base = { operationDomain, platformCeremonyVersion: 1, chainId, transactionData } + const a = deriveAuthorizationDigest({ ...base, authorizationNonce }) + const b = deriveAuthorizationDigest({ + ...base, + authorizationNonce: new Uint8Array(32).fill(0x56), + }) + expect(hex(a)).not.toBe(hex(b)) + }) + + it('rejects values that do not fit their fixed-width field', () => { + const base = { + operationDomain, + platformCeremonyVersion: 1, + chainId, + authorizationNonce, + transactionData, + } + expect(() => + deriveAuthorizationDigest({ ...base, operationDomain: operationDomain.slice(1) }), + ).toThrow(/32 bytes/) + expect(() => deriveAuthorizationDigest({ ...base, chainId: new Uint8Array(33) })).toThrow( + /32 bytes/, + ) + expect(() => + deriveAuthorizationDigest({ ...base, authorizationNonce: new Uint8Array(31) }), + ).toThrow(/32 bytes/) + for (const version of [-1, 1.5, 0x10000, Number.NaN]) { + expect(() => + deriveAuthorizationDigest({ ...base, platformCeremonyVersion: version }), + ).toThrow(/16-bit/) + } + }) + + it('is a pure function of its inputs', () => { + const args = { + operationDomain, + platformCeremonyVersion: 1, + chainId, + authorizationNonce, + transactionData, + } + expect(hex(deriveAuthorizationDigest(args))).toBe(hex(deriveAuthorizationDigest(args))) + }) +}) + +describe('[TEST-COMMON-07] PKCE derivation', () => { + const digest = deriveAuthorizationDigest({ + operationDomain, + platformCeremonyVersion: 1, + chainId, + authorizationNonce, + transactionData, + }) + + it('reproduces the §7 conformance vector exactly', () => { + const verifier = deriveCodeVerifier(digest, authorizationNonce) + expect(verifier).toBe('5teBDl6cz4U77aFweV5PbMhBJ_lEFv6LLNKzqnDI5lo') + expect(deriveCodeChallenge(verifier)).toBe('c8HLMaJOzc8OUoRYc7AocL5ioAkXVtAOmoGxoSY60IQ') + }) + + it('always yields exactly 43 unpadded base64url characters', () => { + for (const fill of [0, 1, 0x7f, 0xff]) { + const verifier = deriveCodeVerifier(digest, new Uint8Array(32).fill(fill)) + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(deriveCodeChallenge(verifier)).toMatch(/^[A-Za-z0-9_-]{43}$/) + } + }) + + it('rejects inputs of the wrong width', () => { + expect(() => deriveCodeVerifier(digest.slice(1), authorizationNonce)).toThrow(/32 bytes/) + expect(() => deriveCodeVerifier(digest, new Uint8Array(16))).toThrow(/32 bytes/) + }) + + it('matches the §3.1 Google nonce encoding of the same digest', () => { + // Google carries the digest itself, base64url-encoded, as the OIDC nonce. + expect(b64urlEncode(digest)).toBe('sxj7VZ4WoXm4U-0oU1ds2hYDLZOwg5u4GlUTXTNMCvU') + }) +}) diff --git a/ts/packages/ceremony/src/platforms/authorization.ts b/ts/packages/ceremony/src/platforms/authorization.ts new file mode 100644 index 00000000..12850337 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/authorization.ts @@ -0,0 +1,84 @@ +// The normative authorization constructions shared by the platform slices: +// the Authorization Digest (ceremony-common §5, REQ-COMMON-01) and the S256 +// PKCE derivation X and GitHub bind it with (§7, REQ-COMMON-12). Only the +// Ceremony Client derives these; the prover receives the already-derived +// code verifier and does not receive the authorization nonce. +// +// Hashes come from @noble/hashes: keccak256 has no native browser +// implementation, and taking sha256 from the same audited pin keeps these +// functions synchronous and dependency-minimal. + +import { sha256 } from '@noble/hashes/sha2.js' +import { keccak_256 } from '@noble/hashes/sha3.js' +import { b64urlEncode } from '../primitives.js' + +export interface AuthorizationInput { + /** `keccak256(UTF8(domainString))` — exactly 32 bytes, supplied by the composition. */ + operationDomain: Uint8Array + platformCeremonyVersion: number + /** `keccak256` of the Chain Profile's canonical identifier bytes — exactly 32 bytes. */ + chainId: Uint8Array + /** Fresh 32 cryptographically secure random bytes per ceremony. */ + authorizationNonce: Uint8Array + /** Opaque canonical bytes; the U32BE length field bounds it. */ + transactionData: Uint8Array +} + +function exact(bytes: Uint8Array, width: number, name: string): Uint8Array { + if (bytes.length !== width) throw new Error(`${name} must be exactly ${width} bytes`) + return bytes +} + +/** + * `keccak256(operationDomain || U16BE(version) || chainId || nonce || + * U32BE(len(transactionData)) || transactionData)` — every field width + * checked, values that do not fit their field rejected (REQ-COMMON-01). + */ +export function deriveAuthorizationDigest(input: AuthorizationInput): Uint8Array { + const { platformCeremonyVersion: version, transactionData } = input + if (!Number.isInteger(version) || version < 0 || version > 0xffff) { + throw new Error('platformCeremonyVersion must fit an unsigned 16-bit integer') + } + if (transactionData.length > 0xffffffff) { + throw new Error('transactionData length must fit an unsigned 32-bit integer') + } + const preimage = new Uint8Array(102 + transactionData.length) + const view = new DataView(preimage.buffer) + preimage.set(exact(input.operationDomain, 32, 'operationDomain'), 0) + view.setUint16(32, version) + preimage.set(exact(input.chainId, 32, 'chainId'), 34) + preimage.set(exact(input.authorizationNonce, 32, 'authorizationNonce'), 66) + view.setUint32(98, transactionData.length) + preimage.set(transactionData, 102) + return keccak_256(preimage) +} + +/** + * `BASE64URL_NOPAD(SHA256(authorizationDigest || authorizationNonce))` — + * exactly 43 base64url characters (REQ-COMMON-12). The nonce is the same + * one committed by the digest; it must not be emitted anywhere before the + * token exchange completes (REQ-COMMON-14). + */ +export function deriveCodeVerifier( + authorizationDigest: Uint8Array, + authorizationNonce: Uint8Array, +): string { + const binding = new Uint8Array(64) + binding.set(exact(authorizationDigest, 32, 'authorizationDigest'), 0) + binding.set(exact(authorizationNonce, 32, 'authorizationNonce'), 32) + return b64urlEncode(sha256(binding)) +} + +/** `BASE64URL_NOPAD(SHA256(ASCII(code_verifier)))` — the S256 challenge. */ +export function deriveCodeChallenge(codeVerifier: string): string { + return b64urlEncode(sha256(new TextEncoder().encode(codeVerifier))) +} + +/** `keccak256(UTF8(domainString))` — how a Consumer fixes an operation domain + * (REQ-COMMON-01A); exposed for compositions and tests. */ +export function operationDomainFromString(domainString: string): Uint8Array { + return keccak_256(new TextEncoder().encode(domainString)) +} + +/** Form-authenticated client IDs must be byte-identical under form serialization. */ +export const isFormClientId = (value: string): boolean => /^[A-Za-z0-9*._-]+$/.test(value) diff --git a/ts/packages/ceremony/src/platforms/codeReturn.test.ts b/ts/packages/ceremony/src/platforms/codeReturn.test.ts new file mode 100644 index 00000000..f78cc0be --- /dev/null +++ b/ts/packages/ceremony/src/platforms/codeReturn.test.ts @@ -0,0 +1,99 @@ +import { expect, it } from 'vitest' +import { parseCodeOAuthReturn } from './codeReturn.js' + +const issuer = 'https://github.com/login/oauth' + +const parse = (query: string, expectedIssuer: string | undefined = issuer) => + parseCodeOAuthReturn({ query, fragment: '' }, expectedIssuer) + +const iss = '&iss=https%3A%2F%2Fgithub.com%2Flogin%2Foauth' + +it('accepts GitHub success and detailed denial/error returns [LIBID-OAUTH-018]', () => { + expect(parse(`?code=test&state=v1.test${iss}`)).toEqual({ + outcome: 'accepted', + state: 'v1.test', + code: 'test', + }) + expect( + parse( + `?error=access_denied&error_description=Access+denied&error_uri=%2Fhelp&state=v1.test${iss}`, + ), + ).toEqual({ outcome: 'denied', state: 'v1.test' }) + expect(parse(`?error=application_suspended&state=v1.test${iss}`)).toEqual({ + outcome: 'error', + state: 'v1.test', + error: 'application_suspended', + }) + expect(parseCodeOAuthReturn({ query: '?code=test&state=v1.test', fragment: '' })).toMatchObject({ + outcome: 'accepted', + }) +}) + +it('decodes equivalent valid form encodings exactly once', () => { + expect(parse(`?code=a%2fb%20c&state=v1.test${iss.toLowerCase()}`)).toMatchObject({ + code: 'a/b c', + }) + expect(parse(`?code=${'%2F'.repeat(4096)}&state=v1.test${iss}`)).toMatchObject({ + code: '/'.repeat(4096), + }) +}) + +it.each([ + '?code=test&state=v1.test', + `?code=test&state=v1.test${iss}/`, + `?code=test&state=v1.test${iss}&iss=https%3A%2F%2Fevil.test`, + `?code=test&code=second&state=v1.test${iss}`, + `?code=test&state=v1.test&error=access_denied${iss}`, + `?code=%ZZ&state=v1.test${iss}`, + `?code=%FF&state=v1.test${iss}`, + `?code=%0A&state=v1.test${iss}`, + `?%63ode=test&state=v1.test${iss}`, + `?code=&state=v1.test${iss}`, + `?error=&state=v1.test${iss}`, + `?code=test&state=v1.test&id_token=unexpected${iss}`, + `?code=test&state=v1.test&access_token=unexpected${iss}`, + `?code=test&state=v1.test&refresh_token=unexpected${iss}`, + `?code=test&state=v1.test&provider_meta=one&provider_meta=two${iss}`, + `?code=test&state=v1.test&provider_meta=%ZZ${iss}`, + `?code=test&state=v1.test&provider_meta=%FF${iss}`, + `?code=test&state=v1.test&provider_meta=${'x'.repeat(8193)}${iss}`, +])('rejects ambiguous or invalid GitHub return: %s', (query) => { + expect(parse(query)).toBeNull() +}) + +it('does not accept issuer fields for X or mixed query/fragment returns', () => { + expect(parseCodeOAuthReturn({ query: `?code=test&state=v1.test${iss}`, fragment: '' })).toBeNull() + expect( + parseCodeOAuthReturn( + { query: `?code=test&state=v1.test${iss}`, fragment: '#code=other' }, + issuer, + ), + ).toBeNull() +}) + +it('ignores new X/GitHub metadata without changing outcome, code or issuer [LIBID-OAUTH-006] [LIBID-OAUTH-018]', () => { + for (const expectedIssuer of [undefined, issuer]) { + const suffix = expectedIssuer ? iss : '' + for (const [query, expected] of [ + ['?code=test&state=v1.test', { outcome: 'accepted', state: 'v1.test', code: 'test' }], + ['?error=access_denied&state=v1.test', { outcome: 'denied', state: 'v1.test' }], + [ + '?error=server_error&state=v1.test', + { outcome: 'error', state: 'v1.test', error: 'server_error' }, + ], + ] as const) { + expect( + parseCodeOAuthReturn( + { + query: + query + + suffix + + '&provider_meta=%E2%9C%93&release.rev=1&new-field=&1_debug=value&error_description=informational&error_uri=', + fragment: '', + }, + expectedIssuer, + ), + ).toEqual(expected) + } + } +}) diff --git a/ts/packages/ceremony/src/platforms/codeReturn.ts b/ts/packages/ceremony/src/platforms/codeReturn.ts new file mode 100644 index 00000000..52e3fe19 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/codeReturn.ts @@ -0,0 +1,54 @@ +import type { OAuthReturn } from '../ccdp/navigation.js' + +export type CodeOAuthOutcome = + | { outcome: 'accepted'; state: string; code: string } + | { outcome: 'denied'; state: string } + | { outcome: 'error'; state: string; error: string } + +const FIELD = /^([A-Za-z0-9_.-]{1,64})=(.*)$/ + +const VALUE = /^[\x20-\x7e]+$/ + +/** Decode provider form values without requiring one particular percent-encoding spelling. */ +export function parseCodeOAuthReturn( + oauthReturn: OAuthReturn, + expectedIssuer?: string, +): CodeOAuthOutcome | null { + if ( + oauthReturn.fragment !== '' || + !oauthReturn.query.startsWith('?') || + oauthReturn.query.length > 32768 + ) + return null + const fields = new Map() + for (const part of oauthReturn.query.slice(1).split('&')) { + const match = FIELD.exec(part) + if (!match) return null + const [, key, raw] = match + if ( + ['id_token', 'access_token', 'refresh_token'].includes(key) || + (key === 'iss' && !expectedIssuer) + ) + return null + if (fields.has(key)) return null + let value: string + try { + value = decodeURIComponent(raw.replace(/\+/g, ' ')) + } catch { + return null + } + if (value.length > 8192) return null + // Metadata has no value schema; only fields used by this profile are interpreted. + if (['state', 'code', 'error', 'iss'].includes(key) && !VALUE.test(value)) return null + fields.set(key, value) + } + if (expectedIssuer && fields.get('iss') !== expectedIssuer) return null + const state = fields.get('state'), + code = fields.get('code'), + error = fields.get('error') + if (!state || (code === undefined) === (error === undefined)) return null + if (code !== undefined) return { outcome: 'accepted', state, code } + return error === 'access_denied' + ? { outcome: 'denied', state } + : { outcome: 'error', state, error: error! } +} diff --git a/ts/packages/ceremony/src/platforms/context.ts b/ts/packages/ceremony/src/platforms/context.ts new file mode 100644 index 00000000..be7df8e1 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/context.ts @@ -0,0 +1,12 @@ +import type { ProveIdentity } from '../ccdp/index.js' +import type { OAuthReturn } from '../ccdp/navigation.js' +import type { OperationEvent } from '../events.js' + +/** Per-run inputs and one event producer shared by the Prover page and platform pipelines. */ +export interface ProverContext { + request: ProveIdentity + ceremonyId: string + oauthReturn: OAuthReturn + signal: AbortSignal + emit(event: OperationEvent): void +} diff --git a/ts/packages/ceremony/src/platforms/github/1/events.ts b/ts/packages/ceremony/src/platforms/github/1/events.ts new file mode 100644 index 00000000..31429a0e --- /dev/null +++ b/ts/packages/ceremony/src/platforms/github/1/events.ts @@ -0,0 +1,19 @@ +import { proofEvents, proofWeights } from '../../../barretenberg/events.js' +import type { CoreEvent } from '../../../events.js' + +/** Core proving operations admitted for this platform version; independent of UI weights. */ +export const events: readonly CoreEvent[] = [ + ...proofEvents, + 'token-fetch', + 'token-attestation', + 'identity-fetch', + 'identity-attestation', +] + +export const progressWeights = { + ...proofWeights, + 'token-fetch': 2, + 'token-attestation': 1, + 'identity-fetch': 2, + 'identity-attestation': 1, +} diff --git a/ts/packages/ceremony/src/platforms/github/1/github.assets.ts b/ts/packages/ceremony/src/platforms/github/1/github.assets.ts new file mode 100644 index 00000000..3a4f77f7 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/github/1/github.assets.ts @@ -0,0 +1,10 @@ +import { proofAssets } from '../../../barretenberg/barretenberg.assets.js' +import { + bearerCircuit as circuit, + bearerVerificationKey as verificationKey, +} from '../../../barretenberg/circuits/bearer_link/bearer_link.assets.js' +import { notaryAssets } from '../../../notary/notary.assets.js' + +export { circuit, verificationKey } + +export const assets = [...proofAssets, ...notaryAssets, circuit, verificationKey] as const diff --git a/ts/packages/ceremony/src/platforms/github/1/prover.test.ts b/ts/packages/ceremony/src/platforms/github/1/prover.test.ts new file mode 100644 index 00000000..d9c2701c --- /dev/null +++ b/ts/packages/ceremony/src/platforms/github/1/prover.test.ts @@ -0,0 +1,281 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { OperationEvent } from '../../../events.js' +import type { ProverContext } from '../../context.js' +import { prove as proveGitHub } from './prover.js' + +const { prepare, initialize, generate, destroy } = vi.hoisted(() => ({ + prepare: vi.fn(), + initialize: vi.fn(), + generate: vi.fn(), + destroy: vi.fn(), +})) + +vi.mock('virtual:ceremony-assets', () => ({ urls: {} })) +vi.mock('../../../assets/index.js', async (original) => ({ + ...(await original()), + resolve: () => 'https://ccdp.test/asset', +})) +vi.mock('../../../barretenberg/engine.js', () => ({ + ProofEngine: class { + prove = generate + destroy = destroy + }, +})) +vi.mock('../../../barretenberg/circuits/bearer_link/inputs.js', () => ({ + buildBearerLinkWitness: () => ({}), + validateBearerLinkPublicInputs: () => true, +})) +vi.mock('../../../notary/notarize.js', () => ({ bearerOpening: () => ({}) })) +vi.mock('../../../notary/session.js', () => ({ + Notarization: class { + constructor(address: string, signal: AbortSignal, emit: (event: OperationEvent) => void) { + initialize(address, signal, emit) + } + prepare = prepare + }, +})) +vi.mock('./token.js', async (original) => ({ + ...(await original()), + selectToken: () => ({ + accessToken: 'fixture', + ranges: { sent: [], received: [] }, + bearerRange: { start: 0, end: 7 }, + }), +})) +vi.mock('./transcript.js', async (original) => ({ + ...(await original()), + selectIdentity: () => ({ + userId: '1', + userName: 'fixture', + ranges: { sent: [], received: [] }, + bearerRange: { start: 0, end: 7 }, + }), +})) + +afterEach(() => { + vi.resetAllMocks() + vi.unstubAllGlobals() +}) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function transcript(body: unknown) { + const json = JSON.stringify(body) + return { + sent: new Uint8Array(), + received: new TextEncoder().encode( + `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${json.length}\r\n\r\n${json}`, + ), + } +} + +it.each(['accepted', 'failed'])( + 'overlaps identity fetch with token openings and waits for every output: %s [LIBID-PROVER-007] [LIBID-PROVER-013] [LIBID-PROVER-014]', + async (outcome) => { + vi.stubGlobal('navigator', { userAgent: 'browser fixture' }) + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + // Synthetic sessions isolate orchestration; real TLSN concurrency has a separate qualification gate. + const tokenResponse = deferred>() + const tokenOpenings = deferred<{ openings: []; attestation: Promise }>() + const tokenAttestation = deferred() + const identityAttestation = deferred() + const token = { + send: vi.fn(() => tokenResponse.promise), + reveal: vi.fn(() => tokenOpenings.promise), + } + const identity = { + send: vi.fn(async () => transcript({ id: 1, login: 'fixture' })), + reveal: vi.fn(async () => ({ openings: [], attestation: identityAttestation.promise })), + } + prepare.mockResolvedValueOnce(token).mockResolvedValueOnce(identity) + generate.mockResolvedValue({ proof: new Uint8Array([1]), publicInputs: [] }) + const events: OperationEvent[] = [] + const ceremonyId = '6e171568-54e1-4f0d-aeb5-e8859826476a' + const context: ProverContext = { + ceremonyId, + signal: new AbortController().signal, + emit: (event) => events.push(event), + request: { + type: 'prove-identity', + platformId: 'github', + platformCeremonyVersion: 1, + clientId: 'client', + clientCredential: 'public-fixture', + codeVerifier: 'a'.repeat(43), + redirectUri: 'https://bridge.test/callback', + notaryAddress: 'https://notary.test', + }, + oauthReturn: { + query: `?code=fixture&state=v1.${ceremonyId}&iss=https%3A%2F%2Fgithub.com%2Flogin%2Foauth`, + fragment: '', + }, + } + let settled = false + const result = proveGitHub(context).finally(() => { + settled = true + }) + const checked = + outcome === 'accepted' + ? expect(result).resolves.toMatchObject({ identity: { userId: '1' } }) + : expect(result).rejects.toMatchObject({ + event: 'token-attestation', + message: 'Final attestation failed', + }) + expect(prepare.mock.calls.map(([url]) => url)).toEqual([ + 'https://github.com/login/oauth/access_token', + 'https://api.github.com/user', + ]) + expect(fetch).not.toHaveBeenCalled() + expect(identity.send).not.toHaveBeenCalled() + tokenResponse.resolve(transcript({ access_token: 'fixture' })) + await vi.waitFor(() => expect(identity.reveal).toHaveBeenCalledOnce()) + // Event timing lives in the real session tests; this fake isolates the platform joins. + expect(initialize).toHaveBeenCalledWith( + 'https://notary.test', + expect.any(AbortSignal), + context.emit, + ) + expect(prepare.mock.calls.map(([, event]) => event)).toEqual([ + 'token-attestation', + 'identity-attestation', + ]) + identityAttestation.resolve(new Uint8Array([2])) + expect(generate).not.toHaveBeenCalled() + tokenOpenings.resolve({ openings: [], attestation: tokenAttestation.promise }) + await vi.waitFor(() => expect(generate).toHaveBeenCalledOnce()) + expect(settled).toBe(false) + if (outcome === 'accepted') tokenAttestation.resolve(new Uint8Array([3])) + else tokenAttestation.reject(new Error('Final attestation failed')) + await checked + for (const name of ['token-fetch', 'identity-fetch']) + expect(events.filter((event) => event.event === name).map((event) => event.phase)).toEqual([ + 'started', + 'finished', + ]) + expect(fetch).not.toHaveBeenCalled() + expect(destroy).toHaveBeenCalledOnce() + }, +) + +it.each(['notaryAddress', 'codeVerifier'] as const)( + 'requires %s before notarization [LIBID-OAUTH-021]', + async (field) => { + const ceremonyId = '6e171568-54e1-4f0d-aeb5-e8859826476a' + const context: ProverContext = { + ceremonyId, + signal: new AbortController().signal, + emit: vi.fn(), + request: { + type: 'prove-identity', + platformId: 'github', + platformCeremonyVersion: 1, + clientId: 'client', + clientCredential: 'public-fixture', + redirectUri: 'https://bridge.test/callback', + codeVerifier: 'A'.repeat(43), + notaryAddress: 'https://notary.test', + }, + oauthReturn: { + query: `?code=fixture&state=v1.${ceremonyId}&iss=https%3A%2F%2Fgithub.com%2Flogin%2Foauth`, + fragment: '', + }, + } + context.request[field] = null + await expect(proveGitHub(context)).rejects.toBeInstanceOf(Error) + expect(prepare).not.toHaveBeenCalled() + expect(generate).not.toHaveBeenCalled() + }, +) + +it.each([ + ['denial', '?error=access_denied', null], + ['wrong issuer', '?code=fixture&iss=https://other.test', 'authorization'], + ['missing credential', '?code=fixture', 'token-fetch'], +] as const)('handles %s before any exchange', async (name, query, event) => { + const ceremonyId = '6e171568-54e1-4f0d-aeb5-e8859826476a' + const context: ProverContext = { + ceremonyId, + signal: new AbortController().signal, + emit: vi.fn(), + request: { + type: 'prove-identity', + platformId: 'github', + platformCeremonyVersion: 1, + clientId: 'client', + redirectUri: 'https://bridge.test/auth/callback', + codeVerifier: 'a'.repeat(43), + notaryAddress: 'https://notary.test', + ...(name === 'missing credential' ? {} : { clientCredential: 'public-fixture' }), + }, + oauthReturn: { + fragment: '', + query: `${query}&state=v1.${ceremonyId}${name === 'wrong issuer' ? '' : '&iss=https%3A%2F%2Fgithub.com%2Flogin%2Foauth'}`, + }, + } + if (event === null) await expect(proveGitHub(context)).resolves.toBeNull() + else await expect(proveGitHub(context)).rejects.toMatchObject({ event }) + expect(prepare).not.toHaveBeenCalled() + expect(generate).not.toHaveBeenCalled() +}) + +it.each(['closed', 'identity setup failed'])( + 'retires both sessions and proving when %s [LIBID-PROVER-004]', + async (failure) => { + const abort = new AbortController() + const ceremonyId = '6e171568-54e1-4f0d-aeb5-e8859826476a' + const context: ProverContext = { + ceremonyId, + signal: abort.signal, + emit: vi.fn(), + request: { + type: 'prove-identity', + platformId: 'github', + platformCeremonyVersion: 1, + clientId: 'client', + clientCredential: 'public-fixture', + redirectUri: 'https://bridge.test/auth/callback', + codeVerifier: 'a'.repeat(43), + notaryAddress: 'https://notary.test', + }, + oauthReturn: { + fragment: '', + query: `?code=fixture&state=v1.${ceremonyId}&iss=https%3A%2F%2Fgithub.com%2Flogin%2Foauth`, + }, + } + prepare.mockImplementation(() => { + const signal: AbortSignal = initialize.mock.calls[0][1] + return new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }) + const identitySetup = deferred() + prepare + .mockImplementationOnce(prepare.getMockImplementation()!) + .mockReturnValueOnce(identitySetup.promise) + const result = proveGitHub(context) + const checked = expect(result).rejects.toMatchObject({ + event: failure === 'closed' ? 'token-fetch' : 'identity-fetch', + message: failure, + }) + expect(prepare).toHaveBeenCalledTimes(2) + expect(initialize).toHaveBeenCalledOnce() + expect(initialize.mock.calls[0][0]).toBe(context.request.notaryAddress) + if (failure === 'closed') { + abort.abort(new Error(failure)) + identitySetup.reject(new Error(failure)) + } else identitySetup.reject(new Error(failure)) + await checked + expect(initialize.mock.calls[0][1].aborted).toBe(true) + expect(generate).not.toHaveBeenCalled() + expect(destroy).toHaveBeenCalledOnce() + }, +) diff --git a/ts/packages/ceremony/src/platforms/github/1/prover.ts b/ts/packages/ceremony/src/platforms/github/1/prover.ts new file mode 100644 index 00000000..25642cd8 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/github/1/prover.ts @@ -0,0 +1,152 @@ +import { resolve as resolveAsset } from '../../../assets/index.js' +import { + buildBearerLinkWitness, + validateBearerLinkPublicInputs, +} from '../../../barretenberg/circuits/bearer_link/inputs.js' +import { ProofEngine } from '../../../barretenberg/engine.js' +import { isClientCredential } from '../../../ccdp/index.js' +import { oauthState } from '../../../ccdp/navigation.js' +import { CeremonyError, ceremonyError } from '../../../errors.js' +import { operation } from '../../../events.js' +import { responseJson } from '../../../notary/http.js' +import { bearerOpening } from '../../../notary/notarize.js' +import { Notarization, type NotarizationSession, type Reveals } from '../../../notary/session.js' +import { isRecord } from '../../../primitives.js' +import { isFormClientId } from '../../authorization.js' +import { parseCodeOAuthReturn } from '../../codeReturn.js' +import type { ProverContext } from '../../context.js' +import type { Identity } from '../../types.js' +import { circuit, verificationKey } from './github.assets.js' +import { buildTokenRequest, selectToken } from './token.js' +import { identityRequest, selectIdentity } from './transcript.js' +import type { GitHubProofV1 } from './types.js' + +export async function prove( + context: ProverContext, +): Promise<{ identity: Identity<'github'>; proof: GitHubProofV1 } | null> { + const { request, emit } = context + const { notaryAddress, clientCredential } = request + context.signal.throwIfAborted() + if (!isFormClientId(request.clientId)) throw new Error('Invalid profile client identifier') + const returned = parseCodeOAuthReturn(context.oauthReturn, 'https://github.com/login/oauth') + if ( + !returned || + returned.state !== oauthState(context.ceremonyId) || + request.codeVerifier === null + ) + throw new CeremonyError('authorization', 'Invalid GitHub return') + if (returned.outcome === 'denied') return null + if (returned.outcome !== 'accepted') + throw new CeremonyError('authorization', 'GitHub authorization failed') + if (!isClientCredential(clientCredential)) + throw new CeremonyError('token-fetch', 'Missing GitHub public token-exchange credential') + if (notaryAddress === null) throw new CeremonyError('prover', 'Missing notary address') + const controller = new AbortController(), + abort = () => controller.abort(context.signal.reason) + context.signal.addEventListener('abort', abort, { once: true }) + const engine = new ProofEngine({ + circuitUrl: resolveAsset(circuit), + verificationKeyUrl: resolveAsset(verificationKey), + emit, + }) + // Observe every provisional branch immediately; any failure retires sibling work. + const observe = (p: Promise) => { + void p.catch((error) => controller.abort(error)) + return p + } + // Keep openings available immediately; observe final attestation failure before its join. + async function reveal( + session: NotarizationSession, + ranges: Reveals, + event: 'token-attestation' | 'identity-attestation', + ) { + try { + const result = await session.reveal(ranges) + const attestation = observe( + result.attestation.catch((error) => { + throw ceremonyError(error, event) + }), + ) + return { ...result, attestation } + } catch (error) { + throw ceremonyError(error, event) + } + } + try { + const input = { + clientId: request.clientId, + code: returned.code, + redirectUri: request.redirectUri, + codeVerifier: request.codeVerifier, + clientCredential, + } + const notary = new Notarization(notaryAddress, controller.signal, emit) + const tokenRequest = buildTokenRequest(input) + const tokenSession = observe( + notary.prepare(tokenRequest.url, 'token-attestation').catch((e) => { + throw ceremonyError(e, 'token-fetch') + }), + ) + const identitySession = observe( + notary.prepare('https://api.github.com/user', 'identity-attestation').catch((e) => { + throw ceremonyError(e, 'identity-fetch') + }), + ) + const { session, selection, bearer } = await operation(emit, 'token-fetch', async () => { + const session = await tokenSession + const transcript = await session.send(tokenRequest) + const body = responseJson(transcript) + const selection = selectToken(transcript, input) + if (!isRecord(body) || body.access_token !== selection.accessToken) + throw new Error('Invalid token response') + const bearer = selection.accessToken + return { session, selection, bearer } + }) + const tokenReveal = observe(reveal(session, selection.ranges, 'token-attestation')) + const { identity, selected } = await operation(emit, 'identity-fetch', async () => { + const identity = await identitySession + const transcript = await identity.send(identityRequest(bearer)) + const body = responseJson(transcript, true), + selected = selectIdentity(transcript, bearer) + if ( + !isRecord(body) || + typeof body.id !== 'bigint' || + body.id.toString() !== selected.userId || + body.login !== selected.userName + ) + throw new Error('Invalid GitHub identity') + return { identity, selected } + }) + const identityReveal = observe(reveal(identity, selected.ranges, 'identity-attestation')) + const [first, second] = await Promise.all([tokenReveal, identityReveal]) + const final = observe(Promise.all([first.attestation, second.attestation])) + const inputs = await operation(emit, 'circuit-inputs', () => + buildBearerLinkWitness( + bearer, + bearerOpening(first.openings, 'received', selection.bearerRange, bearer), + bearerOpening(second.openings, 'sent', selected.bearerRange, bearer), + ), + ) + const proof = observe(engine.prove(inputs, controller.signal)) + const [raw, [tokenAttestation, identityAttestation]] = await Promise.all([proof, final]) + if (!validateBearerLinkPublicInputs(raw.publicInputs, inputs)) + throw new Error('Bearer public input mismatch') + return { + identity: { + platformId: 'github', + oauthClientId: request.clientId, + userId: selected.userId, + userName: selected.userName, + }, + proof: { + bearerLinkProof: raw.proof, + tokenAttestation, + identityAttestation, + }, + } + } finally { + context.signal.removeEventListener('abort', abort) + controller.abort() + engine.destroy() + } +} diff --git a/ts/packages/ceremony/src/platforms/github/1/token.test.ts b/ts/packages/ceremony/src/platforms/github/1/token.test.ts new file mode 100644 index 00000000..3c3a71fd --- /dev/null +++ b/ts/packages/ceremony/src/platforms/github/1/token.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { ExactHttpRequest } from '../../../notary/session.js' +import { buildTokenRequest, selectToken, type TokenRequestInput } from './token.js' + +const encoder = new TextEncoder(), + decoder = new TextDecoder() +const input: TokenRequestInput = { + clientId: 'Iv1.example', + code: 'github-code', + redirectUri: 'https://bridge.test/auth/callback', + codeVerifier: 'c8HLMaJOzc8OUoRYc7AocL5ioAkXVtAOmoGxoSY60IQ', + clientCredential: 'public&credential=with+delimiters%', +} +function transcript(request: ExactHttpRequest, body = '{"access_token":"ghu_fixture"}') { + return { + sent: encoder.encode( + [ + 'POST /login/oauth/access_token HTTP/1.1', + ...Object.entries(request.headers).map( + ([name, value]) => `${name}: ${decoder.decode(value)}`, + ), + '', + decoder.decode(request.body), + ].join('\r\n'), + ), + received: encoder.encode(`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n${body}`), + } +} + +it('reveals the complete canonical five-field request, including the public credential [LIBID-PROVER-004]', () => { + const request = buildTokenRequest(input) + expect(request.url).toBe('https://github.com/login/oauth/access_token') + const body = decoder.decode(request.body) + expect([...new URLSearchParams(body)]).toEqual([ + ['client_id', input.clientId], + ['code', input.code], + ['redirect_uri', input.redirectUri], + ['code_verifier', input.codeVerifier], + ['client_secret', input.clientCredential], + ]) + expect(body).toContain('client_secret=public%26credential%3Dwith%2Bdelimiters%25') + const raw = transcript(request) + const selected = selectToken(raw, input) + expect(selected.ranges.sent).toEqual([{ start: 0, end: raw.sent.length }]) + expect(selected.accessToken).toBe('ghu_fixture') + expect( + decoder.decode(raw.received.slice(selected.bearerRange.start, selected.bearerRange.end)), + ).toBe(selected.accessToken) + expect( + selected.ranges.received.map(({ start, end }) => + decoder.decode(raw.received.slice(start, end)), + ), + ).toEqual(['"access_token":"', '"']) +}) + +describe('complete form validation [LIBID-PROVER-004]', () => { + const original = decoder.decode(buildTokenRequest(input).body) + it.each([ + `${original}&code=second`, + `${original}&grant_type=refresh_token`, + `${original}&refresh_token=old`, + `${original}&device_code=other`, + `${original}&extra=value`, + original.replace('client_id=', '%63lient_id='), + original.replace('%3A', '%3a'), + original.replace('github-code', '%67ithub-code'), + original.replace('code=github-code', 'code='), + original.split('&').reverse().join('&'), + original.slice(0, original.indexOf('&client_secret=')), + original.replace('%26credential%3D', '&credential='), + `${original}&`, + ])('rejects an altered form despite a matching Content-Length: %s', (body) => { + const request = buildTokenRequest(input) + request.body = encoder.encode(body) + request.headers = { ...request.headers, 'Content-Length': encoder.encode(String(body.length)) } + expect(() => selectToken(transcript(request), input)).toThrow() + }) +}) + +it.each(['clientId', 'code', 'redirectUri', 'codeVerifier', 'clientCredential'] as const)( + 'binds the complete request to the frozen %s', + (field) => { + const raw = transcript(buildTokenRequest(input)) + expect(() => selectToken(raw, { ...input, [field]: `${input[field]}x` })).toThrow() + }, +) + +it.each(['', 'has space', 'trailing\n', '\tcredential', 'é', '\x7f'])( + 'rejects an invalid public credential %j before request construction', + (clientCredential) => { + expect(() => buildTokenRequest({ ...input, clientCredential })).toThrow() + }, +) + +it.each([ + '{"access_token":""}', + '{"access_token":"one","access_token":"two"}', + '{"refresh_token":"ghr_fixture"}', +])('rejects an invalid bearer response %s', (body) => { + expect(() => selectToken(transcript(buildTokenRequest(input), body), input)).toThrow() +}) + +it('retains GitHub response whitespace in the bearer framing [LIBID-PROVER-004]', () => { + const raw = transcript(buildTokenRequest(input), '{ "access_token" : \n "ghu_fixture" }') + const selected = selectToken(raw, input) + expect( + decoder.decode( + raw.received.slice(selected.ranges.received[0].start, selected.ranges.received[0].end), + ), + ).toBe('"access_token" : \n "') +}) diff --git a/ts/packages/ceremony/src/platforms/github/1/token.ts b/ts/packages/ceremony/src/platforms/github/1/token.ts new file mode 100644 index 00000000..0c347b38 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/github/1/token.ts @@ -0,0 +1,77 @@ +import { isClientCredential, redirect } from '../../../ccdp/index.js' +import type { ExactHttpRequest, Transcript } from '../../../notary/session.js' +import { decodePrintable, quotedRange, tokenRequestBody } from '../../../notary/transcript.js' +import { bytesEqual } from '../../../primitives.js' +import { isFormClientId } from '../../authorization.js' + +const encoder = new TextEncoder() + +export interface TokenRequestInput { + clientId: string + code: string + redirectUri: string + codeVerifier: string + clientCredential: string +} + +function tokenBody(input: TokenRequestInput): Uint8Array { + if ( + !isFormClientId(input.clientId) || + !/^[\x21-\x7e]{1,1024}$/.test(input.code) || + !redirect(input.redirectUri) || + !/^[A-Za-z0-9_-]{43}$/.test(input.codeVerifier) || + !isClientCredential(input.clientCredential) + ) + throw new Error('Invalid GitHub token request') + return encoder.encode( + new URLSearchParams([ + ['client_id', input.clientId], + ['code', input.code], + ['redirect_uri', input.redirectUri], + ['code_verifier', input.codeVerifier], + ['client_secret', input.clientCredential], + ]).toString(), + ) +} + +/** GitHub's public-client PKCE exchange, sent inside the browser's Proxy session. */ +export function buildTokenRequest(input: TokenRequestInput): ExactHttpRequest { + const body = tokenBody(input) + return { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + headers: { + Host: encoder.encode('github.com'), + 'Content-Type': encoder.encode('application/x-www-form-urlencoded'), + 'Content-Length': encoder.encode(String(body.length)), + Accept: encoder.encode('application/json'), + Connection: encoder.encode('close'), + }, + body, + } +} + +/** Reveal the whole canonical request and the response framing around its hidden bearer. */ +export function selectToken(transcript: Transcript, input: TokenRequestInput) { + const body = tokenRequestBody( + transcript.sent, + 'POST /login/oauth/access_token HTTP/1.1', + 'github.com', + ) + // Exact equality to the frozen tuple rejects duplicate/extra fields and noncanonical encoding. + if (!bytesEqual(body, tokenBody(input))) throw new Error('GitHub token request changed') + const token = quotedRange(transcript.received, 'access_token') + // libid-circuits v0.3.0 bearer-link private-input width. + const accessToken = decodePrintable(token.value, 'access token', 128) + return { + accessToken, + bearerRange: { start: token.valueStart, end: token.range.end - 1 }, + ranges: { + sent: [{ start: 0, end: transcript.sent.length }], + received: [ + { start: token.range.start, end: token.valueStart }, + { start: token.range.end - 1, end: token.range.end }, + ], + }, + } +} diff --git a/ts/packages/ceremony/src/platforms/github/1/transcript.ts b/ts/packages/ceremony/src/platforms/github/1/transcript.ts new file mode 100644 index 00000000..7fedcb6c --- /dev/null +++ b/ts/packages/ceremony/src/platforms/github/1/transcript.ts @@ -0,0 +1,62 @@ +import type { ExactHttpRequest, Transcript } from '../../../notary/session.js' +import { + decodePrintable, + identityBearerRange, + jsonField, + quotedRange, + skipJsonWhitespace, +} from '../../../notary/transcript.js' +import { isUserId } from '../../types.js' +import { isUserName } from './types.js' + +const encoder = new TextEncoder() + +export function identityRequest(bearer: string): ExactHttpRequest { + if (!/^[\x21-\x7e]{1,128}$/.test(bearer)) throw new Error('Invalid bearer') + return { + url: 'https://api.github.com/user', + method: 'GET', + body: new Uint8Array(), + headers: Object.fromEntries( + Object.entries({ + Host: 'api.github.com', + Authorization: `Bearer ${bearer}`, + Accept: 'application/vnd.github+json', + 'User-Agent': navigator.userAgent, + 'X-GitHub-Api-Version': '2022-11-28', + Connection: 'close', + }).map(([k, v]) => [k, encoder.encode(v)]), + ), + } +} + +export function selectIdentity(transcript: Transcript, bearer: string) { + const { start } = identityBearerRange( + transcript.sent, + 'GET /user HTTP/1.1', + identityRequest(bearer).headers, + bearer, + ) + const { start: idStart, valueStart } = jsonField(transcript.received, 'id') + let end = valueStart + while (transcript.received[end] >= 48 && transcript.received[end] <= 57) end++ + const userId = new TextDecoder().decode(transcript.received.slice(valueStart, end)) + end = skipJsonWhitespace(transcript.received, end) + if (!isUserId(userId) || ![44, 125].includes(transcript.received[end])) + throw new Error('Invalid GitHub id') + const login = quotedRange(transcript.received, 'login'), + userName = decodePrintable(login.value, 'identity login', 39) + if (!isUserName(userName)) throw new Error('Invalid GitHub login') + return { + userId, + userName, + ranges: { + sent: [ + { start: 0, end: start }, + { start: start + bearer.length, end: transcript.sent.length }, + ], + received: [{ start: idStart, end: end + 1 }, login.range].sort((a, b) => a.start - b.start), + }, + bearerRange: { start, end: start + bearer.length }, + } +} diff --git a/ts/packages/ceremony/src/platforms/github/1/types.ts b/ts/packages/ceremony/src/platforms/github/1/types.ts new file mode 100644 index 00000000..346cb8a6 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/github/1/types.ts @@ -0,0 +1,40 @@ +import { isAttestation, type NotaryAttestation } from '../../../notary/decode.js' +import { hasExactKeys, isRecord, text } from '../../../primitives.js' +import { isFormClientId } from '../../authorization.js' +import { type Identity, isIdentity, isUserId, proofBytes } from '../../types.js' + +export interface GitHubProofV1 { + bearerLinkProof: Uint8Array + tokenAttestation: NotaryAttestation + identityAttestation: NotaryAttestation +} + +export function validateProof(v: unknown): GitHubProofV1 { + if ( + !isRecord(v) || + !hasExactKeys(v, ['bearerLinkProof', 'tokenAttestation', 'identityAttestation']) || + !proofBytes(v.bearerLinkProof) || + !isAttestation(v.tokenAttestation) || + !isAttestation(v.identityAttestation) + ) + throw new TypeError('Invalid GitHub proof') + return v as unknown as GitHubProofV1 +} + +export function validateIdentity(value: unknown): Identity<'github'> { + if ( + !isIdentity(value, 'github', [512, 20, 39]) || + !isFormClientId(value.oauthClientId) || + !isUserId(value.userId) || + !isUserName(value.userName) + ) + throw new TypeError('Invalid github identity') + return value +} + +export const isUserName = (value: string): boolean => + /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(value) && !value.includes('--') + +/** Client identifier constraints for this platform. */ +export const isClientId = (value: unknown): value is string => + text(value, 512) && isFormClientId(value) diff --git a/ts/packages/ceremony/src/platforms/github/1/url.ts b/ts/packages/ceremony/src/platforms/github/1/url.ts new file mode 100644 index 00000000..77345d4a --- /dev/null +++ b/ts/packages/ceremony/src/platforms/github/1/url.ts @@ -0,0 +1,26 @@ +export const pkce = true + +const AUTHORIZATION_ENDPOINT = 'https://github.com/login/oauth/authorize' + +const PKCE = /^[A-Za-z0-9_-]{43}$/ + +/** Build GitHub v1's fixed public authorization request. */ +export function buildAuthorizationUrl(input: { + clientId: string + redirectUri: string + state: string + codeChallenge: string | null +}): string { + if (input.codeChallenge === null || !PKCE.test(input.codeChallenge)) { + throw new Error('codeChallenge must be exactly 43 base64url characters') + } + const query = new URLSearchParams([ + ['client_id', input.clientId], + ['redirect_uri', input.redirectUri], + ['scope', 'read:user'], + ['state', input.state], + ['code_challenge', input.codeChallenge], + ['code_challenge_method', 'S256'], + ]) + return `${AUTHORIZATION_ENDPOINT}?${query}` +} diff --git a/ts/packages/ceremony/src/platforms/google/1/events.ts b/ts/packages/ceremony/src/platforms/google/1/events.ts new file mode 100644 index 00000000..e238a931 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/google/1/events.ts @@ -0,0 +1,10 @@ +import { proofEvents, proofWeights } from '../../../barretenberg/events.js' +import type { CoreEvent } from '../../../events.js' + +/** Core proving operations admitted for this platform version; independent of UI weights. */ +export const events: readonly CoreEvent[] = [...proofEvents] + +export const progressWeights = { + ...proofWeights, + 'signing-key-fetch': 1, +} diff --git a/ts/packages/ceremony/src/platforms/google/1/google.assets.ts b/ts/packages/ceremony/src/platforms/google/1/google.assets.ts new file mode 100644 index 00000000..261774ce --- /dev/null +++ b/ts/packages/ceremony/src/platforms/google/1/google.assets.ts @@ -0,0 +1,9 @@ +import { proofAssets } from '../../../barretenberg/barretenberg.assets.js' +import { + circuit, + verificationKey, +} from '../../../barretenberg/circuits/oidc_google/oidc_google.assets.js' + +export { circuit, verificationKey } + +export const assets = [...proofAssets, circuit, verificationKey] as const diff --git a/ts/packages/ceremony/src/platforms/google/1/oauth.test.ts b/ts/packages/ceremony/src/platforms/google/1/oauth.test.ts new file mode 100644 index 00000000..f7b2badb --- /dev/null +++ b/ts/packages/ceremony/src/platforms/google/1/oauth.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from 'vitest' +import { parseOAuthReturn } from './oauth.js' + +const state = 'v1.123e4567-e89b-42d3-a456-426614174000' + +const accepted = `#state=${state}&id_token=header.payload.signature` + +const parse = (fragment: string, query = '') => parseOAuthReturn({ query, fragment }) + +it('ignores Google metadata without changing the outcome or credential [LIBID-OAUTH-006]', () => { + for (const [fragment, expected] of [ + [accepted, { outcome: 'accepted', state, idToken: 'header.payload.signature' }], + [`#state=${state}&error=access_denied`, { outcome: 'denied', state }], + [`#state=${state}&error=server_error`, { outcome: 'error', state, error: 'server_error' }], + ] as const) { + for (const metadata of [ + '', + '&version_info=', + '&version_info=synthetic%2Fmetadata%3D', + '&provider_meta=value&release.rev=1&new-field=&1_debug=%E2%9C%93', + '&error_description=informational&error_uri=%2Fhelp', + ]) { + expect(parse(fragment + metadata)).toEqual(expected) + } + } +}) + +it('keeps ambiguous, malformed and credential-bearing extras rejected [LIBID-OAUTH-007] [TEST-PLAT-03]', () => { + for (const extra of [ + '&version_info=one&version_info=two', + '&%76ersion_info=value', + `&version_info=${'x'.repeat(8193)}`, + '&version_info=\n', + '&state=other', + '&id_token=other', + '&error=access_denied', + '&code=unexpected', + '&access_token=unexpected', + '&refresh_token=unexpected', + '&provider_meta=one&provider_meta=two', + '&provider_meta=%ZZ', + '&provider_meta=%FF', + '&%73tate=other', + ]) { + expect(parse(accepted + extra)).toBeNull() + } +}) + +it('does not let version_info supply missing evidence or change transport [LIBID-OAUTH-007]', () => { + for (const fragment of [ + '#version_info=synthetic', + `#state=${state}&version_info=synthetic`, + `#state=${state}&id_token=&version_info=synthetic`, + ]) { + expect(parse(fragment)).toBeNull() + } + expect(parse(accepted, '?version_info=synthetic')).toBeNull() + expect(parse('', `?state=${state}&id_token=synthetic&version_info=synthetic`)).toBeNull() +}) diff --git a/ts/packages/ceremony/src/platforms/google/1/oauth.ts b/ts/packages/ceremony/src/platforms/google/1/oauth.ts new file mode 100644 index 00000000..61d0c74c --- /dev/null +++ b/ts/packages/ceremony/src/platforms/google/1/oauth.ts @@ -0,0 +1,52 @@ +import type { OAuthReturn } from '../../../ccdp/navigation.js' + +export type GoogleOAuthOutcome = + | { outcome: 'accepted'; state: string; idToken: string } + | { outcome: 'denied'; state: string } + | { outcome: 'error'; state: string; error: string } + +// Ignore provider metadata; unexpected credentials still violate the ID-token profile. +const FIELD = /^([A-Za-z0-9_.-]{1,64})=(.*)$/ + +const MAX_FIELD_VALUE = 8192 + +const PRINTABLE_VALUE = /^[\x20-\x7e]*$/ + +function parseFields(component: string): Map | null { + const fields = new Map() + if (component === '') return fields + for (const part of component.split('&')) { + const match = FIELD.exec(part) + if (!match) return null + const [, key, value] = match + if (['code', 'access_token', 'refresh_token'].includes(key)) return null + if (fields.has(key)) return null + if (value.length > MAX_FIELD_VALUE || !PRINTABLE_VALUE.test(value)) return null + try { + decodeURIComponent(value.replace(/\+/g, ' ')) + } catch { + return null + } + fields.set(key, value) + } + return fields +} + +/** Parse Google's exact fragment-only accepted, denied, or error return. */ +export function parseOAuthReturn(oauthReturn: OAuthReturn): GoogleOAuthOutcome | null { + if (oauthReturn.query !== '' || !oauthReturn.fragment.startsWith('#')) return null + const fields = parseFields(oauthReturn.fragment.slice(1)) + if (!fields) return null + const state = fields.get('state') + if (!state) return null + const idToken = fields.get('id_token') + const error = fields.get('error') + if (idToken !== undefined && error !== undefined) return null + if (idToken !== undefined) { + return idToken ? { outcome: 'accepted', state, idToken } : null + } + if (!error) return null + return error === 'access_denied' + ? { outcome: 'denied', state } + : { outcome: 'error', state, error } +} diff --git a/ts/packages/ceremony/src/platforms/google/1/prover.ts b/ts/packages/ceremony/src/platforms/google/1/prover.ts new file mode 100644 index 00000000..e226ffa6 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/google/1/prover.ts @@ -0,0 +1,82 @@ +import { resolve as resolveAsset } from '../../../assets/index.js' +import { buildGoogleWitness } from '../../../barretenberg/circuits/oidc_google/inputs.js' +import { validateGooglePublicInputs } from '../../../barretenberg/circuits/oidc_google/publicInputs.js' +import { ProofEngine } from '../../../barretenberg/engine.js' +import { oauthState } from '../../../ccdp/navigation.js' +import { CeremonyError } from '../../../errors.js' +import { operation } from '../../../events.js' +import { parseJson } from '../../../json.js' +import { isRecord } from '../../../primitives.js' +import { readBody } from '../../../response.js' +import type { ProverContext } from '../../context.js' +import type { Identity } from '../../types.js' +import { circuit, verificationKey } from './google.assets.js' +import { parseOAuthReturn } from './oauth.js' +import { decodeGoogleHeader, decodeGoogleIdToken } from './token.js' +import type { GoogleProofV1 } from './types.js' + +export async function prove( + context: ProverContext, +): Promise<{ identity: Identity<'google'>; proof: GoogleProofV1 } | null> { + const { request, signal, emit } = context + signal.throwIfAborted() + const returned = parseOAuthReturn(context.oauthReturn) + if ( + !returned || + returned.state !== oauthState(context.ceremonyId) || + request.codeVerifier !== null + ) + throw new CeremonyError('authorization', 'Invalid Google return') + if (returned.outcome === 'denied') return null + if (returned.outcome !== 'accepted') + throw new CeremonyError('authorization', 'Google authorization failed') + const token = decodeGoogleIdToken(returned.idToken), + header = token && decodeGoogleHeader(token.header) + if ( + !token || + token.claims.aud !== request.clientId || + !token.claims.emailVerified || + token.claims.exp <= Date.now() / 1000 || + typeof header?.kid !== 'string' + ) + throw new CeremonyError('authorization', 'Invalid Google token') + const engine = new ProofEngine({ + circuitUrl: resolveAsset(circuit), + verificationKeyUrl: resolveAsset(verificationKey), + emit, + }) + try { + const key = await operation(emit, 'signing-key-fetch', async () => { + const response = await fetch('https://www.googleapis.com/oauth2/v3/certs', { + credentials: 'omit', + redirect: 'error', + signal, + }) + if (!response.ok) throw new Error('Signing key request failed') + const body: unknown = parseJson( + new TextDecoder('utf-8', { fatal: true }).decode(await readBody(response, 128 * 1024)), + ) + if (!isRecord(body) || !Array.isArray(body.keys)) throw new Error('Invalid key set') + const keys = body.keys.filter((k) => isRecord(k) && k.kid === header.kid) + if (keys.length !== 1) throw new Error('Signing key is not unique') + return keys[0] + }) + const built = await operation(emit, 'circuit-inputs', () => + buildGoogleWitness(returned.idToken, key), + ) + const raw = await engine.prove(built.inputs, signal), + proof = { identityProof: raw.proof, ...built.proofFields } + if ( + !validateGooglePublicInputs( + raw.publicInputs, + new Uint8Array(built.inputs.authorization_digest), + built.identity, + proof, + ) + ) + throw new Error('Google public input mismatch') + return { identity: built.identity, proof } + } finally { + engine.destroy() + } +} diff --git a/ts/packages/ceremony/src/platforms/google/1/token.ts b/ts/packages/ceremony/src/platforms/google/1/token.ts new file mode 100644 index 00000000..1fb71abd --- /dev/null +++ b/ts/packages/ceremony/src/platforms/google/1/token.ts @@ -0,0 +1,96 @@ +import { parseJson } from '../../../json.js' +import { b64urlDecode } from '../../../primitives.js' +import { MAX_AUD_BYTES, MAX_EMAIL_BYTES, MAX_SUB_BYTES, printableWithoutQuote } from './types.js' + +export interface GoogleIdTokenClaims { + iss: string + aud: string + sub: string + email: string + emailVerified: boolean + exp: number + nonce: string +} + +export interface DecodedGoogleIdToken { + header: Uint8Array + headerB64: string + payload: Uint8Array + payloadB64: string + signature: Uint8Array + claims: GoogleIdTokenClaims +} + +const text = new TextDecoder('utf-8', { fatal: true }) + +function json(bytes: Uint8Array): Record | null { + try { + const value: unknown = parseJson(text.decode(bytes)) + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null + } catch { + return null + } +} + +/** The one strict payload decoder used only by Prover. */ +export function decodeGoogleIdToken(idToken: string): DecodedGoogleIdToken | null { + const segments = idToken.split('.') + if (segments.length !== 3 || segments.some((segment) => segment === '')) return null + const [headerB64, payloadB64, signatureB64] = segments + const header = b64urlDecode(headerB64) + const payload = b64urlDecode(payloadB64) + const signature = b64urlDecode(signatureB64) + if (!header || !payload || !signature) return null + + const p = json(payload) + if (!p) return null + const { iss, aud, sub, email, email_verified: emailVerified, exp, nonce } = p + if (typeof iss !== 'string' || iss === '') return null + if (typeof aud !== 'string' || aud.length > MAX_AUD_BYTES || !printableWithoutQuote.test(aud)) { + return null + } + if (typeof sub !== 'string' || sub.length > MAX_SUB_BYTES || !printableWithoutQuote.test(sub)) { + return null + } + if ( + typeof email !== 'string' || + email.length > MAX_EMAIL_BYTES || + !printableWithoutQuote.test(email) + ) { + return null + } + if (typeof emailVerified !== 'boolean') return null + if (typeof exp !== 'number' || !Number.isSafeInteger(exp) || exp < 0) return null + if (typeof nonce !== 'string' || nonce === '') return null + + return { + header, + headerB64, + payload, + payloadB64, + signature, + claims: { iss, aud, sub, email, emailVerified, exp, nonce }, + } +} + +export function decodeGoogleHeader(bytes: Uint8Array): Record | null { + return json(bytes) +} + +interface ParsedGoogleIdToken extends DecodedGoogleIdToken { + kid: string +} + +export function parseGoogleIdToken(idToken: string): ParsedGoogleIdToken { + const token = decodeGoogleIdToken(idToken) + if (token?.claims.iss !== 'https://accounts.google.com') { + throw new Error('invalid Google ID token') + } + const header = decodeGoogleHeader(token.header) + if (header?.alg !== 'RS256' || typeof header.kid !== 'string' || header.kid === '') { + throw new Error('invalid Google ID token header') + } + return { ...token, kid: header.kid } +} diff --git a/ts/packages/ceremony/src/platforms/google/1/types.ts b/ts/packages/ceremony/src/platforms/google/1/types.ts new file mode 100644 index 00000000..b7f8f04e --- /dev/null +++ b/ts/packages/ceremony/src/platforms/google/1/types.ts @@ -0,0 +1,43 @@ +import { fixedBytes, hasExactKeys, isRecord, text } from '../../../primitives.js' +import { type Identity, isIdentity, proofBytes } from '../../types.js' + +export const MAX_HONK_PROOF_BYTES = 4 * 1024 * 1024 + +export const MAX_EMAIL_BYTES = 62, + MAX_SUB_BYTES = 31, + MAX_AUD_BYTES = 128, + RSA_MODULUS_BYTES = 256 + +export interface GoogleProofV1 { + identityProof: Uint8Array + tokenExpiresAt: number + signingKeyModulus: Uint8Array +} + +export function validateProof(v: unknown): GoogleProofV1 { + if ( + !isRecord(v) || + !hasExactKeys(v, ['identityProof', 'tokenExpiresAt', 'signingKeyModulus']) || + !proofBytes(v.identityProof) || + typeof v.tokenExpiresAt !== 'number' || + !Number.isSafeInteger(v.tokenExpiresAt) || + v.tokenExpiresAt < 0 || + !fixedBytes(v.signingKeyModulus, 256) + ) + throw new TypeError('Invalid Google proof') + return v as unknown as GoogleProofV1 +} + +export function validateIdentity(value: unknown): Identity<'google'> { + if ( + !isIdentity(value, 'google', [128, 31, 62]) || + ![value.oauthClientId, value.userId, value.userName].every((s) => printableWithoutQuote.test(s)) + ) + throw new TypeError('Invalid google identity') + return value +} + +export const printableWithoutQuote = /^[\x20-\x21\x23-\x7e]+$/ + +/** Client identifier constraints for this platform. */ +export const isClientId = (value: unknown): value is string => text(value, MAX_AUD_BYTES) diff --git a/ts/packages/ceremony/src/platforms/google/1/url.ts b/ts/packages/ceremony/src/platforms/google/1/url.ts new file mode 100644 index 00000000..5219683a --- /dev/null +++ b/ts/packages/ceremony/src/platforms/google/1/url.ts @@ -0,0 +1,33 @@ +import { b64urlEncode } from '../../../primitives.js' + +/** Google carries the digest as the OIDC nonce; no PKCE (spec §5 table). */ +export const pkce = false + +const AUTHORIZATION_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth' + +/** + * The §3.1 authorization request: seven fields, exactly this order, + * serialized by the WHATWG form-urlencoded serializer (REQ-COMMON-07/-08 — + * `URLSearchParams` implements it). The nonce is the base64url encoding of + * the 32 digest bytes (REQ-PLAT-10). + */ +export function buildAuthorizationUrl(input: { + clientId: string + redirectUri: string + state: string + authorizationDigest: Uint8Array +}): string { + if (input.authorizationDigest.length !== 32) { + throw new Error('authorizationDigest must be exactly 32 bytes') + } + const query = new URLSearchParams([ + ['response_type', 'id_token'], + ['response_mode', 'fragment'], + ['client_id', input.clientId], + ['redirect_uri', input.redirectUri], + ['scope', 'openid email'], + ['state', input.state], + ['nonce', b64urlEncode(input.authorizationDigest)], + ]) + return `${AUTHORIZATION_ENDPOINT}?${query}` +} diff --git a/ts/packages/ceremony/src/platforms/index.ts b/ts/packages/ceremony/src/platforms/index.ts new file mode 100644 index 00000000..e333221e --- /dev/null +++ b/ts/packages/ceremony/src/platforms/index.ts @@ -0,0 +1,150 @@ +import type { IdentityProof } from '../ccdp/index.js' +import * as githubEvents from './github/1/events.js' +import { + isClientId as githubClientId, + validateIdentity as githubIdentity, + validateProof as githubProof, +} from './github/1/types.js' +import * as githubUrl from './github/1/url.js' +import * as googleEvents from './google/1/events.js' +import { + isClientId as googleClientId, + validateIdentity as googleIdentity, + validateProof as googleProof, +} from './google/1/types.js' +import * as googleUrl from './google/1/url.js' +import type { Identity } from './types.js' +import * as xEvents from './x/1/events.js' +import { + isClientId as xClientId, + validateIdentity as xIdentity, + validateProof as xProof, +} from './x/1/types.js' +import * as xUrl from './x/1/url.js' + +export type { Identity } from './types.js' + +export const platforms = { + google: { + requiresClientCredential: false, + isClientId: googleClientId, + versions: { + 1: { + ...googleUrl, + ...googleEvents, + validateIdentity: googleIdentity, + validateProof: googleProof, + }, + }, + }, + x: { + requiresClientCredential: false, + isClientId: xClientId, + versions: { 1: { ...xUrl, ...xEvents, validateIdentity: xIdentity, validateProof: xProof } }, + }, + github: { + requiresClientCredential: true, + isClientId: githubClientId, + versions: { + 1: { + ...githubUrl, + ...githubEvents, + validateIdentity: githubIdentity, + validateProof: githubProof, + }, + }, + }, +} as const + +export type PlatformId = keyof typeof platforms + +export type SupportedCeremonyVersion

= P extends PlatformId + ? keyof (typeof platforms)[P]['versions'] & number + : never + +export type ProofByPlatformVersion = { + [P in PlatformId]: { + [V in SupportedCeremonyVersion

]: (typeof platforms)[P]['versions'][V] extends { + validateProof(value: unknown): infer Proof + } + ? Proof + : never + } +} + +export const supportedPlatforms: readonly PlatformId[] = Object.freeze( + Object.keys(platforms) as PlatformId[], +) + +export type OAuthProof

= { + [K in P]: { + [V in SupportedCeremonyVersion]: { + platformCeremonyVersion: V + authorizationNonce: Uint8Array + proof: ProofByPlatformVersion[K][V] + } + }[SupportedCeremonyVersion] +}[P] + +export type IdentityResult

= + | { [K in P]: { status: 'accepted'; identity: Identity; oauthProof: OAuthProof } }[P] + | { status: 'denied' } + +export function validateProofMessage

>( + platformId: P, + version: V, + message: IdentityProof, +): IdentityProof & { identity: Identity

; proof: ProofByPlatformVersion[P][V] } { + const implementation = platforms[platformId]?.versions[version as 1] + if (!implementation) throw new TypeError('Unsupported platform version') + implementation.validateIdentity(message.identity) + implementation.validateProof(message.proof) + return message as IdentityProof & { + identity: Identity

+ proof: ProofByPlatformVersion[P][V] + } +} + +export function assembleResult

( + platformId: P, + version: SupportedCeremonyVersion

, + message: IdentityProof, + clientId: string, + authorizationNonce: Uint8Array, +): IdentityResult

{ + const { identity, proof } = validateProofMessage(platformId, version, message) + if (identity.oauthClientId !== clientId) throw new TypeError('OAuth client ID mismatch') + return { + status: 'accepted', + identity, + oauthProof: { + platformCeremonyVersion: version, + authorizationNonce: authorizationNonce.slice(), + proof, + }, + } as IdentityResult

+} + +/** Enumerate the closed catalog/Bridge intersection in ascending version order. */ +export function commonVersions

( + platform: P, + advertised: readonly number[], +): readonly SupportedCeremonyVersion

[] { + if (typeof platform !== 'string' || !Object.hasOwn(platforms, platform)) + throw new TypeError('Unsupported platform') + return Object.freeze( + Object.keys(platforms[platform].versions) + .map(Number) + .filter((v) => advertised.includes(v)) + .sort((a, b) => a - b), + ) as readonly SupportedCeremonyVersion

[] +} + +export function implementationFor

( + platform: P, + version: SupportedCeremonyVersion

, +) { + const implementation = platforms[platform].versions[version as 1] + if (!implementation) throw new TypeError('Unsupported platform version') + return implementation +} diff --git a/ts/packages/ceremony/src/platforms/platforms.assets.ts b/ts/packages/ceremony/src/platforms/platforms.assets.ts new file mode 100644 index 00000000..59b38db4 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/platforms.assets.ts @@ -0,0 +1,18 @@ +import type { Asset } from '../assets/index.js' +import { assets as github } from './github/1/github.assets.js' +import { assets as google } from './google/1/google.assets.js' +import type { PlatformId, SupportedCeremonyVersion } from './index.js' +import { assets as x } from './x/1/x.assets.js' + +export const assetsByPlatform = { + google: { 1: google }, + x: { 1: x }, + github: { 1: github }, +} as const satisfies { [P in PlatformId]: { [V in SupportedCeremonyVersion

]: readonly Asset[] } } + +import { bearerCircuit } from '../barretenberg/circuits/bearer_link/bearer_link.assets.js' +import { circuit as googleCircuit } from './google/1/google.assets.js' + +export const circuits = [googleCircuit, bearerCircuit] as const + +export { SRS_SIZE } from '../barretenberg/barretenberg.assets.js' diff --git a/ts/packages/ceremony/src/platforms/types.test.ts b/ts/packages/ceremony/src/platforms/types.test.ts new file mode 100644 index 00000000..29f8a688 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/types.test.ts @@ -0,0 +1,76 @@ +import { expect, it } from 'vitest' +import { validateIdentity as github } from './github/1/types.js' +import { validateIdentity as google, validateProof } from './google/1/types.js' +import type { IdentityResult, OAuthProof, ProofByPlatformVersion } from './index.js' +import { validateProofMessage } from './index.js' +import { validateIdentity as x } from './x/1/types.js' + +it('checks profile identity encodings without reading evidence [LIBID-MOD-019]', () => { + for (const [validate, identity, badNames] of [ + [ + google, + { platformId: 'google', oauthClientId: 'client', userId: '1', userName: 'a@b.c' }, + ['é', 'a"b'], + ], + [x, { platformId: 'x', oauthClientId: 'client', userId: '1', userName: 'a_b' }, ['a-b', 'é']], + [ + github, + { platformId: 'github', oauthClientId: 'client', userId: '1', userName: 'a-b' }, + ['a_b', 'a--b', '-a'], + ], + ] as const) { + expect(validate(identity)).toBe(identity) + for (const userName of badNames) expect(() => validate({ ...identity, userName })).toThrow() + expect(() => validate({ ...identity, platformId: 'other' })).toThrow() + if (identity.platformId !== 'google') { + for (const userId of ['0', '01', '18446744073709551616']) + expect(() => validate({ ...identity, userId })).toThrow() + expect(() => validate({ ...identity, oauthClientId: 'a+b' })).toThrow() + } + } +}) + +it('narrows the separate identity/proof message and rejects nested identity [LIBID-MOD-019]', () => { + const message = { + type: 'identity-proof' as const, + identity: { platformId: 'google', oauthClientId: 'client', userId: '1', userName: 'a@b.c' }, + proof: { + identityProof: new Uint8Array([1]), + tokenExpiresAt: 42, + signingKeyModulus: new Uint8Array(256), + }, + } + expect(validateProofMessage('google', 1, message)).toBe(message) + expect(() => validateProof({ ...message.proof, identity: message.identity })).toThrow() +}) + +// Compile-only result correlation and dynamic narrowing checks. +function checkResultTypes(result: IdentityResult) { + const isGoogle = ( + value: IdentityResult, + ): value is Extract, { status: 'accepted' }> => + value.status === 'accepted' && + value.identity.platformId === 'google' && + value.oauthProof.platformCeremonyVersion === 1 + if (isGoogle(result)) { + const proof: Uint8Array = result.oauthProof.proof.identityProof + void proof + } + if (result.status === 'accepted' && result.identity.platformId === 'google') { + // @ts-expect-error A nested discriminator does not narrow its sibling. + result.oauthProof.proof.identityProof + } + const googleProof = {} as OAuthProof<'google'> + // @ts-expect-error Platform and proof must correspond. + const invalid: IdentityResult = { + status: 'accepted', + identity: { platformId: 'x', oauthClientId: 'c', userId: '1', userName: 'a' }, + oauthProof: googleProof, + } + // @ts-expect-error Unsupported version. + const unsupported: ProofByPlatformVersion['google'][2] = {} + void invalid + void unsupported +} + +void checkResultTypes diff --git a/ts/packages/ceremony/src/platforms/types.ts b/ts/packages/ceremony/src/platforms/types.ts new file mode 100644 index 00000000..e15eef86 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/types.ts @@ -0,0 +1,30 @@ +import { hasExactKeys, isRecord, text } from '../primitives.js' +import type { PlatformId } from './index.js' + +export interface Identity

{ + platformId: P + oauthClientId: string + userId: string + userName: string +} + +export const proofBytes = (v: unknown): v is Uint8Array => + v instanceof Uint8Array && v.length > 0 && v.length <= 4 * 1024 * 1024 + +export function isIdentity

( + v: unknown, + platform: P, + limits = [512, 255, 255], +): v is Identity

{ + return ( + isRecord(v) && + hasExactKeys(v, ['platformId', 'oauthClientId', 'userId', 'userName']) && + v.platformId === platform && + text(v.oauthClientId, limits[0]) && + text(v.userId, limits[1]) && + text(v.userName, limits[2]) + ) +} + +export const isUserId = (value: string): boolean => + /^[1-9][0-9]{0,19}$/.test(value) && BigInt(value) <= 0xffffffffffffffffn diff --git a/ts/packages/ceremony/src/platforms/x/1/events.ts b/ts/packages/ceremony/src/platforms/x/1/events.ts new file mode 100644 index 00000000..31429a0e --- /dev/null +++ b/ts/packages/ceremony/src/platforms/x/1/events.ts @@ -0,0 +1,19 @@ +import { proofEvents, proofWeights } from '../../../barretenberg/events.js' +import type { CoreEvent } from '../../../events.js' + +/** Core proving operations admitted for this platform version; independent of UI weights. */ +export const events: readonly CoreEvent[] = [ + ...proofEvents, + 'token-fetch', + 'token-attestation', + 'identity-fetch', + 'identity-attestation', +] + +export const progressWeights = { + ...proofWeights, + 'token-fetch': 2, + 'token-attestation': 1, + 'identity-fetch': 2, + 'identity-attestation': 1, +} diff --git a/ts/packages/ceremony/src/platforms/x/1/prover.test.ts b/ts/packages/ceremony/src/platforms/x/1/prover.test.ts new file mode 100644 index 00000000..1ce4e9ae --- /dev/null +++ b/ts/packages/ceremony/src/platforms/x/1/prover.test.ts @@ -0,0 +1,167 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { OperationEvent } from '../../../events.js' +import type { ProverContext } from '../../context.js' +import { prove as proveX } from './prover.js' + +const { prepare, initialize, generate, destroy } = vi.hoisted(() => ({ + prepare: vi.fn(), + initialize: vi.fn(), + generate: vi.fn(), + destroy: vi.fn(), +})) + +vi.mock('virtual:ceremony-assets', () => ({ urls: {} })) +vi.mock('../../../assets/index.js', async (original) => ({ + ...(await original()), + resolve: () => 'https://ccdp.test/asset', +})) +vi.mock('../../../barretenberg/engine.js', () => ({ + ProofEngine: class { + prove = generate + destroy = destroy + }, +})) +vi.mock('../../../barretenberg/circuits/bearer_link/inputs.js', () => ({ + buildBearerLinkWitness: () => ({}), + validateBearerLinkPublicInputs: () => true, +})) +vi.mock('../../../notary/notarize.js', () => ({ bearerOpening: () => ({}) })) +vi.mock('../../../notary/session.js', () => ({ + Notarization: class { + constructor(address: string, signal: AbortSignal, emit: (event: OperationEvent) => void) { + initialize(address, signal, emit) + } + prepare = prepare + }, +})) +vi.mock('./transcript.js', async (original) => ({ + ...(await original()), + selectTokenReveals: () => ({ accessToken: 'fixture', ranges: { sent: [], recv: [] } }), + selectIdentityReveals: () => ({ sent: [{ end: 0 }, { start: 1 }], recv: [] }), + identityFromReveals: () => ({ userId: '1', handle: 'fixture' }), +})) + +afterEach(() => vi.resetAllMocks()) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function transcript(body: unknown) { + const json = JSON.stringify(body) + return { + sent: new Uint8Array(), + received: new TextEncoder().encode( + `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${json.length}\r\n\r\n${json}`, + ), + } +} + +it.each(['accepted', 'failed'])( + 'overlaps identity fetch with token openings and waits for every output: %s [LIBID-PROVER-007] [LIBID-PROVER-013] [LIBID-PROVER-014]', + async (outcome) => { + // Synthetic sessions isolate orchestration; real TLSN concurrency has a separate qualification gate. + const tokenResponse = deferred>() + const tokenOpenings = deferred<{ openings: []; attestation: Promise }>() + const tokenAttestation = deferred() + const identityAttestation = deferred() + const token = { + send: vi.fn(() => tokenResponse.promise), + reveal: vi.fn(() => tokenOpenings.promise), + } + const identity = { + send: vi.fn(async () => transcript({ data: { id: '1', username: 'fixture' } })), + reveal: vi.fn(async () => ({ openings: [], attestation: identityAttestation.promise })), + } + prepare.mockResolvedValueOnce(token).mockResolvedValueOnce(identity) + generate.mockResolvedValue({ proof: new Uint8Array([1]), publicInputs: [] }) + const events: OperationEvent[] = [] + const ceremonyId = '6e171568-54e1-4f0d-aeb5-e8859826476a' + const context: ProverContext = { + ceremonyId, + signal: new AbortController().signal, + emit: (event) => events.push(event), + request: { + type: 'prove-identity', + platformId: 'x', + platformCeremonyVersion: 1, + clientId: 'client', + codeVerifier: 'a'.repeat(43), + redirectUri: 'https://bridge.test/callback', + notaryAddress: 'https://notary.test', + }, + oauthReturn: { query: `?code=fixture&state=v1.${ceremonyId}`, fragment: '' }, + } + let settled = false + const result = proveX(context).finally(() => { + settled = true + }) + const checked = + outcome === 'accepted' + ? expect(result).resolves.toMatchObject({ identity: { userId: '1' } }) + : expect(result).rejects.toMatchObject({ + event: 'token-attestation', + message: 'Final attestation failed', + }) + expect(prepare).toHaveBeenCalledTimes(2) + expect(identity.send).not.toHaveBeenCalled() + tokenResponse.resolve(transcript({ access_token: 'fixture' })) + await vi.waitFor(() => expect(identity.reveal).toHaveBeenCalledOnce()) + // Event timing lives in the real session tests; this fake isolates the platform joins. + expect(initialize).toHaveBeenCalledWith( + 'https://notary.test', + expect.any(AbortSignal), + context.emit, + ) + expect(prepare.mock.calls.map(([, event]) => event)).toEqual([ + 'token-attestation', + 'identity-attestation', + ]) + identityAttestation.resolve(new Uint8Array([2])) + expect(generate).not.toHaveBeenCalled() + tokenOpenings.resolve({ openings: [], attestation: tokenAttestation.promise }) + await vi.waitFor(() => expect(generate).toHaveBeenCalledOnce()) + expect(settled).toBe(false) + if (outcome === 'accepted') tokenAttestation.resolve(new Uint8Array([3])) + else tokenAttestation.reject(new Error('Final attestation failed')) + await checked + for (const name of ['token-fetch', 'identity-fetch']) + expect(events.filter((event) => event.event === name).map((event) => event.phase)).toEqual([ + 'started', + 'finished', + ]) + expect(destroy).toHaveBeenCalledOnce() + }, +) + +it.each(['notaryAddress', 'codeVerifier'] as const)( + 'requires %s before notarization [LIBID-OAUTH-021]', + async (field) => { + const ceremonyId = '6e171568-54e1-4f0d-aeb5-e8859826476a' + const context: ProverContext = { + ceremonyId, + signal: new AbortController().signal, + emit: vi.fn(), + request: { + type: 'prove-identity', + platformId: 'x', + platformCeremonyVersion: 1, + clientId: 'client', + redirectUri: 'https://bridge.test/callback', + codeVerifier: 'A'.repeat(43), + notaryAddress: 'https://notary.test', + }, + oauthReturn: { query: `?code=fixture&state=v1.${ceremonyId}`, fragment: '' }, + } + context.request[field] = null + await expect(proveX(context)).rejects.toBeInstanceOf(Error) + expect(prepare).not.toHaveBeenCalled() + expect(generate).not.toHaveBeenCalled() + }, +) diff --git a/ts/packages/ceremony/src/platforms/x/1/prover.ts b/ts/packages/ceremony/src/platforms/x/1/prover.ts new file mode 100644 index 00000000..ddd61430 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/x/1/prover.ts @@ -0,0 +1,177 @@ +import { resolve as resolveAsset } from '../../../assets/index.js' +import { + buildBearerLinkWitness, + validateBearerLinkPublicInputs, +} from '../../../barretenberg/circuits/bearer_link/inputs.js' +import { ProofEngine } from '../../../barretenberg/engine.js' +import { oauthState } from '../../../ccdp/navigation.js' +import { CeremonyError, ceremonyError } from '../../../errors.js' +import { operation } from '../../../events.js' +import { responseJson } from '../../../notary/http.js' +import { bearerOpening } from '../../../notary/notarize.js' +import { Notarization } from '../../../notary/session.js' +import { isRecord } from '../../../primitives.js' +import { isFormClientId } from '../../authorization.js' +import { parseCodeOAuthReturn } from '../../codeReturn.js' +import type { ProverContext } from '../../context.js' +import type { Identity } from '../../types.js' +import { + buildIdentityRequest, + buildTokenRequest, + identityFromReveals, + selectIdentityReveals, + selectTokenReveals, +} from './transcript.js' +import type { XProofV1 } from './types.js' +import { circuit, verificationKey } from './x.assets.js' + +export async function prove( + context: ProverContext, +): Promise<{ identity: Identity<'x'>; proof: XProofV1 } | null> { + const { request, emit } = context + const { notaryAddress } = request + context.signal.throwIfAborted() + if (!isFormClientId(request.clientId)) throw new Error('Invalid profile client identifier') + const returned = parseCodeOAuthReturn(context.oauthReturn) + if ( + !returned || + returned.state !== oauthState(context.ceremonyId) || + request.codeVerifier === null + ) + throw new CeremonyError('authorization', 'Invalid X return') + if (returned.outcome === 'denied') return null + if (returned.outcome !== 'accepted') + throw new CeremonyError('authorization', 'X authorization failed') + if (notaryAddress === null) throw new CeremonyError('prover', 'Missing notary address') + const controller = new AbortController(), + abort = () => controller.abort(context.signal.reason) + context.signal.addEventListener('abort', abort, { once: true }) + const engine = new ProofEngine({ + circuitUrl: resolveAsset(circuit), + verificationKeyUrl: resolveAsset(verificationKey), + emit, + }) + // Observe every provisional branch immediately; any failure retires sibling work. + const observe = (p: Promise) => { + void p.catch((error) => controller.abort(error)) + return p + } + try { + const input = { + clientId: request.clientId, + code: returned.code, + redirectUri: request.redirectUri, + codeVerifier: request.codeVerifier, + } + const notary = new Notarization(notaryAddress, controller.signal, emit) + const tokenRequest = buildTokenRequest(input) + const tokenSession = observe( + notary.prepare(tokenRequest.url, 'token-attestation').catch((e) => { + throw ceremonyError(e, 'token-fetch') + }), + ) + const identitySession = observe( + notary.prepare('https://api.x.com/2/users/me', 'identity-attestation').catch((e) => { + throw ceremonyError(e, 'identity-fetch') + }), + ) + const { session, selection, bearer } = await operation(emit, 'token-fetch', async () => { + const session = await tokenSession + const transcript = await session.send(tokenRequest) + const body = responseJson(transcript) + const selection = selectTokenReveals( + { sent: transcript.sent, recv: transcript.received }, + input, + ) + if (!isRecord(body) || body.access_token !== selection.accessToken) + throw new Error('Invalid token response') + const bearer = selection.accessToken + return { session, selection, bearer } + }) + const tokenReveal = observe( + session + .reveal({ sent: selection.ranges.sent, received: selection.ranges.recv }) + .then((value) => { + const attestation = observe( + value.attestation.catch((e) => { + throw ceremonyError(e, 'token-attestation') + }), + ) + return { ...value, attestation } + }) + .catch((e) => { + throw ceremonyError(e, 'token-attestation') + }), + ) + const { identity, ranges, extracted } = await operation(emit, 'identity-fetch', async () => { + const identity = await identitySession + const identityTranscript = await identity.send(buildIdentityRequest(bearer)) + const identityBody = responseJson(identityTranscript), + ranges = selectIdentityReveals( + { sent: identityTranscript.sent, recv: identityTranscript.received }, + bearer, + ) + const extracted = identityFromReveals( + ranges.recv.map((r) => identityTranscript.received.slice(r.start, r.end)), + ) + if ( + !isRecord(identityBody) || + !isRecord(identityBody.data) || + identityBody.data.id !== extracted.userId || + identityBody.data.username !== extracted.handle + ) + throw new Error('Invalid identity response') + return { identity, ranges, extracted } + }) + const identityReveal = observe( + identity + .reveal({ sent: ranges.sent, received: ranges.recv }) + .then((value) => { + const attestation = observe( + value.attestation.catch((e) => { + throw ceremonyError(e, 'identity-attestation') + }), + ) + return { ...value, attestation } + }) + .catch((e) => { + throw ceremonyError(e, 'identity-attestation') + }), + ) + const [first, second] = await Promise.all([tokenReveal, identityReveal]) + const final = observe(Promise.all([first.attestation, second.attestation])) + const inputs = await operation(emit, 'circuit-inputs', () => + buildBearerLinkWitness( + bearer, + bearerOpening(first.openings, 'received', selection.bearerRange, bearer), + bearerOpening( + second.openings, + 'sent', + { start: ranges.sent[0].end, end: ranges.sent[1].start }, + bearer, + ), + ), + ) + const proof = observe(engine.prove(inputs, controller.signal)) + const [raw, [tokenAttestation, identityAttestation]] = await Promise.all([proof, final]) + if (!validateBearerLinkPublicInputs(raw.publicInputs, inputs)) + throw new Error('Bearer public input mismatch') + return { + identity: { + platformId: 'x', + oauthClientId: request.clientId, + userId: extracted.userId, + userName: extracted.handle, + }, + proof: { + bearerLinkProof: raw.proof, + tokenAttestation, + identityAttestation, + }, + } + } finally { + context.signal.removeEventListener('abort', abort) + controller.abort() + engine.destroy() + } +} diff --git a/ts/packages/ceremony/src/platforms/x/1/transcript.ts b/ts/packages/ceremony/src/platforms/x/1/transcript.ts new file mode 100644 index 00000000..aad4e923 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/x/1/transcript.ts @@ -0,0 +1,179 @@ +import type { ByteRange, RevealRanges, Transcript } from '../../../notary/notarize.js' +import type { ExactHttpRequest } from '../../../notary/session.js' +import { + decodePrintable, + identityBearerRange, + quotedRange, + tokenRequestBody, +} from '../../../notary/transcript.js' +import { bytesEqual } from '../../../primitives.js' +import { isUserId } from '../../types.js' +import { isUserName } from './types.js' + +const encoder = new TextEncoder() + +const TOKEN_LINE = 'POST /2/oauth2/token HTTP/1.1' + +const IDENTITY_LINE = 'GET /2/users/me HTTP/1.1' + +const USER_ID = encoder.encode('"id"') + +const USERNAME = encoder.encode('"username"') + +// libid-circuits v0.3.0 bearer-link private-input width. +const MAX_BEARER_BYTES = 128 + +const PKCE = /^[A-Za-z0-9_-]{43}$/ + +export interface TokenRequestInput { + clientId: string + code: string + redirectUri: string + codeVerifier: string +} + +export interface TokenRevealSelection { + ranges: RevealRanges + accessToken: string + bearerRange: ByteRange +} + +function invalid(reason: string): never { + throw new Error(`invalid X v1 transcript: ${reason}`) +} + +function ascii(value: string): Uint8Array { + return encoder.encode(value) +} + +function tokenBody(input: TokenRequestInput): string { + if (!PKCE.test(input.codeVerifier)) { + throw new Error('codeVerifier must be exactly 43 base64url characters') + } + if (!input.clientId || !input.code || !input.redirectUri) { + throw new Error('X token request fields must be nonempty') + } + return new URLSearchParams([ + ['grant_type', 'authorization_code'], + ['client_id', input.clientId], + ['code', input.code], + ['redirect_uri', input.redirectUri], + ['code_verifier', input.codeVerifier], + ]).toString() +} + +export function buildTokenRequest(input: TokenRequestInput): ExactHttpRequest { + const body = encoder.encode(tokenBody(input)) + return { + url: 'https://api.x.com/2/oauth2/token', + method: 'POST', + // Concrete launch encoding qualified by the reclaimed-notary PoC. The + // attested TLS server identity, not this prover-written Host field, is + // the verifier's authority input. + headers: { + Host: ascii('api.x.com'), + 'Content-Type': ascii('application/x-www-form-urlencoded'), + 'Content-Length': ascii(String(body.length)), + Accept: ascii('application/json'), + Connection: ascii('close'), + }, + body, + } +} + +export function buildIdentityRequest(accessToken: string): ExactHttpRequest { + const bearer = encoder.encode(accessToken) + decodePrintable(bearer, 'access token', MAX_BEARER_BYTES) + return { + url: 'https://api.x.com/2/users/me', + method: 'GET', + headers: { + Authorization: ascii(`Bearer ${accessToken}`), + Accept: ascii('application/json'), + Host: ascii('api.x.com'), + Connection: ascii('close'), + }, + body: new Uint8Array(), + } +} + +/** Select X's token request fields and bearer framing from one raw transcript. */ +export function selectTokenReveals( + transcript: Transcript, + input: TokenRequestInput, +): TokenRevealSelection { + const body = tokenRequestBody(transcript.sent, TOKEN_LINE, 'api.x.com') + if (!bytesEqual(body, encoder.encode(tokenBody(input)))) { + return invalid('token request body changed') + } + + const accessToken = quotedRange(transcript.recv, 'access_token') + const token = decodePrintable(accessToken.value, 'access token', MAX_BEARER_BYTES) + const valueStart = accessToken.valueStart + return { + ranges: { + sent: [{ start: 0, end: transcript.sent.length }], + recv: [ + { start: accessToken.range.start, end: valueStart }, + { start: accessToken.range.end - 1, end: accessToken.range.end }, + ], + }, + accessToken: token, + bearerRange: { start: valueStart, end: accessToken.range.end - 1 }, + } +} + +export function identityFromReveals(reveals: readonly Uint8Array[]): { + userId: string + handle: string +} { + let userId: string | null = null + let handle: string | null = null + for (const reveal of reveals) { + if (bytesEqual(reveal.subarray(0, USER_ID.length), USER_ID) && reveal.at(-1) === 0x22) { + if (userId !== null) return invalid('identity id reveal is duplicated') + const field = quotedRange(reveal, 'id') + if (field.range.end !== reveal.length) return invalid('identity id trailing bytes') + const value = decodePrintable(field.value, 'identity id', 20) + if (!isUserId(value)) return invalid('identity id is not canonical') + userId = value + } else if ( + bytesEqual(reveal.subarray(0, USERNAME.length), USERNAME) && + reveal.at(-1) === 0x22 + ) { + if (handle !== null) return invalid('identity username reveal is duplicated') + const field = quotedRange(reveal, 'username') + if (field.range.end !== reveal.length) return invalid('identity username trailing bytes') + const value = decodePrintable(field.value, 'identity username', 15) + if (!isUserName(value)) return invalid('identity username is not canonical') + handle = value + } else { + return invalid('identity response contains an unexpected reveal') + } + } + if (userId === null || handle === null) return invalid('identity fields are missing') + return { userId, handle } +} + +/** Reveal the complete fixed identity request except its bearer and the two identity fields. */ +export function selectIdentityReveals(transcript: Transcript, accessToken: string): RevealRanges { + const expectedRequest = buildIdentityRequest(accessToken) + const { start: bearerStart, end: bearerEnd } = identityBearerRange( + transcript.sent, + IDENTITY_LINE, + expectedRequest.headers, + accessToken, + ) + + const id = quotedRange(transcript.recv, 'id') + const username = quotedRange(transcript.recv, 'username') + const response = [id.range, username.range].sort((a, b) => a.start - b.start) + identityFromReveals(response.map((range) => transcript.recv.slice(range.start, range.end))) + return { + sent: [ + { start: 0, end: bearerStart }, + { start: bearerEnd, end: transcript.sent.length }, + ], + recv: response, + } +} diff --git a/ts/packages/ceremony/src/platforms/x/1/types.ts b/ts/packages/ceremony/src/platforms/x/1/types.ts new file mode 100644 index 00000000..dd20bc70 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/x/1/types.ts @@ -0,0 +1,39 @@ +import { isAttestation, type NotaryAttestation } from '../../../notary/decode.js' +import { hasExactKeys, isRecord, text } from '../../../primitives.js' +import { isFormClientId } from '../../authorization.js' +import { type Identity, isIdentity, isUserId, proofBytes } from '../../types.js' + +export interface XProofV1 { + bearerLinkProof: Uint8Array + tokenAttestation: NotaryAttestation + identityAttestation: NotaryAttestation +} + +export function validateProof(v: unknown): XProofV1 { + if ( + !isRecord(v) || + !hasExactKeys(v, ['bearerLinkProof', 'tokenAttestation', 'identityAttestation']) || + !proofBytes(v.bearerLinkProof) || + !isAttestation(v.tokenAttestation) || + !isAttestation(v.identityAttestation) + ) + throw new TypeError('Invalid X proof') + return v as unknown as XProofV1 +} + +export function validateIdentity(value: unknown): Identity<'x'> { + if ( + !isIdentity(value, 'x', [512, 20, 15]) || + !isFormClientId(value.oauthClientId) || + !isUserId(value.userId) || + !isUserName(value.userName) + ) + throw new TypeError('Invalid x identity') + return value +} + +export const isUserName = (value: string): boolean => /^[A-Za-z0-9_]{1,15}$/.test(value) + +/** Client identifier constraints for this platform. */ +export const isClientId = (value: unknown): value is string => + text(value, 512) && isFormClientId(value) diff --git a/ts/packages/ceremony/src/platforms/x/1/url.ts b/ts/packages/ceremony/src/platforms/x/1/url.ts new file mode 100644 index 00000000..13689ccd --- /dev/null +++ b/ts/packages/ceremony/src/platforms/x/1/url.ts @@ -0,0 +1,27 @@ +export const pkce = true + +const AUTHORIZATION_ENDPOINT = 'https://x.com/i/oauth2/authorize' + +const PKCE = /^[A-Za-z0-9_-]{43}$/ + +/** Build X v1's fixed public-client S256 authorization request. */ +export function buildAuthorizationUrl(input: { + clientId: string + redirectUri: string + state: string + codeChallenge: string | null +}): string { + if (input.codeChallenge === null || !PKCE.test(input.codeChallenge)) { + throw new Error('codeChallenge must be exactly 43 base64url characters') + } + const query = new URLSearchParams([ + ['response_type', 'code'], + ['client_id', input.clientId], + ['redirect_uri', input.redirectUri], + ['scope', 'tweet.read users.read'], + ['state', input.state], + ['code_challenge', input.codeChallenge], + ['code_challenge_method', 'S256'], + ]) + return `${AUTHORIZATION_ENDPOINT}?${query}` +} diff --git a/ts/packages/ceremony/src/platforms/x/1/x.assets.ts b/ts/packages/ceremony/src/platforms/x/1/x.assets.ts new file mode 100644 index 00000000..3a4f77f7 --- /dev/null +++ b/ts/packages/ceremony/src/platforms/x/1/x.assets.ts @@ -0,0 +1,10 @@ +import { proofAssets } from '../../../barretenberg/barretenberg.assets.js' +import { + bearerCircuit as circuit, + bearerVerificationKey as verificationKey, +} from '../../../barretenberg/circuits/bearer_link/bearer_link.assets.js' +import { notaryAssets } from '../../../notary/notary.assets.js' + +export { circuit, verificationKey } + +export const assets = [...proofAssets, ...notaryAssets, circuit, verificationKey] as const diff --git a/ts/packages/ceremony/src/primitives.test.ts b/ts/packages/ceremony/src/primitives.test.ts new file mode 100644 index 00000000..23113fd6 --- /dev/null +++ b/ts/packages/ceremony/src/primitives.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { b64urlDecode, b64urlEncode, bytesEqual, hasExactKeys, isRecord } from './primitives.js' + +const utf8 = (s: string) => new TextEncoder().encode(s) + +describe('b64url codec', () => { + // RFC 4648 §10 vectors, unpadded. + const vectors: Array<[string, string]> = [ + ['', ''], + ['f', 'Zg'], + ['fo', 'Zm8'], + ['foo', 'Zm9v'], + ['foob', 'Zm9vYg'], + ['fooba', 'Zm9vYmE'], + ['foobar', 'Zm9vYmFy'], + ] + + it('encodes the RFC 4648 vectors unpadded', () => { + for (const [plain, encoded] of vectors) { + expect(b64urlEncode(utf8(plain))).toBe(encoded) + } + }) + + it('decodes the RFC 4648 vectors', () => { + for (const [plain, encoded] of vectors) { + expect(b64urlDecode(encoded)).toEqual(utf8(plain)) + } + }) + + it('uses the url-safe alphabet', () => { + // 0xfb 0xef 0xbe encodes to '++++'/'////' territory in plain base64. + expect(b64urlEncode(new Uint8Array([0xfb, 0xef, 0xbe]))).toBe('----') + expect(b64urlEncode(new Uint8Array([0xff, 0xff, 0xff]))).toBe('____') + expect(b64urlDecode('____')).toEqual(new Uint8Array([0xff, 0xff, 0xff])) + }) + + it('round-trips arbitrary bytes', () => { + for (const len of [1, 2, 3, 31, 32, 33, 255]) { + const bytes = new Uint8Array(len).map((_, i) => (i * 37 + len) & 0xff) + expect(b64urlDecode(b64urlEncode(bytes))).toEqual(bytes) + } + }) + + it('rejects padding, invalid characters, impossible lengths, and nonzero trailing bits', () => { + expect(b64urlDecode('Zg==')).toBeNull() + expect(b64urlDecode('Zm9v Yg')).toBeNull() + expect(b64urlDecode('Zm9+')).toBeNull() // plain-base64 alphabet + expect(b64urlDecode('Zm9/')).toBeNull() + expect(b64urlDecode('Zm9vY')).toBeNull() // length % 4 === 1 + expect(b64urlDecode('Zh')).toBeNull() // trailing bits nonzero + expect(b64urlDecode('Zm9vYh')).toBeNull() + expect(b64urlDecode('é')).toBeNull() + }) +}) + +describe('bytesEqual', () => { + it('compares content, not identity', () => { + expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2]))).toBe(true) + expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 3]))).toBe(false) + expect(bytesEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false) + expect(bytesEqual(new Uint8Array(0), new Uint8Array(0))).toBe(true) + }) +}) + +describe('exact-record helpers', () => { + it('isRecord admits only plain objects', () => { + expect(isRecord({})).toBe(true) + expect(isRecord([])).toBe(false) + expect(isRecord(null)).toBe(false) + expect(isRecord('x')).toBe(false) + }) + + it('hasExactKeys rejects missing and unknown keys', () => { + expect(hasExactKeys({ a: 1, b: 2 }, ['a', 'b'])).toBe(true) + expect(hasExactKeys({ a: 1 }, ['a', 'b'])).toBe(false) + expect(hasExactKeys({ a: 1, b: 2, c: 3 }, ['a', 'b'])).toBe(false) + expect(hasExactKeys({}, [])).toBe(true) + }) +}) diff --git a/ts/packages/ceremony/src/primitives.ts b/ts/packages/ceremony/src/primitives.ts new file mode 100644 index 00000000..8b9a8938 --- /dev/null +++ b/ts/packages/ceremony/src/primitives.ts @@ -0,0 +1,111 @@ +// Shared byte and validation primitives. Admission rule: a helper lives here only +// when its consumers span two or more entrypoint bundles (client, popup, +// prover); anything narrower lives beside its one consumer. + +const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + +const B64URL_REV = new Int8Array(128).fill(-1) + +for (let i = 0; i < B64URL.length; i++) B64URL_REV[B64URL.charCodeAt(i)] = i + +/** Encode bytes as canonical unpadded base64url. */ +export function b64urlEncode(bytes: Uint8Array): string { + let out = '' + let acc = 0 + let bits = 0 + for (const byte of bytes) { + acc = (acc << 8) | byte + bits += 8 + while (bits >= 6) { + bits -= 6 + out += B64URL[(acc >> bits) & 0x3f] + } + } + if (bits > 0) out += B64URL[(acc << (6 - bits)) & 0x3f] + return out +} + +/** + * Decode unpadded base64url strictly: padding, invalid characters, an + * impossible length, and noncanonical (nonzero) trailing bits are all + * rejected. Returns null instead of throwing — every caller is a validator. + */ +export function b64urlDecode(s: string): Uint8Array | null { + if (s.length % 4 === 1) return null + const out = new Uint8Array(Math.floor((s.length * 3) / 4)) + let acc = 0 + let bits = 0 + let o = 0 + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i) + const v = c < 128 ? B64URL_REV[c] : -1 + if (v < 0) return null + acc = ((acc << 6) | v) & 0x3fff + bits += 6 + if (bits >= 8) { + bits -= 8 + out[o++] = (acc >> bits) & 0xff + } + } + if (bits > 0 && (acc & ((1 << bits) - 1)) !== 0) return null + return out +} + +/** Byte equality. Not constant-time; never used to compare secrets. */ +export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false + return true +} + +export function isRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +/** + * Exact-shape gate: the record owns exactly the listed keys — unknown + * fields fail before use. Field types are the caller's next check. + */ +export function hasExactKeys(rec: Record, keys: readonly string[]): boolean { + if (Object.keys(rec).length !== keys.length) return false + for (const k of keys) if (!Object.hasOwn(rec, k)) return false + return true +} + +export const fixedBytes = (v: unknown, n: number): v is Uint8Array => + v instanceof Uint8Array && v.length === n + +export function uint(value: unknown, max: number): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= max +} + +export function text(value: unknown, max: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + !/\p{Cc}/u.test(value) && + new TextEncoder().encode(value).length <= max + ) +} + +export function webUrl(value: unknown): value is string { + if (typeof value !== 'string') return false + try { + const u = new URL(value) + return ( + (u.protocol === 'https:' || + (u.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(u.hostname))) && + !u.username && + !u.password && + u.href === value + ) + } catch { + return false + } +} + +export function origin(value: unknown): value is string { + return typeof value === 'string' && webUrl(`${value}/`) && new URL(value).origin === value +} diff --git a/ts/packages/ceremony/src/response.ts b/ts/packages/ceremony/src/response.ts new file mode 100644 index 00000000..680361e1 --- /dev/null +++ b/ts/packages/ceremony/src/response.ts @@ -0,0 +1,28 @@ +/** Bound bytes while reading, not after allocating an attacker-sized response. */ +export async function readBody(response: Response, maximum: number): Promise { + const reader = response.body?.getReader() + if (!reader) throw new Error('Missing response body') + const chunks: Uint8Array[] = [] + let length = 0 + try { + for (;;) { + const { value, done } = await reader.read() + if (done) break + length += value.length + if (length > maximum) throw new Error('Response exceeds limit') + chunks.push(value) + } + } catch (error) { + await reader.cancel().catch(() => {}) + throw error + } finally { + reader.releaseLock() + } + const bytes = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.length + } + return bytes +} diff --git a/ts/packages/ceremony/src/vite-env.d.ts b/ts/packages/ceremony/src/vite-env.d.ts new file mode 100644 index 00000000..6f6ef6c5 --- /dev/null +++ b/ts/packages/ceremony/src/vite-env.d.ts @@ -0,0 +1,14 @@ +declare module 'virtual:ceremony-assets' { + export const requestsByProfile: Record< + string, + readonly import('./assets/index.js').AssetRequest[] + > + export const allowedRequests: readonly import('./assets/index.js').AssetRequest[] + export const urls: Record + export const profiles: Record + export const local: readonly string[] +} + +declare module 'virtual:ceremony-popup-fallback' { + export const fallback: import('@libid/popup').CarrierConstructor | undefined +} diff --git a/ts/packages/ceremony/tsconfig.build.json b/ts/packages/ceremony/tsconfig.build.json new file mode 100644 index 00000000..0a11719f --- /dev/null +++ b/ts/packages/ceremony/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts"] +} diff --git a/ts/packages/ceremony/tsconfig.e2e.json b/ts/packages/ceremony/tsconfig.e2e.json new file mode 100644 index 00000000..319489c9 --- /dev/null +++ b/ts/packages/ceremony/tsconfig.e2e.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true, + "rootDir": ".", + "resolveJsonModule": true + }, + "include": ["src", "e2e", "playwright.config.ts"] +} diff --git a/ts/packages/ceremony/tsconfig.json b/ts/packages/ceremony/tsconfig.json new file mode 100644 index 00000000..419813c5 --- /dev/null +++ b/ts/packages/ceremony/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/ts/packages/ceremony/tsconfig.scripts.json b/ts/packages/ceremony/tsconfig.scripts.json new file mode 100644 index 00000000..305fa92a --- /dev/null +++ b/ts/packages/ceremony/tsconfig.scripts.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "types": ["node"] + }, + "include": ["build", "src/vite-env.d.ts"] +} diff --git a/ts/packages/ceremony/vitest.config.ts b/ts/packages/ceremony/vitest.config.ts new file mode 100644 index 00000000..405a450c --- /dev/null +++ b/ts/packages/ceremony/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config' +export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'] } }) diff --git a/ts/packages/ledger/README.md b/ts/packages/ledger/README.md new file mode 100644 index 00000000..335ec507 --- /dev/null +++ b/ts/packages/ledger/README.md @@ -0,0 +1,30 @@ +# @libid/ledger + +Shared `LedgerId` contract for application code: `hash()` and `notaryAddress()`. +See the [identity contract](docs/identity.md), extracted from architecture PR #13 +at `0259e72c184e2be7b78a0ad92188e8722d8d6daf`. + +**No real ledger definitions are implemented yet.** Adding one requires its +canonical Chain Profile hash vectors and notary-address checks. Ceremony stays +chain agnostic; Prover has no ledger dependency. + +## Shared test fixture + +```ts +import { testnet } from '@libid/ledger/testing' + +const localLedger = { + hash: () => testnet.hash(), + notaryAddress: () => 'https://localhost:4687', +} +``` + +The testing entrypoint exports `mainnet` and `testnet`, synthetic identities with +dummy 32-byte hashes. They establish no real ledger conformance. A local fixture +can change the notary address without changing its hash. Fixtures belong to the +application or tests; CCDP uses the same distribution for every ledger. + +## Checks + +From the TypeScript workspace: `pnpm --filter @libid/ledger build`, +`pnpm --filter @libid/ledger typecheck`, and `pnpm --filter @libid/ledger test`. diff --git a/ts/packages/ledger/docs/identity.md b/ts/packages/ledger/docs/identity.md new file mode 100644 index 00000000..b91cd056 --- /dev/null +++ b/ts/packages/ledger/docs/identity.md @@ -0,0 +1,52 @@ +# `@libid/ledger` + +Ledger identity and notary routing for application code. The package owns the +Chain Profile hash and each ledger's notary address. It contains no RPC client, +transaction handling, or ceremony dependency. + +## API + +```ts +export interface LedgerId { + hash(): Uint8Array // exact 32-byte Chain Profile identifier + notaryAddress(): string // canonical HTTPS origin; HTTP on localhost/127.0.0.1 for development +} +``` + +`hash()` uses the ledger's canonical Chain Profile encoding and matches the +identifier used by that ledger's verifier. Returned bytes cannot mutate the +ledger value. The notary address is not part of this hash. + +`notaryAddress()` returns a canonical HTTPS origin with no credentials, path, +query, or fragment. Ledger definitions use `https://notary.lib.id` for mainnets +and `https://testnet.notary.lib.id` for testnets. Definitions can share these +constants or choose another address without adding a profile abstraction or +changing the ledger identity. The address selects a network destination, not +the signing keys trusted by a ledger verifier. + +Tests and local development can supply a fixture that preserves the target +ledger hash while selecting a local notary; no environment override is needed: + +```ts +const localLedger: LedgerId = { + hash: () => targetLedger.hash(), + notaryAddress: () => 'https://localhost:8443', +} +``` + +Concrete ledger definitions and their hash/address checks belong beside +their implementation. Sharing an implementation within a ledger family or +using a class per ledger is an internal choice; neither a public registration +API nor a class hierarchy is required. + +## Checks + +For every supported ledger, test its notary address and exact 32-byte Chain +Profile hash against the ledger's vectors. Distinct supported networks must +retain distinct identities. A fixture changing only the notary address must +retain the target ledger hash. Mutating returned hash bytes must not change +later results. + +For local development only, `notaryAddress()` may return a canonical HTTP origin +on exactly `localhost` or `127.0.0.1`. Ceremony derives WS on the same authority; +public notaries continue to require HTTPS/WSS. No other HTTP hosts are accepted. diff --git a/ts/packages/ledger/package.json b/ts/packages/ledger/package.json new file mode 100644 index 00000000..641a380a --- /dev/null +++ b/ts/packages/ledger/package.json @@ -0,0 +1,39 @@ +{ + "name": "@libid/ledger", + "version": "0.0.0", + "private": true, + "description": "Shared ledger identity and notary routing contract.", + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "git+https://github.com/libid-org/libid.git", + "directory": "ts/packages/ledger" + }, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" + } + }, + "files": [ + "dist", + "src", + "docs", + "!src/**/*.test.ts" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "typescript": "^5.9.0", + "vitest": "^3.2.0" + } +} diff --git a/ts/packages/ledger/src/index.test.ts b/ts/packages/ledger/src/index.test.ts new file mode 100644 index 00000000..af726f95 --- /dev/null +++ b/ts/packages/ledger/src/index.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { mainnet, testnet } from './testing.js' +it('synthetic ledgers return independent hashes and support local notary routing', () => { + for (const ledger of [mainnet, testnet]) { + const expected = ledger.hash() + expect(expected).toHaveLength(32) + ledger.hash().fill(9) + expect(ledger.hash()).toEqual(expected) + const local = { ...ledger, notaryAddress: () => 'https://localhost:4687' } + expect(local.hash()).toEqual(expected) + expect(local.notaryAddress()).toBe('https://localhost:4687') + } + expect(mainnet.hash()).not.toEqual(testnet.hash()) +}) diff --git a/ts/packages/ledger/src/index.ts b/ts/packages/ledger/src/index.ts new file mode 100644 index 00000000..12d04694 --- /dev/null +++ b/ts/packages/ledger/src/index.ts @@ -0,0 +1,5 @@ +/** Ledger definitions own their Chain Profile hash and notary routing. */ +export interface LedgerId { + hash(): Uint8Array + notaryAddress(): string +} diff --git a/ts/packages/ledger/src/testing.ts b/ts/packages/ledger/src/testing.ts new file mode 100644 index 00000000..dc718c10 --- /dev/null +++ b/ts/packages/ledger/src/testing.ts @@ -0,0 +1,11 @@ +/** Synthetic identities for tests; neither represents a production ledger. */ +import type { LedgerId } from './index.js' + +export const mainnet: LedgerId = Object.freeze({ + hash: () => new Uint8Array(32).fill(1), + notaryAddress: () => 'https://notary.lib.id', +}) +export const testnet: LedgerId = Object.freeze({ + hash: () => new Uint8Array(32).fill(2), + notaryAddress: () => 'https://testnet.notary.lib.id', +}) diff --git a/ts/packages/ledger/tsconfig.build.json b/ts/packages/ledger/tsconfig.build.json new file mode 100644 index 00000000..0a11719f --- /dev/null +++ b/ts/packages/ledger/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts"] +} diff --git a/ts/packages/ledger/tsconfig.json b/ts/packages/ledger/tsconfig.json new file mode 100644 index 00000000..69d9e0e5 --- /dev/null +++ b/ts/packages/ledger/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "stripInternal": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/ts/packages/ledger/vitest.config.ts b/ts/packages/ledger/vitest.config.ts new file mode 100644 index 00000000..405a450c --- /dev/null +++ b/ts/packages/ledger/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config' +export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'] } }) diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts index 803deaa2..fbda1a1d 100644 --- a/ts/packages/popup/e2e/popup.spec.ts +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -102,17 +102,9 @@ const navigate = (page: Page, url: string) => ) /** Run an action that replaces the popup document and wait for the new one. */ -async function nextDocument( - popup: Page, - action: () => Promise = async () => {}, -): Promise { - const before = await popup.evaluate(() => performance.timeOrigin) - await action() - await expect - .poll(() => popup.evaluate(() => performance.timeOrigin).catch(() => before), { - timeout: 15_000, - }) - .not.toBe(before) +async function nextDocument(popup: Page, action: () => Promise): Promise { + await popup.waitForLoadState('domcontentloaded') + await Promise.all([popup.waitForEvent('domcontentloaded', { timeout: 15_000 }), action()]) } async function expectPong(page: Page, n: number): Promise { @@ -341,7 +333,7 @@ test('[POPUP-KEEPER-003] [POPUP-CONNECTION-003] a long non-participating hop exp const next = encodeURIComponent(`${POPUP}/p#c=${id}`) await nextDocument(popup, () => navigate(page, `${POPUP}/external?delay=5500&next=${next}`)) await expect(popup.locator('#status')).toHaveText('external') - await nextDocument(popup) + await nextDocument(popup, () => popup.getByRole('button', { name: 'Return' }).click()) await expect(popup.locator('#status')).toHaveText('connected') // Expired in the worker: the fresh document found nothing and used its opener. expect(await diag(popup)).toEqual(['claim-empty', 'carrier-message-port']) @@ -372,8 +364,8 @@ test('[POPUP-KEEPER-003] a short non-participating hop keeps the port', async ({ await expectPong(page, 0) await popup.evaluate(() => navigator.serviceWorker.ready) const next = encodeURIComponent(`${POPUP}/p#c=${id}`) - await nextDocument(popup, () => navigate(page, `${POPUP}/external?delay=200&next=${next}`)) - await nextDocument(popup) + await nextDocument(popup, () => navigate(page, `${POPUP}/external?next=${next}`)) + await nextDocument(popup, () => popup.getByRole('button', { name: 'Return' }).click()) await expect(popup.locator('#status')).toHaveText('connected') expect(await diag(popup)).toEqual(['carrier-restored']) await ping(page, 6) @@ -454,9 +446,9 @@ test('[POPUP-CONTROL-005] navigateAway leaves for a provider page directly and t await expectPong(page, 0) await popup.evaluate(() => navigator.serviceWorker.ready) const next = encodeURIComponent(`${POPUP}/p#c=${id}`) - await nextDocument(popup, () => navigateAway(page, `${POPUP}/external?delay=200&next=${next}`)) + await nextDocument(popup, () => navigateAway(page, `${POPUP}/external?next=${next}`)) expect((await diag(page)).at(-1)).toBe('control-direct') - await nextDocument(popup) + await nextDocument(popup, () => popup.getByRole('button', { name: 'Return' }).click()) await expect(popup.locator('#status')).toHaveText('connected') // Nothing was kept: the returning document found no port and used its opener. expect(await diag(popup)).toEqual(['claim-empty', 'carrier-message-port']) @@ -478,10 +470,10 @@ test('[POPUP-CONTROL-005] popup-side navigateAway keeps no port', async ({ page url: url.split('#')[0], fragment: url.split('#')[1] ?? '', }), - `${POPUP}/external?delay=200&next=${next}`, + `${POPUP}/external?next=${next}`, ), ) - await nextDocument(popup) + await nextDocument(popup, () => popup.getByRole('button', { name: 'Return' }).click()) await expect(popup.locator('#status')).toHaveText('connected') expect(await diag(popup)).toEqual(['claim-empty', 'carrier-message-port']) }) @@ -527,12 +519,32 @@ test('[POPUP-CONNECTION-010] a reply sent before navigate reaches the popup befo expect(await diag(popup)).toEqual(['carrier-message-port']) }) +/** Isolation tests need the keeper installed before their automatic handoff. */ +async function prepareKeeper(page: Page, nestedScope: string): Promise { + await page.goto(`${POPUP}/health`) + await page.evaluate(async (scope) => { + await Promise.all( + ['/', scope].map((scope) => navigator.serviceWorker.register('/sw.js', { scope })), + ) + }, nestedScope) + await expect + .poll(() => + page.evaluate(async () => + (await navigator.serviceWorker.getRegistrations()).map((r) => r.active?.state), + ), + ) + .toEqual(['activated', 'activated']) +} + test('[POPUP-CONNECTION-011] an isolation-requiring document isolates by DIP or by its COOP fallback, delivering once', async ({ page, }) => { const id = freshId() // Send the instant the handshake completes: the value must reach the // isolated document exactly once whichever path the engine takes. + // Worker startup is a fixture prerequisite, not part of the two-second + // continuity exchange. Keep both scopes to exercise nested registration lookup. + await prepareKeeper(page, '/dip') const { popup } = await open(page, { id, href: `${POPUP}/dip#c=${id}`, pingOnHandshake: 77 }) await expect(popup.locator('#status')).toHaveText('connected') expect(await popup.evaluate(() => crossOriginIsolated)).toBe(true) @@ -541,7 +553,9 @@ test('[POPUP-CONNECTION-011] an isolation-requiring document isolates by DIP or expect((await events(page)).filter((e) => (e as Pong).n === 77)).toHaveLength(1) const popupDiag = await diag(popup) const viaFallback = popup.url().includes('/dip/fallback') - expect(popupDiag).toEqual(viaFallback ? ['carrier-restored'] : ['carrier-message-port']) + expect(popupDiag).toEqual( + viaFallback ? ['carrier-restored'] : ['claim-empty', 'carrier-message-port'], + ) // The application saw exactly one carrier for the whole transition. expect((await diag(page)).filter((c) => c === 'carrier-message-port')).toHaveLength(1) await ping(page, 78) @@ -552,6 +566,7 @@ test('[POPUP-CONNECTION-012] a fallback that stays non-isolated fails closed wit page, }) => { const id = freshId() + await prepareKeeper(page, '/dip-broken') const { popup } = await open(page, { id, href: `${POPUP}/dip-broken#c=${id}` }) await expect(popup.locator('#status')).toHaveText(/connected|failed/) test.skip( diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs index c65f8b09..94da2173 100644 --- a/ts/packages/popup/e2e/server.mjs +++ b/ts/packages/popup/e2e/server.mjs @@ -134,15 +134,18 @@ const popupPage = html(` `) -// Non-participating: like a provider page, it eventually sends the user -// back to a participating document without touching the package. +// Non-participating: the test controls when the user returns, so even a +// slow runner can observe this document before leaving it. const externalPage = html(`

external

+ `) diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index cfb52960..2a05c6c1 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -320,11 +320,11 @@ describe('controls [POPUP-CONTROL-001/002/003/004]', () => { expect(codes(app.events).at(-1)).toBe('control-direct') const scope = fakeScope() - await acceptPopup(pair, { worker: scope.worker }).connection - await tick() + const popup = await acceptPopup(pair, { worker: scope.worker }).connection + await app.connection.ready await app.connection.navigate('https://popup.example/isolated') expect(codes(app.events).at(-1)).toBe('control-connected') - await tick(20) + expect(await popup.closed).toEqual({ outcome: 'closed' }) // The popup kept its port with the worker and replaced itself. expect(scope.pending).toHaveLength(1) expect(pair.popupProxy.replaced).toEqual([ @@ -354,10 +354,10 @@ describe('controls [POPUP-CONTROL-001/002/003/004]', () => { const readies: number[] = [] app.connection.on(Ready, (r) => void readies.push(r.version)) const scope = fakeScope() - await acceptPopup(pair, { worker: scope.worker }).connection - await tick() + const popup = await acceptPopup(pair, { worker: scope.worker }).connection + await app.connection.ready await app.connection.navigate('https://popup.example/isolated') - await tick(20) + expect(await popup.closed).toEqual({ outcome: 'closed' }) // The destination document claims and continues with the same port. const next = await acceptPopup(pair, { worker: scope.worker, opener: false }) const nextEvents = next.events @@ -373,10 +373,10 @@ describe('controls [POPUP-CONTROL-001/002/003/004]', () => { const app = connectApp(pair) const stale = fakeScope() const root = fakeScope() - await acceptPopup(pair, { worker: root.worker }).connection - await tick() + const popup = await acceptPopup(pair, { worker: root.worker }).connection + await app.connection.ready await app.connection.navigate('https://popup.example/isolated') - await tick(20) + expect(await popup.closed).toEqual({ outcome: 'closed' }) expect(root.pending).toHaveLength(1) // The destination is controlled by a stale nested registration holding // nothing; the port is still found in the root worker. @@ -452,13 +452,13 @@ describe('controls [POPUP-CONTROL-001/002/003/004]', () => { const pair2 = fakePair() const app2 = connectApp(pair2) const scope = fakeScope() - await acceptPopup(pair2, { worker: scope.worker }).connection - await tick() + const popup2 = await acceptPopup(pair2, { worker: scope.worker }).connection + await app2.connection.ready const raw = (app2.connection as unknown as { carrier: Carrier }).carrier raw.send({ type: 'navigate', url: 'https://popup.example/a' } as Message) raw.send({ type: 'navigate', url: 'https://popup.example/b' } as Message) raw.send({ type: 'close-popup' }) - await tick(20) + expect(await popup2.closed).toEqual({ outcome: 'closed' }) expect(pair2.popupProxy.replaced).toEqual(['https://popup.example/a']) expect(pair2.popupProxy.closed).toBe(false) error.mockRestore() @@ -486,7 +486,7 @@ describe('ordering across a transition [POPUP-CONNECTION-010]', () => { const popup = await side.connection await tick() popup.send(new Ready(1)) - await tick(20) + expect(await popup.closed).toEqual({ outcome: 'closed' }) expect(order).toEqual(['start']) expect(pair.popupProxy.replaced).toEqual(['https://popup-b.example/p']) expect(codes(events).at(-1)).toBe('control-connected') @@ -517,7 +517,7 @@ describe('cross-origin replacement [POPUP-CONNECTION-008/009]', () => { await tick() await app.connection.navigate(`${OTHER_POPUP}/p`) - await tick(20) + expect(await popup.closed).toEqual({ outcome: 'closed' }) expect(scope.pending).toHaveLength(0) // no keep expect(pair.popupProxy.replaced).toEqual([`${OTHER_POPUP}/p`]) expect(codes(first.events).at(-1)).toBe('connection-closed') @@ -726,10 +726,10 @@ describe('isolation fallback [POPUP-CONNECTION-011/012]', () => { const side = acceptIsolating(pair, { worker: scope.worker }) const starts = vi.fn() side.endpoint.on(Start, starts) - await tick(20) + await app.connection.ready // The application already sent into the handshake port; it must travel. app.connection.send(new Start()) - await tick(20) + expect(await side.endpoint.closed).toEqual({ outcome: 'closed' }) expect(starts).not.toHaveBeenCalled() expect(scope.pending).toHaveLength(1) expect(pair.popupProxy.replaced).toEqual([`${POPUP_ORIGIN}${FALLBACK}#c=1`]) @@ -747,7 +747,6 @@ describe('isolation fallback [POPUP-CONNECTION-011/012]', () => { ) await tick() expect(settled).toBe(false) - expect(await side.endpoint.closed).toEqual({ outcome: 'closed' }) // The isolated fallback document restores the port and receives the value once. pair.relocate(POPUP_ORIGIN, FALLBACK, '#c=1') @@ -841,13 +840,13 @@ describe('isolation fallback [POPUP-CONNECTION-011/012]', () => { captured, ) const events: PopupDiagnostic[] = [] - PopupConnection.accept(popup, { + const endpoint = PopupConnection.accept(popup, { connectionId: ID, allowedApplicationOrigins: [APP_ORIGIN], isolationFallbackUrl: '/f', onDiagnostic: (e) => void events.push(e), }) - await tick(20) + expect(await endpoint.closed).toEqual({ outcome: 'closed' }) expect(pair.popupProxy.replaced).toEqual([`${POPUP_ORIGIN}/f#c=1&x=y%20z`]) expect(codes(events)).toContain('keep-acknowledged') for (const bad of ['/f#own', '/f#']) { @@ -882,8 +881,15 @@ describe('isolation fallback [POPUP-CONNECTION-011/012]', () => { const pair2 = fakePair() pair2.relocate(POPUP_ORIGIN, '/prover', '#c=1') connectApp(pair2) - const closing = acceptIsolating(pair2, { worker: worker(null) }) - await tick(20) // handshake done; the keep is now waiting on the silent worker + const silent = worker(null) + const sent = vi.spyOn(silent, 'postMessage') + const closing = acceptIsolating(pair2, { worker: silent }) + await vi.waitFor(() => + expect(sent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'libid-popup-keep' }), + expect.any(Array), + ), + ) expect(codes(closing.events)).toContain('isolation-fallback') await closing.endpoint.close() expect(await closing.endpoint.closed).toEqual({ outcome: 'closed' }) @@ -976,7 +982,7 @@ describe('structured fragments [POPUP-CONNECTION-013]', () => { const snapshot = params.toString() await app.connection.navigate('https://popup-b.example/p', params) params.set('next', 'mutated after the call') - await tick(20) + expect(await side.endpoint.closed).toEqual({ outcome: 'closed' }) expect(pair.popupProxy.replaced.at(-1)).toBe(`https://popup-b.example/p#${snapshot}`) // An empty fragment adds nothing. const bare = connectApp(fakePair()) @@ -1011,7 +1017,6 @@ describe('structured fragments [POPUP-CONNECTION-013]', () => { await tick() const before = codes(app.events).length await popup.navigate(`${POPUP_ORIGIN}/next`, new URLSearchParams({ secret: 'value' })) - await tick(20) expect(pair.popupProxy.replaced).toEqual([`${POPUP_ORIGIN}/next#secret=value`]) // The application saw no control, no diagnostic, and no message. expect(codes(app.events)).toHaveLength(before) @@ -1089,11 +1094,10 @@ describe('isolation fallback over a non-transferable carrier [POPUP-CONNECTION-0 first.endpoint.on(Start, replies) app.send(new Start()) await app.navigate(`${targetOrigin}/prover`, new URLSearchParams('c=1')) - await tick(20) + expect(await first.endpoint.closed).toEqual({ outcome: 'closed' }) expect(replies).toHaveBeenCalledTimes(1) expect(pair.popupProxy.replaced).toEqual([`${targetOrigin}/prover#c=1`]) expect(codes(events).filter((c) => c === 'carrier-fallback')).toHaveLength(1) - expect(await first.endpoint.closed).toEqual({ outcome: 'closed' }) const rounds = hub.carriers.length // The non-isolated destination hops without spending a connection: no @@ -1102,7 +1106,7 @@ describe('isolation fallback over a non-transferable carrier [POPUP-CONNECTION-0 const second = acceptWith(pair, hub, '/prover/fallback') const leaked = vi.fn() second.endpoint.on(Start, leaked) - await tick(20) + expect(await second.endpoint.closed).toEqual({ outcome: 'closed' }) expect(pair.popupProxy.replaced.at(-1)).toBe(`${targetOrigin}/prover/fallback#c=1`) expect(codes(second.events)).toEqual(['isolation-fallback', 'connection-closed']) expect(hub.carriers).toHaveLength(rounds) @@ -1144,7 +1148,7 @@ describe('isolation fallback over a non-transferable carrier [POPUP-CONNECTION-0 const { pair } = severedPair(hub) const side = acceptWith(pair, hub, '/prover/fallback') await side.endpoint.close() - await tick(20) + await tick() expect(pair.popupProxy.replaced).toEqual([]) expect(codes(side.events)).not.toContain('isolation-fallback') expect(await side.endpoint.closed).toEqual({ outcome: 'closed' }) @@ -1154,10 +1158,9 @@ describe('isolation fallback over a non-transferable carrier [POPUP-CONNECTION-0 const hub = fakeSignaling() const { pair, app, events } = severedPair(hub) const first = acceptWith(pair, hub, '/prover/fallback') - await tick(20) // left for the fallback without a carrier + expect(await first.endpoint.closed).toEqual({ outcome: 'closed' }) expect(pair.popupProxy.replaced).toHaveLength(1) expect(hub.carriers).toHaveLength(0) - void first pair.relocate(POPUP_ORIGIN, '/prover/fallback', '') pair.setIsolated(true) hub.failNext = true diff --git a/ts/packages/popup/src/testing/fakes.ts b/ts/packages/popup/src/testing/fakes.ts index b7606de9..3442f136 100644 --- a/ts/packages/popup/src/testing/fakes.ts +++ b/ts/packages/popup/src/testing/fakes.ts @@ -21,9 +21,8 @@ export const ID = '1c037b6a-2f08-4b17-9f9e-0d9a6a5b3c2d' export const OTHER_ID = '2d148c7b-3f19-4c28-8a0f-1e0b7b6c4d3e' /** - * Lets pending deliveries land. Fake window dispatch is synchronous; real - * MessagePort values arrive in the event loop's poll phase, which a timer - * firing after a stall can precede, so two further loop turns follow it. + * Yields to pending MessagePort deliveries. Multi-step handoffs must await + * their ready/closed signal; a fixed number of event-loop turns is not enough. */ export const tick = async (ms = 5): Promise => { for (const delay of [ms, 0, 0]) await new Promise((resolve) => setTimeout(resolve, delay)) diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index baa8df7a..01dc1de7 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -49,6 +49,83 @@ importers: specifier: ^7.1.0 version: 7.3.6(@types/node@22.20.1) + apps/dev: + dependencies: + '@libid/ceremony': + specifier: workspace:^ + version: link:../../packages/ceremony + '@libid/ledger': + specifier: workspace:^ + version: link:../../packages/ledger + '@libid/popup': + specifier: workspace:^ + version: link:../../packages/popup + '@noble/hashes': + specifier: ^2.3.0 + version: 2.4.0 + devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vite: + specifier: ^7.3.6 + version: 7.3.6(@types/node@22.20.1) + + packages/ceremony: + dependencies: + '@aztec/bb.js': + specifier: 5.2.0 + version: 5.2.0 + '@libid/ledger': + specifier: workspace:^ + version: link:../ledger + '@libid/popup': + specifier: workspace:^ + version: link:../popup + '@noble/hashes': + specifier: ^2.3.0 + version: 2.4.0 + '@noir-lang/acvm_js': + specifier: 1.0.0-beta.25 + version: 1.0.0-beta.25 + '@noir-lang/noir_js': + specifier: 1.0.0-beta.25 + version: 1.0.0-beta.25 + '@noir-lang/noirc_abi': + specifier: 1.0.0-beta.25 + version: 1.0.0-beta.25 + devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/estree': + specifier: ^1.0.8 + version: 1.0.9 + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + smol-toml: + specifier: ^1.8.0 + version: 1.8.0 + tar: + specifier: ^7.5.22 + version: 7.5.22 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vite: + specifier: ^7.3.6 + version: 7.3.6(@types/node@22.20.1) + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@22.20.1) + packages/claim: dependencies: '@aztec/bb.js': @@ -99,6 +176,15 @@ importers: specifier: ^3.2.0 version: 3.2.7(@types/node@22.20.1) + packages/ledger: + devDependencies: + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@22.20.1) + packages/popup: devDependencies: '@playwright/test': @@ -126,6 +212,10 @@ packages: resolution: {integrity: sha512-d7QJwqDW8DBBJZ1PF8zfHJEz/jL1MrPbxQ3qMjfNeCBrYjYYqseTHnjD4+omuHbKgNtwhbqWBVedHZfLwTmvlQ==} hasBin: true + '@aztec/bb.js@5.2.0': + resolution: {integrity: sha512-XQRbl6TfHg7xXwf4b3cNSggtwsIxl2z2/zV49/Ahxa1Nc1D7x05NY0QL5mYSRdOJlGKZ3TUTH48lDWAwJSVulg==} + hasBin: true + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -422,6 +512,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -497,18 +591,34 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@noir-lang/acvm_js@1.0.0-beta.20': resolution: {integrity: sha512-L+DXYcvG7cGgsr1+yR5OYP2fCncm5HUrSi3vqMzkpaxy/tlUuyzNnEECVZ6Q0Zg30QA/aWO7Uwc94dEG87ZHsQ==} + '@noir-lang/acvm_js@1.0.0-beta.25': + resolution: {integrity: sha512-FDLST/Sya92n0dHVFM5B/LiscvKnhYiXfn1RmYQFPdxtA+GLUrGEW1RYF6Pot49Mi8u1Q4TDydylVgwSfBvDPQ==} + '@noir-lang/noir_js@1.0.0-beta.20': resolution: {integrity: sha512-9al+SHliPEre2y4vT9IfZ7rYuvDZxduZLqPL+vQb7Yz18p5HRk6gq0Hm2GFo48ooOxkX+YFjKKDlGTqtPMWR2w==} + '@noir-lang/noir_js@1.0.0-beta.25': + resolution: {integrity: sha512-Lr+CmJzOGFNR3txRJ7s3hT2RTGQEpYagRaj9XfRJDaHA+Z2FJbSSYWZ+BXwEjtXxVcXsA1fSpCa3yqNOhvzVIw==} + '@noir-lang/noirc_abi@1.0.0-beta.20': resolution: {integrity: sha512-livQmsyq+ebO74rwO7iqvOIoPbv17neAkue+L86J18oBsuqyzCRfS8Vw8jN3cCe0MAXHacyz/+3rjh5BqTSY0Q==} + '@noir-lang/noirc_abi@1.0.0-beta.25': + resolution: {integrity: sha512-bB11NuEeq7Qz26fkBiY0upzdByr8KJG2lvXX9Kl9vgZnuO4HKfajHyEAInNupnxkgH1DVwVyD/1RDXXwsjTOmA==} + '@noir-lang/types@1.0.0-beta.20': resolution: {integrity: sha512-uqje0gPxubHmcQ+NIoD2NXpah2DVaIAY9Mxt8j4S2cc2e88NnbuohrC8K9vPxlvwkghUOq9mBqU+m83Q871mVg==} + '@noir-lang/types@1.0.0-beta.25': + resolution: {integrity: sha512-rgCgzyPLw2WHQGlP9EBqIT0tLQu0SIJRvc7RafV3aMQJ18oqmpq3X5p5l9BGhzOscrrfzcxKMjCvmag+Wk0ElQ==} + '@playwright/test@1.62.1': resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} @@ -771,6 +881,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + comlink@4.4.2: resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==} @@ -882,6 +996,14 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -972,6 +1094,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -985,6 +1111,10 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1125,6 +1255,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + snapshots: '@adraffy/ens-normalize@1.11.1': {} @@ -1138,6 +1272,15 @@ snapshots: pako: 2.2.0 tslib: 2.8.1 + '@aztec/bb.js@5.2.0': + dependencies: + comlink: 4.4.2 + commander: 12.1.0 + idb-keyval: 6.3.0 + msgpackr: 1.12.1 + pako: 2.2.0 + tslib: 2.8.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1363,6 +1506,10 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1419,8 +1566,12 @@ snapshots: '@noble/hashes@1.8.0': {} + '@noble/hashes@2.4.0': {} + '@noir-lang/acvm_js@1.0.0-beta.20': {} + '@noir-lang/acvm_js@1.0.0-beta.25': {} + '@noir-lang/noir_js@1.0.0-beta.20': dependencies: '@noir-lang/acvm_js': 1.0.0-beta.20 @@ -1428,12 +1579,25 @@ snapshots: '@noir-lang/types': 1.0.0-beta.20 pako: 2.2.0 + '@noir-lang/noir_js@1.0.0-beta.25': + dependencies: + '@noir-lang/acvm_js': 1.0.0-beta.25 + '@noir-lang/noirc_abi': 1.0.0-beta.25 + '@noir-lang/types': 1.0.0-beta.25 + pako: 2.2.0 + '@noir-lang/noirc_abi@1.0.0-beta.20': dependencies: '@noir-lang/types': 1.0.0-beta.20 + '@noir-lang/noirc_abi@1.0.0-beta.25': + dependencies: + '@noir-lang/types': 1.0.0-beta.25 + '@noir-lang/types@1.0.0-beta.20': {} + '@noir-lang/types@1.0.0-beta.25': {} + '@playwright/test@1.62.1': dependencies: playwright: 1.62.1 @@ -1654,6 +1818,8 @@ snapshots: check-error@2.1.3: {} + chownr@3.0.0: {} + comlink@4.4.2: {} commander@12.1.0: {} @@ -1750,6 +1916,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + ms@2.1.3: {} msgpackr-extract@3.0.4: @@ -1863,6 +2035,8 @@ snapshots: siginfo@2.0.0: {} + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -1873,6 +2047,14 @@ snapshots: dependencies: js-tokens: 9.0.1 + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1999,3 +2181,5 @@ snapshots: ws@8.21.0: {} yallist@3.1.1: {} + + yallist@5.0.0: {}