From 5d288f24143e43a74c5417cd559220b25fa51808 Mon Sep 17 00:00:00 2001 From: Chijioke Ugwuanyi Date: Fri, 25 Sep 2026 18:50:27 +0200 Subject: [PATCH 1/4] feat: build signed gami-verify downloads and trigger Netlify releases --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 8 +- .github/workflows/standalone.yml | 147 ++++++++++++ README.md | 71 ++++-- bin/run.ts | 4 +- docs/PORTABLE-RELEASES.md | 161 ++++++++++++++ docs/RELEASING.md | 4 +- docs/STANDALONE_VERIFIER.md | 44 ++-- package.json | 10 +- pnpm-lock.yaml | 322 ++++++++++++++++++++++++++- scripts/build-standalone.mjs | 54 +++++ scripts/create-download-manifest.mjs | 21 ++ scripts/create-release-artifacts.mjs | 2 +- scripts/package-standalone.mjs | 114 ++++++++++ scripts/sign-linux.sh | 25 +++ scripts/test-standalone.mjs | 67 ++++++ scripts/verify-package.mjs | 3 +- src/adapters/did-webvh.test.ts | 12 + src/adapters/did-webvh.ts | 114 ++++++++++ src/commands/verify.ts | 46 ++-- src/domain/canonical.ts | 6 +- src/domain/did-document.test.ts | 31 +++ src/domain/did-document.ts | 22 +- src/domain/did.test.ts | 17 +- src/domain/did.ts | 38 +++- src/domain/gpr.ts | 4 +- src/domain/result.ts | 3 +- src/domain/verify.ts | 22 +- 28 files changed, 1283 insertions(+), 93 deletions(-) create mode 100644 .github/workflows/standalone.yml create mode 100644 docs/PORTABLE-RELEASES.md create mode 100644 scripts/build-standalone.mjs create mode 100644 scripts/create-download-manifest.mjs create mode 100644 scripts/package-standalone.mjs create mode 100644 scripts/sign-linux.sh create mode 100644 scripts/test-standalone.mjs create mode 100644 src/adapters/did-webvh.test.ts create mode 100644 src/adapters/did-webvh.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfaeccc..fc06c67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,5 +54,5 @@ jobs: - run: pnpm package:check - run: mkdir package-test && npm pack --pack-destination package-test - run: npm install --global ./package-test/*.tgz - - run: gami version --json - - run: gami --help + - run: gami-verify version --json + - run: gami-verify --help diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6001329..0406633 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,8 +50,12 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish ./release/*.tgz --access public --provenance - - name: Create GitHub release + - name: Attach npm artifacts to release if: github.event_name == 'push' env: GH_TOKEN: ${{ github.token }} - run: gh release create "${GITHUB_REF_NAME}" release/* --verify-tag --generate-notes + run: | + if ! gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release create "$GITHUB_REF_NAME" --draft --verify-tag --generate-notes || gh release view "$GITHUB_REF_NAME" >/dev/null + fi + gh release upload "$GITHUB_REF_NAME" release/* diff --git a/.github/workflows/standalone.yml b/.github/workflows/standalone.yml new file mode 100644 index 0000000..cf01618 --- /dev/null +++ b/.github/workflows/standalone.yml @@ -0,0 +1,147 @@ +name: Build standalone verifier + +on: + push: + tags: ["v*", "test-v*"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.platform }} / ${{ matrix.arch }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + platform: windows + arch: x64 + - os: ubuntu-22.04 + platform: linux + arch: x64 + - os: ubuntu-24.04-arm + platform: linux + arch: arm64 + - os: macos-15-intel + platform: macos + arch: x64 + - os: macos-15 + platform: macos + arch: arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + environment: ${{ startsWith(github.ref, 'refs/tags/') && matrix.platform != 'macos' && format('{0}-signing', matrix.platform) || 'standalone-preview' }} + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + with: + version: 10.13.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.23.3 + architecture: ${{ matrix.arch }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm compile + - run: pnpm lint + - run: pnpm test --runInBand + - run: pnpm build:standalone + - run: pnpm test:standalone + - name: Azure login + if: startsWith(github.ref, 'refs/tags/') && matrix.platform == 'windows' + uses: azure/login@935127ca5bb3c4b02c9c2c10060028383878f33f # v3 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Sign Windows executable + if: startsWith(github.ref, 'refs/tags/') && matrix.platform == 'windows' + uses: azure/artifact-signing-action@c0ae2c1d0c1847ab81ac0ab8521bee597cfedd30 # v2 + with: + endpoint: https://neu.codesigning.azure.net + signing-account-name: authenticmemorysigning + certificate-profile-name: gami-public-signing + files: ${{ github.workspace }}\release\standalone\gami-verify.exe + exclude-environment-credential: true + - name: Verify Windows publisher and timestamp + if: startsWith(github.ref, 'refs/tags/') && matrix.platform == 'windows' + shell: pwsh + run: | + $signature = Get-AuthenticodeSignature release/standalone/gami-verify.exe + $organization = "Authentic Memory gemeinn$([char]0x00fc)tzige UG (haftungsbeschr$([char]0x00e4)nkt)" + if ($signature.Status -ne 'Valid' -or -not $signature.TimeStamperCertificate -or $signature.SignerCertificate.Subject -notmatch ('(?:^|,\s*)O=' + [regex]::Escape($organization) + '(?:,|$)')) { + throw 'Expected valid timestamped Authentic Memory signature' + } + - run: pnpm test:standalone + - run: pnpm package:standalone + - name: Sign and independently verify Linux archives + if: startsWith(github.ref, 'refs/tags/') && matrix.platform == 'linux' + shell: bash + env: + LINUX_SIGNING_PRIVATE_KEY: ${{ secrets.LINUX_SIGNING_PRIVATE_KEY }} + LINUX_SIGNING_PASSPHRASE: ${{ secrets.LINUX_SIGNING_PASSPHRASE }} + LINUX_SIGNING_FINGERPRINT: ${{ vars.LINUX_SIGNING_FINGERPRINT }} + run: bash scripts/sign-linux.sh release/downloads release/downloads/*.tar.gz release/downloads/*.cdx.json release/downloads/*.sha256 + - name: Attest packaged artifacts + if: startsWith(github.ref, 'refs/tags/') + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3 + with: + subject-path: release/downloads/* + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: gami-verify-${{ matrix.platform }}-${{ matrix.arch }}${{ (matrix.platform == 'macos' || !startsWith(github.ref, 'refs/tags/')) && '-unsigned-preview' || '' }} + path: release/downloads/* + if-no-files-found: error + retention-days: 30 + + publish-downloads: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22.23.3 + - uses: actions/download-artifact@v4 + with: + pattern: gami-verify-* + path: release/artifacts + - name: Collect signed platforms only + shell: bash + run: | + set -euo pipefail + mkdir -p release/downloads + for platform in windows-x64 linux-x64 linux-arm64; do + cp release/artifacts/gami-verify-$platform/* release/downloads/ + done + node scripts/create-download-manifest.mjs + - name: Publish complete signed downloads + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if ! gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release create "$GITHUB_REF_NAME" --draft --verify-tag --generate-notes || gh release view "$GITHUB_REF_NAME" >/dev/null + fi + test "$(gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft)" = true || { echo 'Refusing to replace a published release'; exit 1; } + gh release upload "$GITHUB_REF_NAME" release/downloads/* + gh release edit "$GITHUB_REF_NAME" --draft=false --latest + - name: Trigger full Netlify site rebuild + env: + NETLIFY_BUILD_HOOK: ${{ secrets.NETLIFY_BUILD_HOOK }} + shell: bash + run: | + set -euo pipefail + test -n "$NETLIFY_BUILD_HOOK" || { echo 'Set NETLIFY_BUILD_HOOK to enable automatic website deployment'; exit 1; } + curl --fail --silent --show-error --retry 3 --request POST "$NETLIFY_BUILD_HOOK" --output /dev/null diff --git a/README.md b/README.md index efbd2ab..0810fba 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,45 @@ -# GAMI CLI +# GAMI Verify -`gami` is the independent command-line verifier for GAMI Proof Records (GPRs). -It is distributed through npm, requires Node.js 20 or newer, and does not need -the GAMI web application, database, or API to inspect local records. +`gami-verify` is the independent command-line verifier for GAMI Proof Records (GPRs). +It supports npm installation (Node.js 20 or newer) and standalone executable +builds that include Node.js. Neither needs the GAMI web application, database, +or API to inspect local records. See [standalone downloads and signing](docs/PORTABLE-RELEASES.md) +for build, release and installation instructions. The verifier validates the structure of a local GPR, streams the document through SHA-256, and independently verifies deployed raw-Ed25519 and WebAuthn/Merkle signatures. It verifies current `did:web` authorization using supplied evidence or -direct HTTPS resolution. It verifies OpenTimestamps anchors using a local Bitcoin -Core node or agreement between Blockstream and mempool.space. It never needs the -GAMI application or registry to validate a supplied GPR. +direct HTTPS resolution. It verifies pinned historical `did:webvh` authorization +from the `?versionId=` in `proof.key_id`; it does not resolve HEAD for those +records. It verifies OpenTimestamps anchors using a local Bitcoin Core node or +agreement between Blockstream and mempool.space. It never needs the GAMI +application or registry to validate a supplied GPR. ## Install and run +For a standalone release, extract the archive and open a terminal in its folder. +Run `.\gami-verify.exe --help` on Windows, or `./gami-verify --help` on Linux/macOS. +No separate Node.js or npm installation is required. Public download availability +depends on completion of the platform signing and acceptance gates. + +The next release renames the executable from `gami` to `gami-verify`. Update any +scripts that invoke the old command. The npm package name stays `@authenticmemory/gami`. + +For npm installation: + ```sh npm install --global @authenticmemory/gami -gami --help -gami version --json -gami inspect ./first.gpr.json ./second.gpr.json -gami inspect ./record.gpr.json --json -gami verify ./document.pdf ./record.gpr.json -gami verify ./document.pdf ./record.gpr.json --did-evidence ./institution.did.json -gami verify ./document.pdf ./record.gpr.json --bitcoin-source core -gami verify ./document.pdf ./record.gpr.json --bitcoin-source public -gami verify ./document.pdf ./record.gpr.json --offline -gami verify ./document.pdf ./record.gpr.json --json +gami-verify --help +gami-verify version --json +gami-verify inspect ./first.gpr.json ./second.gpr.json +gami-verify inspect ./record.gpr.json --json +gami-verify verify ./document.pdf ./record.gpr.json +gami-verify verify ./document.pdf ./record.gpr.json --did-evidence ./institution.did.json +gami-verify verify ./document.pdf ./record.gpr.json --bitcoin-source core +gami-verify verify ./document.pdf ./record.gpr.json --bitcoin-source public +gami-verify verify ./document.pdf ./record.gpr.json --offline +gami-verify verify ./document.pdf ./record.gpr.json --json +gami-verify verify ./placeholder-record.gpr.json ``` During development: @@ -39,7 +54,7 @@ pnpm build ## Commands -### `gami inspect ` +### `gami-verify inspect ` Parses a GPR JSON file and validates the frozen GPR v1 envelope and field encodings. This is structural validation only; a structurally valid record may @@ -50,18 +65,26 @@ Options: - `--json`: emit one versioned JSON object per input as newline-delimited JSON. -### `gami verify ` +### `gami-verify verify ` Streams and hashes the local document, compares it with `subject.file_hash`, reconstructs the deployed v1 signing payload and any batch Merkle path, and verifies the raw Ed25519 or WebAuthn Ed25519 signature. +### `gami-verify verify ` + +Verifies a GPR when no original file is available. The document hash check is +reported as skipped; signature, DID authorization, and timestamp checks still +run. + Options: - `--public-key `: override the embedded 32-byte Ed25519 key; the result reports the key source as `overridden`. - `--did-evidence `: use a caller-supplied current `did:web` document. - Without this option, the CLI resolves the document directly from `proof.key_id`. + Without this option, the CLI resolves `did:web` documents directly from + `proof.key_id`. For `did:webvh`, the CLI fetches `did.jsonl` and resolves the + exact `?versionId=` in `proof.key_id`, never HEAD. - `--bitcoin-source `: `auto` tries local Bitcoin Core and then requires Blockstream and mempool.space to agree; default `auto`. - `--bitcoin-cli `: path to `bitcoin-cli` when it is not on `PATH`. @@ -89,9 +112,11 @@ are documented in [docs/STANDALONE_VERIFIER.md](docs/STANDALONE_VERIFIER.md). ## Security Current `did:web` resolution proves current authorization, not authorization at -the historical signing time. Native `did:webvh` history remains pending. Public -Bitcoin verification reveals the requested block height and the user's IP address -to both providers; local Bitcoin Core is the stronger and more private source. +the historical signing time. `did:webvh` records must carry `?versionId=` in +`proof.key_id`; the verifier resolves that historical DID log version and reports +the signature key as active or archived. Public Bitcoin verification reveals the +requested block height and the user's IP address to both providers; local Bitcoin +Core is the stronger and more private source. Every JSON result includes the package version, Node.js version, and supported Bitcoin sources. Releases are tested from the packed npm tarball and publish diff --git a/bin/run.ts b/bin/run.ts index d894bec..8e471dc 100644 --- a/bin/run.ts +++ b/bin/run.ts @@ -5,14 +5,14 @@ import packageJson from "../package.json"; const run = yargs(hideBin(process.argv)); -run.scriptName("gami").usage("$0 [options]").version(packageJson.version); +run.scriptName("gami-verify").usage("$0 [options]").version(packageJson.version); for (const command of commands) { run.command(command as unknown as CommandModule); } void run - .demandCommand(1, "Choose a command. Run gami --help for usage.") + .demandCommand(1, "Choose a command. Run gami-verify --help for usage.") .strict() .recommendCommands() .help() diff --git a/docs/PORTABLE-RELEASES.md b/docs/PORTABLE-RELEASES.md new file mode 100644 index 0000000..8128ba9 --- /dev/null +++ b/docs/PORTABLE-RELEASES.md @@ -0,0 +1,161 @@ +# Portable verifier releases + +The standalone executable is the existing verifier bundled with Node SEA. +It is not a second implementation. npm distribution remains supported. +End users do not install Node.js, npm or project dependencies. + +## Build and test locally + +Use Node 22 (CI pins 22.23.3) and pnpm 10.13.1: + +```sh +pnpm install --frozen-lockfile +pnpm compile +pnpm lint +pnpm test --runInBand +pnpm build:standalone +pnpm test:standalone +pnpm package:standalone +``` + +The executable is `release/standalone/gami-verify.exe` on Windows or +`release/standalone/gami-verify` elsewhere. Archives, per-platform checksums and +CycloneDX inventories go into `release/downloads/`. Packaging downloads the +license for the exact embedded Node version from the official Node repository. +Archives include the project license and bundled dependency notices. + +The smoke test copies only the executable into a temporary folder, removes +Node/npm from PATH, and checks help, version, JSON output, malformed input, +missing files, a real valid signature, a tampered signature, offline behavior, +and exit codes. It does not replace acceptance on a clean target machine. + +## GitHub Actions setup (this repository) + +Workflow: **Build standalone verifier**. Pull requests and manual branch runs +produce unsigned previews. Pushing a `test-v*` or `v*` tag runs signed Windows +and Linux builds. Manual runs selected on a tag also sign. Never move an existing +release tag to new code; push a fresh tag containing the workflow changes. + +The matrix builds Windows x64, Linux x64/ARM64 and macOS Intel/Apple silicon. +Linux targets glibc systems, not Alpine/musl. macOS artifacts are explicitly +marked unsigned previews: ad-hoc signing lets them execute locally but is not +Developer ID signing or notarization. Do not advertise them as signed releases. + +Configure these environments in **authenticmemory/gami-cli-verifier**, not only +in gami-hash. Protect both signing environments with tag rules `test-v*` and `v*`. +Keep `standalone-preview` free of signing secrets. + +### Windows + +Create `windows-signing` with environment variables `AZURE_CLIENT_ID`, +`AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID`. Reuse the authorized Azure signing +identity only after adding its GitHub federated credential: + +```text +Issuer: https://token.actions.githubusercontent.com +Subject: repo:authenticmemory@290849074/gami-cli-verifier@1347337260:environment:windows-signing +Audience: api://AzureADTokenExchange +``` + +The identity needs signing permission for the existing `gami-public-signing` +profile in `authenticmemorysigning`. The workflow signs `gami-verify.exe`, verifies the +full Authentic Memory legal organization and a timestamp, retests it, then +creates the ZIP and its checksums. Local builds are not organization-signed. + +### Linux + +Create `linux-signing` with: + +| Kind | Name | Value | +| -------- | --------------------------- | ------------------------------------------ | +| Secret | `LINUX_SIGNING_PRIVATE_KEY` | Entire armored secret-subkey export | +| Secret | `LINUX_SIGNING_PASSPHRASE` | Exported signing subkey's passphrase | +| Variable | `LINUX_SIGNING_FINGERPRINT` | `EC8558C864C9D2A41AC419B7FA5C4B8C1395EC9E` | + +These can use the existing Linux release key. Do not put the private export in +the repository or website. The workflow signs the tarball, SBOM and checksum +file, verifies signatures in a separate public-only keyring, and includes the +public key and fingerprint. This is detached OpenPGP signing, not an APT/RPM +repository signature. + +## Retrieve and publish + +1. Push committed changes and a fresh test tag. Open **Actions → Build standalone + verifier** and confirm every relevant platform job passed. +2. Download the matching workflow artifact. Inside that outer GitHub ZIP is the + actual distribution ZIP/tarball, checksums, SBOM and (Linux) signatures. +3. Have an independent user test the archive on the target OS without Node/npm. + Test an original document/GPR pair, a changed document, offline mode, and JSON + output. Exit 2 is an incomplete verification, not a successful full proof. +4. Publish the approved archives to a public release or copy them into the + website's `public/downloads/gami-verifier/` directory. The workflow deliberately + does not race the existing npm release workflow or attach unsigned previews + to a public release. Actions artifacts expire after 30 days. +5. Set the matching `archive`, `checksum`, and Linux `signature` URLs in + `AuthenticMemory_Website/src/data/verifier-downloads.ts`. Use versioned URLs. + Both Technology and For Institutions consume this single manifest. Null URLs + keep unavailable builds unlinked. Do not link private Actions artifact URLs. +6. Build the website and confirm that the public links work without GitHub login. + +## User installation and verification + +### Windows + +Download the ZIP and matching `.sha256` file into Downloads. In PowerShell: + +```powershell +cd "$HOME\Downloads" +Get-FileHash .\gami-verify-0.2.0-windows-x64.zip -Algorithm SHA256 +Get-Content .\gami-verify-0.2.0-windows-x64.sha256 +``` + +Compare the entire hash with the line ending in `.zip` (case does not matter). +If different, stop. Right-click the ZIP → Extract All. Open PowerShell in the +extracted folder, then run: + +```powershell +$signature = Get-AuthenticodeSignature .\gami-verify.exe +$signature.Status +$signature.SignerCertificate.Subject +$signature.TimeStamperCertificate.Subject +.\gami-verify.exe --help +.\gami-verify.exe verify "C:\Documents\original.pdf" "C:\Documents\record.gpr.json" --offline --json +$LASTEXITCODE +``` + +Status must be `Valid`, the signer must be Authentic Memory's legal organization, +and the timestamp certificate must be present. A SmartScreen reputation warning +can still appear for a new correctly signed executable. + +### Linux + +Obtain the public key from the Authentic Memory website and compare its primary +fingerprint with the one published independently on that website. With GnuPG: + +```sh +gpg --show-keys --with-fingerprint gami-linux-signing-key.asc +gpg --import gami-linux-signing-key.asc +gpg --verify gami-verify-0.2.0-linux-x64.tar.gz.asc gami-verify-0.2.0-linux-x64.tar.gz +gpg --verify gami-verify-0.2.0-linux-x64.sha256.asc gami-verify-0.2.0-linux-x64.sha256 +sha256sum --check gami-verify-0.2.0-linux-x64.sha256 +mkdir gami-verifier +tar -xzf gami-verify-0.2.0-linux-x64.tar.gz -C gami-verifier +cd gami-verifier +./gami-verify --help +./gami-verify verify /path/to/original.pdf /path/to/record.gpr.json --offline --json +echo $? +``` + +Keep the SBOM alongside the archive when checking the checksum file. Substitute +`arm64` for `x64` on ARM64 Linux. A GnuPG trust warning means you still need to +authenticate the fingerprint; a bad signature means stop. + +## Maintenance + +The embedded Node runtime does not update itself. Rebuild and re-sign whenever +the runtime or bundled dependencies need a security update. Keep the runtime +pin reviewed. The bundled dependency inventory is not a complete native Node +component inventory; Node's license file includes its native third-party notices. +Native Linux/macOS execution and cloud signing must pass CI before public release. + +Implementation reference: [Node single-executable applications](https://nodejs.org/download/release/latest-jod/docs/api/single-executable-applications.html). diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7b61dd2..3992760 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -16,7 +16,7 @@ and discloses the queried height and caller IP address. bitcoin-cli getblockhash ``` -`gami version --json` discloses the supported sources. Provider additions or trust +`gami-verify version --json` discloses the supported sources. Provider additions or trust policy changes require ordinary code review and a minor-version release. ## Release procedure @@ -34,7 +34,7 @@ policy changes require ordinary code review and a minor-version release. ```sh npm view @authenticmemory/gami@0.2.0 dist.integrity npm install --global @authenticmemory/gami@0.2.0 -gami version --json +gami-verify version --json gh attestation verify gami-0.2.0.tgz --repo authenticmemory/gami-cli-verifier sha256sum --check SHA256SUMS ``` diff --git a/docs/STANDALONE_VERIFIER.md b/docs/STANDALONE_VERIFIER.md index af618a7..a4f5532 100644 --- a/docs/STANDALONE_VERIFIER.md +++ b/docs/STANDALONE_VERIFIER.md @@ -1,13 +1,16 @@ -# Standalone GAMI Verifier +# Standalone GAMI Verify ## Decision -For this project, **standalone** means installable through npm, requiring Node.js -20 or newer, and running without the GAMI web application, database, or API. +For this project, **standalone** means running without the GAMI web application, +database, or API. Distribution supports both npm (Node.js 20 or newer) and portable +executables with an embedded Node.js runtime. Portable users need neither npm nor +Node.js installed. See [portable releases](PORTABLE-RELEASES.md) for platform status, +signing setup and acceptance gates. -The executable is `gami`. The package may expose only that command; names such as -`gami-verify` and `gv` are deliberately avoided so the public interface remains -simple and can grow beyond one subcommand. +The executable is `gami-verify`, distinct from `gami-hash` and `gami-local`. +The npm package remains `@authenticmemory/gami`; updating it installs the new +command name. The previous `gami` command is no longer provided. ## Purpose @@ -71,11 +74,12 @@ The frozen production invariants are: The target verification interface is: ```text -gami inspect record.gpr.json -gami verify document.pdf record.gpr.json -gami verify document.pdf record.gpr.json --offline -gami verify document.pdf record.gpr.json --strict -gami verify document.pdf record.gpr.json --json +gami-verify inspect record.gpr.json +gami-verify verify document.pdf record.gpr.json +gami-verify verify placeholder-record.gpr.json +gami-verify verify document.pdf record.gpr.json --offline +gami-verify verify document.pdf record.gpr.json --strict +gami-verify verify document.pdf record.gpr.json --json ``` `--offline` forbids network access and reports missing external evidence as @@ -98,10 +102,10 @@ reported in output and must never masquerade as live institutional authorization ### Phase 1 — contracts and format safety - Replace template branding, package metadata, and commands. -- Establish `gami` as the sole executable. +- Establish `gami-verify` as the sole executable. - Document the trust model, output contract, and exit codes. - Define the strict supported GPR v1 shape and encoding constraints. -- Implement `gami inspect` without making cryptographic claims. +- Implement `gami-verify inspect` without making cryptographic claims. - Parse standards-conforming `did:web` and `did:webvh` signing-key identifiers. - Report the deployed record lifecycle: unsigned, signed, stamped, or upgraded. - Add valid and adversarial fixtures with meaningful automated tests. @@ -119,16 +123,20 @@ the CLI never upgrades a partial result into complete GAMI proof validity. ### Phase 3 — identity verification -- Implemented: accept a caller-supplied current `did:web` document or resolve it - directly over HTTPS from `proof.key_id`, without trusting a registry result. +- Implemented: accept a caller-supplied current `did:web` document or resolve + identity evidence directly over HTTPS from `proof.key_id`, without trusting a + registry result. - Implemented in Phase 3A: require the exact GPR key under `assertionMethod`, validate its controller, decode Ed25519 Multikey or JWK material, and bind it to the key that verifies the GPR signature. - Implemented: lock the Flossenbürg `did:web` document and its deployed GPR as a production conformance pair. The fixture was supplied directly before the DID document was deployed at its HTTPS location. -- Pending Phase 3B: validate native `did:webvh` history and historical key - authorization using a real deployment log. +- Implemented in Phase 3B: validate native `did:webvh` history with + `didwebvh-ts` 2.8.0 and authorize the signing key from the exact + `?versionId=` carried in `proof.key_id`. The verifier refuses HEAD resolution + for `did:webvh` key IDs because current DID documents can legitimately remove + archived signing keys. ### Phase 4 — independent Bitcoin verification @@ -151,7 +159,7 @@ the CLI never upgrades a partial result into complete GAMI proof validity. - Implemented: publish npm provenance, SHA-256 checksums, a CycloneDX SBOM, and GitHub-signed build attestations from immutable, SHA-pinned workflow actions. - Implemented: disclose the verifier version and supported Bitcoin sources - through `gami version` and every JSON verification result. + through `gami-verify version` and every JSON verification result. - Documented: release operation and independent artifact verification in `docs/RELEASING.md`. diff --git a/package.json b/package.json index 45a9c4c..3cc31ae 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Independent command-line verifier for GAMI Proof Records.", "keywords": [ "archives", - "gami", + "gami-verify", "opentimestamps", "provenance", "verification" @@ -18,7 +18,7 @@ "url": "git+https://github.com/authenticmemory/gami-cli-verifier.git" }, "bin": { - "gami": "./bin/run" + "gami-verify": "./bin/run" }, "directories": { "lib": "src", @@ -34,6 +34,9 @@ }, "scripts": { "build": "tsup-node", + "build:standalone": "node scripts/build-standalone.mjs", + "test:standalone": "node scripts/test-standalone.mjs", + "package:standalone": "node scripts/package-standalone.mjs", "build:watch": "tsup-node --watch", "clean": "rimraf dist", "commit": "cz", @@ -56,6 +59,7 @@ "dependencies": { "@otskit/core": "0.2.0", "consola": "3.4.2", + "didwebvh-ts": "2.8.0", "dotenv": "17.4.2", "json-canonicalize": "2.0.0", "picocolors": "1.1.1", @@ -73,11 +77,13 @@ "@types/yargs": "^17.0.35", "commitizen": "^4.3.2", "cz-conventional-changelog": "^3.3.0", + "esbuild": "0.25.12", "husky": "^9.1.7", "jest": "^30.4.2", "jscpd": "^5.0.15", "oxfmt": "^0.63.0", "oxlint": "^1.78.0", + "postject": "1.0.0-alpha.6", "rimraf": "^6.1.3", "semantic-release": "^25.0.9", "ts-jest": "^29.4.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9c44a5..8046000 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: consola: specifier: 3.4.2 version: 3.4.2 + didwebvh-ts: + specifier: 2.8.0 + version: 2.8.0 dotenv: specifier: 17.4.2 version: 17.4.2 @@ -60,6 +63,9 @@ importers: cz-conventional-changelog: specifier: ^3.3.0 version: 3.3.0(@types/node@26.2.0)(typescript@6.0.3) + esbuild: + specifier: 0.25.12 + version: 0.25.12 husky: specifier: ^9.1.7 version: 9.1.7 @@ -75,6 +81,9 @@ importers: oxlint: specifier: ^1.78.0 version: 1.78.0 + postject: + specifier: 1.0.0-alpha.6 + version: 1.0.0-alpha.6 rimraf: specifier: ^6.1.3 version: 6.1.3 @@ -83,7 +92,7 @@ importers: version: 25.0.9(typescript@6.0.3) ts-jest: specifier: ^29.4.12 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.27.7)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.2.0)(ts-node@10.9.2(@types/node@26.2.0)(typescript@6.0.3)))(typescript@6.0.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.25.12)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.2.0)(ts-node@10.9.2(@types/node@26.2.0)(typescript@6.0.3)))(typescript@6.0.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@26.2.0)(typescript@6.0.3) @@ -383,156 +392,312 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -667,6 +832,10 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} engines: {node: '>= 20'} @@ -1623,6 +1792,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + commitizen@4.3.2: resolution: {integrity: sha512-1Zs37z9JPvAcuTSSricZZwBhOPVNNxJouuY4yDEt+eD70EoxT2TU9kViG8CuB/PmVg2G4XsAGQiK4YCst97aDQ==} engines: {node: '>= 18'} @@ -1689,6 +1862,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -1767,6 +1944,10 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + didwebvh-ts@2.8.0: + resolution: {integrity: sha512-JAIK/Udr7xbGoYTDRjbm34nD8iddREIQffRL+vsrCaREyVtZDuy8SdFs5lXoop9zwEjXwh64ZFRSOYm6ZCZcAw==} + hasBin: true + diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} @@ -1829,6 +2010,11 @@ packages: es-toolkit@1.50.0: resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -2958,6 +3144,11 @@ packages: yaml: optional: true + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + pretty-format@30.4.1: resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3962,81 +4153,159 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -4275,6 +4544,8 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@noble/hashes@2.4.0': {} + '@octokit/auth-token@6.0.0': {} '@octokit/core@7.0.7': @@ -5078,6 +5349,8 @@ snapshots: commander@4.1.1: {} + commander@9.5.0: {} + commitizen@4.3.2(@types/node@26.2.0)(typescript@6.0.3): dependencies: cachedir: 2.4.0 @@ -5154,6 +5427,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + core-util-is@1.0.3: {} cosmiconfig-typescript-loader@6.3.0(@types/node@26.2.0)(cosmiconfig@9.0.2(typescript@6.0.3))(typescript@6.0.3): @@ -5220,6 +5495,14 @@ snapshots: detect-newline@3.1.0: {} + didwebvh-ts@2.8.0: + dependencies: + '@noble/hashes': 2.4.0 + cookie: 1.1.1 + glob: 13.0.6 + js-yaml: 4.3.1 + json-canonicalize: 2.0.0 + diff@4.0.4: {} dir-glob@3.0.1: @@ -5268,6 +5551,35 @@ snapshots: es-toolkit@1.50.0: {} + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -6500,6 +6812,10 @@ snapshots: jiti: 2.6.1 yaml: 2.4.2 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + pretty-format@30.4.1: dependencies: '@jest/schemas': 30.4.1 @@ -6907,7 +7223,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.27.7)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.2.0)(ts-node@10.9.2(@types/node@26.2.0)(typescript@6.0.3)))(typescript@6.0.3): + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.25.12)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.2.0)(ts-node@10.9.2(@types/node@26.2.0)(typescript@6.0.3)))(typescript@6.0.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -6925,7 +7241,7 @@ snapshots: '@jest/transform': 30.4.1 '@jest/types': 30.4.1 babel-jest: 30.4.1(@babel/core@7.29.7) - esbuild: 0.27.7 + esbuild: 0.25.12 jest-util: 30.4.1 ts-node@10.9.2(@types/node@26.2.0)(typescript@6.0.3): diff --git a/scripts/build-standalone.mjs b/scripts/build-standalone.mjs new file mode 100644 index 0000000..2003b6c --- /dev/null +++ b/scripts/build-standalone.mjs @@ -0,0 +1,54 @@ +import { build } from "esbuild"; +import { inject } from "postject"; +import { mkdir, copyFile, readFile, writeFile, chmod } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; + +const dir = resolve("release/standalone"); +await mkdir(dir, { recursive: true }); +const main = resolve(dir, "main.cjs"); +await build({ + entryPoints: ["bin/run.ts"], + outfile: main, + bundle: true, + platform: "node", + format: "cjs", + target: "node22", + metafile: true, + define: { "import.meta.url": "seaModuleUrl" }, + banner: { + js: 'const seaModuleUrl = require("node:url").pathToFileURL(process.execPath).href;', + }, + logLevel: "info", +}).then(async (result) => { + const external = Object.values(result.metafile.outputs) + .flatMap((o) => o.imports) + .filter( + (i) => i.external && !i.path.startsWith("node:") && !process.getBuiltinModule(i.path), + ); + if (external.length) throw new Error(`Unbundled dependencies: ${JSON.stringify(external)}`); + await writeFile(resolve(dir, "bundle-metadata.json"), JSON.stringify(result.metafile, null, 2)); +}); +const blob = resolve(dir, "sea.blob"); +const config = resolve(dir, "sea.json"); +await writeFile( + config, + JSON.stringify({ + main, + output: blob, + disableExperimentalSEAWarning: true, + useSnapshot: false, + useCodeCache: false, + }), +); +execFileSync(process.execPath, ["--experimental-sea-config", config], { stdio: "inherit" }); +const executable = resolve(dir, process.platform === "win32" ? "gami-verify.exe" : "gami-verify"); +await copyFile(process.execPath, executable); +if (process.platform === "darwin") execFileSync("codesign", ["--remove-signature", executable]); +await inject(executable, "NODE_SEA_BLOB", await readFile(blob), { + sentinelFuse: "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2", + ...(process.platform === "darwin" ? { machoSegmentName: "NODE_SEA" } : {}), +}); +await chmod(executable, 0o755); +if (process.platform === "darwin") execFileSync("codesign", ["--sign", "-", executable]); +console.log(`Built ${executable} with ${process.version} (${process.platform}/${process.arch})`); diff --git a/scripts/create-download-manifest.mjs b/scripts/create-download-manifest.mjs new file mode 100644 index 0000000..2c3cce9 --- /dev/null +++ b/scripts/create-download-manifest.mjs @@ -0,0 +1,21 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; + +const [directory = 'release/downloads'] = process.argv.slice(2); +const version = JSON.parse(readFileSync('package.json')).version; +if (process.env.GITHUB_REF_NAME !== `v${version}`) throw new Error('Release tag must match package version'); +const artifacts = []; +for (const platform of ['windows-x64', 'linux-x64', 'linux-arm64']) { + const stem = `gami-verify-${version}-${platform}`; + const archive = `${stem}${platform.startsWith('windows') ? '.zip' : '.tar.gz'}`; + const checksum = `${stem}.sha256`; + const names = [archive, checksum, `${stem}.cdx.json`]; + const signature = platform.startsWith('linux') ? `${archive}.asc` : null; + if (signature) names.push(signature, `${checksum}.asc`, `${stem}.cdx.json.asc`); + const files = names.map(name => ({ name, sha256: createHash('sha256').update(readFileSync(join(directory, name))).digest('hex') })); + const expected = `${files[0].sha256} ${archive}`; + if (!readFileSync(join(directory, checksum), 'utf8').split(/\r?\n/).includes(expected)) throw new Error(`Archive checksum mismatch: ${archive}`); + artifacts.push({ platform, archive, checksum, signature, files }); +} +writeFileSync(join(directory, 'gami-verify-downloads.json'), JSON.stringify({ schema: 1, version, tag: `v${version}`, artifacts }, null, 2)); diff --git a/scripts/create-release-artifacts.mjs b/scripts/create-release-artifacts.mjs index 9a75a2c..80d536e 100644 --- a/scripts/create-release-artifacts.mjs +++ b/scripts/create-release-artifacts.mjs @@ -58,7 +58,7 @@ const sbom = { left.purl.localeCompare(right.purl), ), }; -const sbomPath = resolve(output, "gami.cdx.json"); +const sbomPath = resolve(output, "gami-verify.cdx.json"); writeFileSync(sbomPath, `${JSON.stringify(sbom, null, 2)}\n`); const files = [tarball, sbomPath]; diff --git a/scripts/package-standalone.mjs b/scripts/package-standalone.mjs new file mode 100644 index 0000000..0cf7ad3 --- /dev/null +++ b/scripts/package-standalone.mjs @@ -0,0 +1,114 @@ +import { + copyFileSync, + mkdirSync, + readFileSync, + writeFileSync, + existsSync, + readdirSync, +} from "node:fs"; +import { dirname, resolve, join, basename } from "node:path"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; + +const pkg = JSON.parse(readFileSync("package.json")); +const platform = { win32: "windows", darwin: "macos", linux: "linux" }[process.platform]; +if (!platform) throw new Error("Unsupported platform"); +const stem = `gami-verify-${pkg.version}-${platform}-${process.arch}`; +const output = resolve("release/downloads"); +const stage = resolve("release/packages", stem); +mkdirSync(output, { recursive: true }); +mkdirSync(stage, { recursive: true }); +const executable = process.platform === "win32" ? "gami-verify.exe" : "gami-verify"; +copyFileSync(resolve("release/standalone", executable), join(stage, executable)); +copyFileSync("LICENSE", join(stage, "LICENSE")); +const runtimeLicense = await fetch( + `https://raw.githubusercontent.com/nodejs/node/${process.version}/LICENSE`, +); +if (!runtimeLicense.ok) throw new Error(`Cannot retrieve Node license: ${runtimeLicense.status}`); +writeFileSync(join(stage, "NODE-LICENSE.txt"), await runtimeLicense.text()); +const metadata = JSON.parse(readFileSync("release/standalone/bundle-metadata.json")); +const packages = new Map(); +for (const input of Object.keys(metadata.inputs).filter((p) => p.includes("node_modules/"))) { + let directory = dirname(resolve(input)); + while (directory !== dirname(directory)) { + const manifest = join(directory, "package.json"); + if (existsSync(manifest)) { + const dep = JSON.parse(readFileSync(manifest)); + if (dep.name && dep.version) { + packages.set(`${dep.name}@${dep.version}`, { directory, dep }); + break; + } + } + directory = dirname(directory); + } +} +let notices = ""; +for (const [name, { directory, dep }] of packages) { + notices += `\n=== ${name} (${dep.license ?? "see package"}) ===\n`; + let licenseDirectory = directory; + let licenses = []; + while (licenseDirectory !== dirname(licenseDirectory)) { + licenses = readdirSync(licenseDirectory).filter((f) => + /^(license|licence|copying|notice)([.-]|$)/i.test(f), + ); + if (licenses.length) break; + if (basename(dirname(licenseDirectory)) === "node_modules") break; + licenseDirectory = dirname(licenseDirectory); + } + if (!licenses.length) throw new Error(`No license text found for bundled dependency ${name}`); + for (const file of licenses) + notices += `${readFileSync(join(licenseDirectory, file), "utf8")}\n`; +} +writeFileSync(join(stage, "THIRD-PARTY-NOTICES.txt"), notices); +writeFileSync( + join(stage, "README.txt"), + `GAMI Verify ${pkg.version}\n\nNo Node.js or npm installation required.\nExtract the entire archive. Open a terminal in this folder.\n${platform === "windows" ? ".\\gami-verify.exe" : "./gami-verify"} --help\n${platform === "windows" ? ".\\gami-verify.exe" : "./gami-verify"} verify document.pdf record.gpr.json --offline --json\n\nOffline verification can return exit 2 (indeterminate) when identity or Bitcoin evidence is missing.\nExit codes: 0 passed, 1 failed, 2 indeterminate, 3 input error, 4 internal error.\n\nmacOS builds are ad-hoc signed for testing only until Developer ID signing and notarization are configured.\nLinux requires glibc (not Alpine/musl).\nSource: https://github.com/authenticmemory/gami-cli-verifier\n`, +); +const archive = join(output, `${stem}${platform === "windows" ? ".zip" : ".tar.gz"}`); +if (platform === "windows") { + // Paths are supplied through environment variables, never interpolated as shell code. + execFileSync( + "powershell.exe", + [ + "-NoProfile", + "-Command", + 'Compress-Archive -Path (Join-Path $env:GAMI_PACKAGE_STAGE "*") -DestinationPath $env:GAMI_PACKAGE_ARCHIVE -Force', + ], + { + env: { ...process.env, GAMI_PACKAGE_STAGE: stage, GAMI_PACKAGE_ARCHIVE: archive }, + stdio: "inherit", + }, + ); +} else execFileSync("tar", ["-czf", archive, "-C", stage, "."], { stdio: "inherit" }); +const sbom = join(output, `${stem}.cdx.json`); +writeFileSync( + sbom, + JSON.stringify( + { + bomFormat: "CycloneDX", + specVersion: "1.6", + version: 1, + metadata: { component: { type: "application", name: pkg.name, version: pkg.version } }, + components: [ + { type: "application", name: "node", version: process.versions.node }, + ...[...packages.values()].map(({ dep }) => ({ + type: "library", + name: dep.name, + version: dep.version, + })), + ], + }, + null, + 2, + ), +); +writeFileSync( + join(output, `${stem}.sha256`), + [archive, sbom] + .map( + (file) => + `${createHash("sha256").update(readFileSync(file)).digest("hex")} ${basename(file)}\n`, + ) + .join(""), +); +console.log(`Packaged ${archive}`); diff --git a/scripts/sign-linux.sh b/scripts/sign-linux.sh new file mode 100644 index 0000000..582488d --- /dev/null +++ b/scripts/sign-linux.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Detached OpenPGP signatures for direct downloads; not native RPM/APT signing. +set -euo pipefail +umask 077 +: "${LINUX_SIGNING_PRIVATE_KEY:?Missing signing key}" +: "${LINUX_SIGNING_PASSPHRASE:?Missing signing passphrase}" +: "${LINUX_SIGNING_FINGERPRINT:?Missing primary fingerprint}" +[[ "$LINUX_SIGNING_FINGERPRINT" =~ ^[A-Fa-f0-9]{40}$ ]] || { echo 'Expected a full primary key fingerprint' >&2; exit 1; } +[[ $# -ge 2 ]] || { echo 'Usage: sign-linux.sh OUTPUT_DIRECTORY FILE...' >&2; exit 1; } +out="$1"; shift +mkdir -p "$out" +work=$(mktemp -d) +trap 'gpgconf --homedir "$work/sign" --kill gpg-agent 2>/dev/null || true; rm -rf -- "$work"' EXIT +mkdir "$work/sign" "$work/verify" +printf '%s' "$LINUX_SIGNING_PRIVATE_KEY" | gpg --homedir "$work/sign" --batch --quiet --import +fingerprint=$(gpg --homedir "$work/sign" --batch --with-colons --list-secret-keys | awk -F: '$1=="fpr" {print $10; exit}') +[[ "${fingerprint^^}" == "${LINUX_SIGNING_FINGERPRINT^^}" ]] || { echo 'Signing fingerprint mismatch' >&2; exit 1; } +gpg --homedir "$work/sign" --batch --armor --export "$fingerprint" > "$out/gami-linux-signing-key.asc" +gpg --homedir "$work/verify" --batch --quiet --import "$out/gami-linux-signing-key.asc" +for file in "$@"; do + [[ -f "$file" ]] || { echo "Missing artifact: $file" >&2; exit 1; } + printf '%s\n' "$LINUX_SIGNING_PASSPHRASE" | gpg --homedir "$work/sign" --batch --yes --pinentry-mode loopback --passphrase-fd 0 --local-user "$fingerprint" --armor --detach-sign --output "$file.asc" "$file" + gpg --homedir "$work/verify" --batch --verify "$file.asc" "$file" +done +printf '%s\n' "$fingerprint" > "$out/gami-linux-signing-fingerprint.txt" diff --git a/scripts/test-standalone.mjs b/scripts/test-standalone.mjs new file mode 100644 index 0000000..7002517 --- /dev/null +++ b/scripts/test-standalone.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve, join } from "node:path"; + +const root = mkdtempSync(join(tmpdir(), "gami-portable-")); +const name = process.platform === "win32" ? "gami-verify.exe" : "gami-verify"; +const binary = join(root, name); +try { + copyFileSync( + process.argv[2] ? resolve(process.argv[2]) : resolve("release/standalone", name), + binary, + ); + const fixtures = resolve("test/fixtures"); + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (/^(path|node_path|node_options)$/i.test(key)) delete env[key]; + } + env.PATH = root; // No node/npm, node_modules or source files beside the executable. + const run = (args, expected) => { + const result = spawnSync(binary, args, { + cwd: root, + env, + encoding: "utf8", + timeout: 30000, + }); + assert.ifError(result.error); + assert.equal( + result.status, + expected, + `${args.join(" ")}\n${result.stdout}\n${result.stderr}`, + ); + return result.stdout; + }; + assert.match(run(["--help"], 0), /gami-verify verify/); + assert.equal(run(["--version"], 0).trim(), JSON.parse(readFileSync("package.json")).version); + assert.equal( + JSON.parse(run(["inspect", join(fixtures, "valid-unsigned.gpr.json"), "--json"], 0)).status, + "passed", + ); + assert.equal( + JSON.parse(run(["inspect", join(fixtures, "invalid-unknown-field.gpr.json"), "--json"], 1)) + .status, + "failed", + ); + run(["inspect", join(root, "missing.json"), "--json"], 3); + const record = join(fixtures, "production/phase4-single-upgraded.gpr.json"); + const result = JSON.parse(run(["verify", record, "--offline", "--json"], 2)); + assert.equal(result.status, "indeterminate"); + assert.ok( + result.checks.some((c) => c.name === "signature_math" && c.status === "passed"), + JSON.stringify(result), + ); + const altered = JSON.parse(readFileSync(record)); + altered.proof.signature = `ed25519:${"00".repeat(64)}`; + const bad = join(root, "tampered.gpr.json"); + writeFileSync(bad, JSON.stringify(altered)); + const tampered = JSON.parse(run(["verify", bad, "--offline", "--json"], 1)); + assert.ok(tampered.checks.some((c) => c.name === "gpr_format" && c.status === "passed")); + assert.ok(tampered.checks.some((c) => c.name === "signature_math" && c.status === "failed")); + console.log( + "Standalone help, version, valid/invalid input, offline signature, tampering and exit-code tests passed without Node/npm on PATH.", + ); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index fc460d3..8b0fccc 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -25,7 +25,8 @@ for (const [name, range] of Object.entries(packageJson.dependencies)) { if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(range)) throw new Error(`runtime dependency ${name} is not exactly pinned: ${range}`); } -if (packageJson.bin?.gami !== "./bin/run") throw new Error("package exposes an unexpected CLI"); +if (packageJson.bin?.["gami-verify"] !== "./bin/run") + throw new Error("package exposes an unexpected CLI"); console.log( `Package check passed: ${report.filename} (${report.size} bytes, ${paths.length} files)`, ); diff --git a/src/adapters/did-webvh.test.ts b/src/adapters/did-webvh.test.ts new file mode 100644 index 0000000..e1a88ab --- /dev/null +++ b/src/adapters/did-webvh.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "@jest/globals"; +import { didWebvhLogUrl } from "./did-webvh"; + +describe("didWebvhLogUrl", () => { + it("derives the did.jsonl URL from the did:webvh key id without using HEAD", () => { + const scid = "QmdmPkUdYzbr9txmx8gM2rsHPgr5L6m3gHjJGAf4vUFoGE"; + const keyId = `did:webvh:${scid}:sarkazein.xyz:pr81-revocation-20260909?versionId=1-${"a".repeat(64)}#Nm8goDK4`; + expect(didWebvhLogUrl(keyId)).toBe( + "https://sarkazein.xyz/pr81-revocation-20260909/did.jsonl", + ); + }); +}); diff --git a/src/adapters/did-webvh.ts b/src/adapters/did-webvh.ts new file mode 100644 index 0000000..393e03d --- /dev/null +++ b/src/adapters/did-webvh.ts @@ -0,0 +1,114 @@ +import { createPublicKey, verify as verifyWithNode } from "node:crypto"; +import { isIP } from "node:net"; +import { resolveDIDFromLog, type DIDLog, type Verifier } from "didwebvh-ts"; +import { parseDidKeyId } from "../domain/did"; + +const MAX_DID_LOG_BYTES = 2 * 1024 * 1024; +const DID_TIMEOUT_MS = 15_000; + +export interface ResolvedDidWebvhDocument { + document: unknown; + url: string; + versionId: string; + latestVersionId: string; + signatureKeyStatus: "active" | "archived"; +} + +const nodeEd25519Verifier: Verifier = { + async verify( + signature: Uint8Array, + message: Uint8Array, + publicKey: Uint8Array, + ): Promise { + try { + const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex"); + const key = createPublicKey({ + key: Buffer.concat([spkiPrefix, Buffer.from(publicKey)]), + format: "der", + type: "spki", + }); + return verifyWithNode(null, Buffer.from(message), key, Buffer.from(signature)); + } catch { + return false; + } + }, +}; + +export function didWebvhLogUrl(keyId: string): string { + const parsed = parseDidKeyId(keyId); + if (parsed.method !== "webvh") + throw new Error(`did:webvh log resolution cannot resolve did:${parsed.method}`); + const authority = parsed.port === undefined ? parsed.host : `${parsed.host}:${parsed.port}`; + const path = + parsed.path.length === 0 + ? "/.well-known/did.jsonl" + : `/${parsed.path.map(encodeURIComponent).join("/")}/did.jsonl`; + const url = new URL(`https://${authority}${path}`); + if (isIP(url.hostname) || url.hostname === "localhost" || url.hostname.endsWith(".localhost")) + throw new Error("did:webvh must identify a public DNS host"); + return url.href; +} + +export async function resolveDidWebvhDocument( + keyId: string, + fetcher: typeof fetch = fetch, +): Promise { + const parsed = parseDidKeyId(keyId); + if (parsed.method !== "webvh") throw new Error("not a did:webvh key identifier"); + if (!parsed.versionId) { + throw new Error( + "did:webvh key identifiers must include ?versionId=; refusing HEAD resolution", + ); + } + + const url = didWebvhLogUrl(keyId); + const response = await fetcher(url, { + headers: { accept: "application/jsonl, application/json-seq, text/plain" }, + redirect: "error", + signal: AbortSignal.timeout(DID_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`DID log server returned HTTP ${response.status}`); + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_DID_LOG_BYTES) + throw new Error("DID log exceeds the size limit"); + const text = await response.text(); + if (Buffer.byteLength(text) > MAX_DID_LOG_BYTES) + throw new Error("DID log exceeds the size limit"); + + const log = parseDidLog(text); + const latestVersionId = String(log.at(-1)?.versionId ?? ""); + const resolved = await resolveDIDFromLog(log, { + versionId: parsed.versionId, + scid: parsed.scid, + requestedDid: parsed.did, + verifier: nodeEd25519Verifier, + }); + if (!resolved.doc) throw new Error("did:webvh resolver returned no DID document"); + if (resolved.meta.versionId !== parsed.versionId) { + throw new Error( + `did:webvh resolver returned ${resolved.meta.versionId}, expected ${parsed.versionId}`, + ); + } + return { + document: resolved.doc, + url, + versionId: parsed.versionId, + latestVersionId, + signatureKeyStatus: parsed.versionId === latestVersionId ? "active" : "archived", + }; +} + +function parseDidLog(text: string): DIDLog { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (lines.length === 0) throw new Error("DID log is empty"); + return lines.map((line, index) => { + try { + return JSON.parse(line) as DIDLog[number]; + } catch { + throw new Error(`DID log line ${index + 1} is not valid JSON`); + } + }); +} diff --git a/src/commands/verify.ts b/src/commands/verify.ts index 4a1a033..1373238 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -2,6 +2,8 @@ import type { ArgumentsCamelCase, Argv } from "yargs"; import { hashDocument, readJsonFile } from "../adapters/files"; import { resolveBitcoinEvidence, type BitcoinSource } from "../adapters/bitcoin-network"; import { resolveDidWebDocument } from "../adapters/did-web"; +import { resolveDidWebvhDocument } from "../adapters/did-webvh"; +import { parseDidKeyId } from "../domain/did"; import { InputError } from "../domain/error"; import { gprLifecycle, validateGpr } from "../domain/gpr"; import { ExitCode, OUTPUT_VERSION, exitCodeFor, type CommandResult } from "../domain/result"; @@ -11,8 +13,8 @@ import { renderJson } from "../output/json"; import { renderHuman } from "../output/render"; interface VerifyArguments { - document: string; - gpr: string; + first: string; + second?: string; json: boolean; publicKey?: string; didEvidence?: string; @@ -21,20 +23,20 @@ interface VerifyArguments { offline: boolean; } -export const command = "verify "; +export const command = "verify [second]"; export const describe = "Verify a local document against a deployed GPR v1 record"; export function builder(yargs: Argv): Argv { return yargs - .positional("document", { + .positional("first", { type: "string", demandOption: true, - describe: "Path to the original document", + describe: + "Path to the original document, or the GPR when no original file is available", }) - .positional("gpr", { + .positional("second", { type: "string", - demandOption: true, - describe: "Path to the complete .gpr.json file", + describe: "Path to the complete .gpr.json file when a document path was supplied", }) .option("public-key", { type: "string", @@ -70,7 +72,9 @@ export async function handler(argv: ArgumentsCamelCase): Promis if (argv.publicKey && !/^[0-9a-f]{64}$/i.test(argv.publicKey)) { throw new InputError("--public-key must be exactly 64 hexadecimal characters"); } - const validation = validateGpr(await readJsonFile(argv.gpr)); + const documentPath = argv.second ? argv.first : undefined; + const gprPath = argv.second ?? argv.first; + const validation = validateGpr(await readJsonFile(gprPath)); if (!validation.valid) { const result: CommandResult = { output_version: OUTPUT_VERSION, @@ -91,15 +95,30 @@ export async function handler(argv: ArgumentsCamelCase): Promis } let didDocument: unknown; - let didEvidenceSource: "provided-current" | "resolved-current" | undefined; + let didEvidenceSource: + | "provided-current" + | "resolved-current" + | "resolved-history" + | undefined; let didResolutionWarning: string | undefined; + let signatureKeyStatus: "active" | "archived" | undefined; if (argv.didEvidence) { didDocument = await readJsonFile(argv.didEvidence, "DID evidence"); didEvidenceSource = "provided-current"; } else if (!argv.offline) { try { - didDocument = (await resolveDidWebDocument(validation.value.proof.key_id)).document; - didEvidenceSource = "resolved-current"; + const parsed = parseDidKeyId(validation.value.proof.key_id); + if (parsed.method === "webvh") { + const resolved = await resolveDidWebvhDocument(validation.value.proof.key_id); + didDocument = resolved.document; + didEvidenceSource = "resolved-history"; + signatureKeyStatus = resolved.signatureKeyStatus; + } else { + didDocument = (await resolveDidWebDocument(validation.value.proof.key_id)) + .document; + didEvidenceSource = "resolved-current"; + signatureKeyStatus = "active"; + } } catch (error) { didResolutionWarning = `Live DID resolution unavailable; institutional identity is unconfirmed (${message(error)})`; } @@ -125,11 +144,12 @@ export async function handler(argv: ArgumentsCamelCase): Promis bitcoinResolutionWarning = `Bitcoin verification unavailable (${message(error)})`; } } - const documentHash = await hashDocument(argv.document); + const documentHash = documentPath ? await hashDocument(documentPath) : undefined; const result = await verifyLocal(documentHash, validation.value, { publicKeyHex: argv.publicKey?.toLowerCase(), didDocument, didEvidenceSource, + signatureKeyStatus, didResolutionWarning: argv.offline && !argv.didEvidence ? "Offline mode: institutional identity was not checked" diff --git a/src/domain/canonical.ts b/src/domain/canonical.ts index c498c11..cb9c740 100644 --- a/src/domain/canonical.ts +++ b/src/domain/canonical.ts @@ -6,7 +6,8 @@ import type { Gpr } from "./gpr"; * absent: they are bound by the WebAuthn challenge and Merkle inclusion path. */ export function signingObject(gpr: Gpr): Record { - const subject: Record = { file_hash: gpr.subject.file_hash }; + const subject: Record = {}; + if (gpr.subject.file_hash) subject.file_hash = gpr.subject.file_hash; if (gpr.subject.filename) subject.filename = gpr.subject.filename; if (gpr.subject.metadata && Object.keys(gpr.subject.metadata).length > 0) { subject.metadata = gpr.subject.metadata; @@ -35,7 +36,8 @@ export function canonicalForSigning(gpr: Gpr): string { /** Rebuild the deployed v1 timestamp payload: the signed GPR without proof.timestamp. */ export function timestampObject(gpr: Gpr): Record { - const subject: Record = { file_hash: gpr.subject.file_hash }; + const subject: Record = {}; + if (gpr.subject.file_hash) subject.file_hash = gpr.subject.file_hash; if (gpr.subject.filename) subject.filename = gpr.subject.filename; if (gpr.subject.metadata && Object.keys(gpr.subject.metadata).length > 0) subject.metadata = gpr.subject.metadata; diff --git a/src/domain/did-document.test.ts b/src/domain/did-document.test.ts index 7e13980..fc6ec83 100644 --- a/src/domain/did-document.test.ts +++ b/src/domain/did-document.test.ts @@ -137,4 +137,35 @@ describe("authorizeDidKey", () => { message: expect.stringContaining("history"), }); }); + + it("authorizes a did:webvh key only after resolving the pinned history version", () => { + const scid = "QmYwAPJzv5CZsnAzt8auVZRnGi2C19h1QSpL6Y6N7RZ6Z7"; + const didWebvh = `did:webvh:${scid}:archive.example`; + const keyFragment = "Nm8goDK4"; + const keyId = `${didWebvh}?versionId=1-${"a".repeat(64)}#${keyFragment}`; + const document = { + id: didWebvh, + assertionMethod: [ + { + id: `#${keyFragment}`, + type: "JsonWebKey2020", + controller: didWebvh, + publicKeyJwk: { + kty: "OKP", + crv: "Ed25519", + x: Buffer.from(keyHex, "hex").toString("base64url"), + }, + }, + ], + }; + expect( + authorizeDidKey(document, keyId, keyHex, undefined, "resolved-history", "archived"), + ).toMatchObject({ + status: "passed", + did: didWebvh, + keyId, + signatureKeyStatus: "archived", + message: expect.stringContaining(`did:webvh:${scid}:archive.example#${keyFragment}`), + }); + }); }); diff --git a/src/domain/did-document.ts b/src/domain/did-document.ts index e3a98a6..7768cb2 100644 --- a/src/domain/did-document.ts +++ b/src/domain/did-document.ts @@ -9,7 +9,8 @@ export interface DidAuthorization { did?: string; keyId?: string; publicKeyHex?: string; - evidenceSource: "none" | "provided-current" | "resolved-current"; + evidenceSource: "none" | "provided-current" | "resolved-current" | "resolved-history"; + signatureKeyStatus?: "active" | "archived"; } function isObject(value: unknown): value is JsonObject { @@ -138,9 +139,14 @@ export function authorizeDidKey( keyId: string, embeddedPublicKeyHex?: string, overriddenPublicKeyHex?: string, - evidenceSource: "provided-current" | "resolved-current" = "provided-current", + evidenceSource: + | "provided-current" + | "resolved-current" + | "resolved-history" = "provided-current", + signatureKeyStatus?: "active" | "archived", ): DidAuthorization { const parsed = parseDidKeyId(keyId); + const documentKeyId = `${parsed.did}#${parsed.fragment}`; if (document === undefined) { return { status: "indeterminate", @@ -150,7 +156,7 @@ export function authorizeDidKey( evidenceSource: "none", }; } - if (parsed.method === "webvh") { + if (parsed.method === "webvh" && evidenceSource !== "resolved-history") { return { status: "indeterminate", message: @@ -178,7 +184,7 @@ export function authorizeDidKey( } let method: JsonObject | undefined; try { - method = findAuthorizedMethod(document, parsed.did, keyId); + method = findAuthorizedMethod(document, parsed.did, documentKeyId); } catch (error) { return { status: "failed", @@ -191,7 +197,7 @@ export function authorizeDidKey( if (!method) { return { status: "failed", - message: `${keyId} is not authorized by assertionMethod`, + message: `${documentKeyId} is not authorized by assertionMethod`, did: parsed.did, keyId, evidenceSource, @@ -243,10 +249,14 @@ export function authorizeDidKey( } return { status: "passed", - message: `${keyId} is authorized for assertion by the ${evidenceSource === "resolved-current" ? "live" : "supplied"} current did:web document`, + message: + evidenceSource === "resolved-history" + ? `${documentKeyId} is authorized for assertion by the resolved did:webvh history at ${parsed.versionId}` + : `${documentKeyId} is authorized for assertion by the ${evidenceSource === "resolved-current" ? "live" : "supplied"} current did:web document`, did: parsed.did, keyId, publicKeyHex: bytesToHex(key), evidenceSource, + signatureKeyStatus, }; } diff --git a/src/domain/did.test.ts b/src/domain/did.test.ts index cd9e144..31c7b5e 100644 --- a/src/domain/did.test.ts +++ b/src/domain/did.test.ts @@ -3,9 +3,10 @@ import { parseDidKeyId } from "./did"; describe("parseDidKeyId", () => { it("parses the did:web form used by deployed GPRs", () => { - expect(parseDidKeyId("did:web:gedenkstaette-flossenbuerg.de#key-1")).toEqual({ + expect(parseDidKeyId("did:web:gedenkstaette-flossenbuerg.de#key-1")).toMatchObject({ method: "web", did: "did:web:gedenkstaette-flossenbuerg.de", + didUrl: "did:web:gedenkstaette-flossenbuerg.de", fragment: "key-1", host: "gedenkstaette-flossenbuerg.de", path: [], @@ -31,11 +32,25 @@ describe("parseDidKeyId", () => { }); }); + it("parses did:webvh key identifiers pinned to a history version", () => { + const scid = "QmdmPkUdYzbr9txmx8gM2rsHPgr5L6m3gHjJGAf4vUFoGE"; + const keyId = `did:webvh:${scid}:example.com:dids:issuer?versionId=1-${"a".repeat(64)}#Nm8goDK4`; + expect(parseDidKeyId(keyId)).toMatchObject({ + method: "webvh", + did: `did:webvh:${scid}:example.com:dids:issuer`, + didUrl: `did:webvh:${scid}:example.com:dids:issuer?versionId=1-${"a".repeat(64)}`, + versionId: `1-${"a".repeat(64)}`, + fragment: "Nm8goDK4", + }); + }); + it.each([ "did:web:127.0.0.1#key-1", "did:web:example.com:..#key-1", "did:web:example.com%3A70000#key-1", "did:web:example.com#", + `did:web:example.com?versionId=1-${"a".repeat(64)}#key-1`, + `did:webvh:QmdmPkUdYzbr9txmx8gM2rsHPgr5L6m3gHjJGAf4vUFoGE:example.com?versionTime=2026-01-01T00:00:00Z#key-1`, "did:key:z6MkExample#key-1", "did:webvh:not-a-scid:example.com#key-1", ])("rejects unsafe or unsupported key identifier %s", (value) => { diff --git a/src/domain/did.ts b/src/domain/did.ts index d902a6c..f4b6b35 100644 --- a/src/domain/did.ts +++ b/src/domain/did.ts @@ -5,11 +5,13 @@ export type SupportedDidMethod = "web" | "webvh"; export interface ParsedDidKeyId { method: SupportedDidMethod; did: string; + didUrl: string; fragment: string; host: string; port?: number; path: string[]; scid?: string; + versionId?: string; } const BASE58BTC = /^[1-9A-HJ-NP-Za-km-z]{46}$/; @@ -84,18 +86,37 @@ export function parseDidKeyId(value: string): ParsedDidKeyId { const hashIndex = value.indexOf("#"); if (hashIndex < 0 || hashIndex !== value.lastIndexOf("#")) throw new Error("DID key identifier must contain exactly one fragment"); - const did = value.slice(0, hashIndex); + const didUrl = value.slice(0, hashIndex); const fragment = value.slice(hashIndex + 1); if (!fragment || !FRAGMENT_CHAR.test(fragment)) throw new Error("DID key fragment is invalid"); decodeOnce(fragment, "DID key fragment"); - if (did.includes("/") || did.includes("?") || did.includes("#")) + if (didUrl.includes("/") || didUrl.includes("#")) throw new Error("GAMI key identifiers must reference the DID directly"); + const queryIndex = didUrl.indexOf("?"); + const did = queryIndex >= 0 ? didUrl.slice(0, queryIndex) : didUrl; + const query = queryIndex >= 0 ? didUrl.slice(queryIndex + 1) : ""; + let versionId: string | undefined; + if (query) { + const params = new URLSearchParams(query); + versionId = params.get("versionId") ?? undefined; + if (!versionId || params.size !== 1) { + throw new Error("DID URL query may only contain versionId"); + } + } if (did.startsWith("did:web:")) { + if (versionId) throw new Error("did:web key identifiers must not use versionId"); const segments = did.slice("did:web:".length).split(":"); if (!segments[0]) throw new Error("did:web identifier has no domain"); const authority = parseHost(segments[0]); - return { method: "web", did, fragment, ...authority, path: parsePath(segments.slice(1)) }; + return { + method: "web", + did, + didUrl, + fragment, + ...authority, + path: parsePath(segments.slice(1)), + }; } if (did.startsWith("did:webvh:")) { @@ -106,7 +127,16 @@ export function parseDidKeyId(value: string): ParsedDidKeyId { const domain = segments.shift(); if (!domain) throw new Error("did:webvh identifier has no domain"); const authority = parseHost(domain); - return { method: "webvh", did, fragment, scid, ...authority, path: parsePath(segments) }; + return { + method: "webvh", + did, + didUrl, + fragment, + versionId, + scid, + ...authority, + path: parsePath(segments), + }; } throw new Error("signing key must use did:web or did:webvh"); diff --git a/src/domain/gpr.ts b/src/domain/gpr.ts index 9d34560..16b71c3 100644 --- a/src/domain/gpr.ts +++ b/src/domain/gpr.ts @@ -32,7 +32,7 @@ export interface Gpr { type: "gami-proof"; schema: "v1"; id: string; - subject: { filename?: string; file_hash: string; metadata?: Record }; + subject: { filename?: string; file_hash?: string; metadata?: Record }; proof: GprProof; parent: string | null; } @@ -220,7 +220,7 @@ export function validateGpr(value: unknown): GprValidation { exactKeys(value.subject, ["filename", "file_hash", "metadata"], "$.subject", issues); optionalString(value.subject, "filename", "$.subject", issues); checkPattern( - requiredString(value.subject, "file_hash", "$.subject", issues), + optionalString(value.subject, "file_hash", "$.subject", issues), SHA256, "$.subject.file_hash", "must use sha256:<64 lowercase hex> format", diff --git a/src/domain/result.ts b/src/domain/result.ts index b958a6a..1792510 100644 --- a/src/domain/result.ts +++ b/src/domain/result.ts @@ -30,8 +30,9 @@ export interface CommandResult { webauthn_user_verified?: boolean; did?: string; did_key_id?: string; - did_evidence_source?: "none" | "provided-current" | "resolved-current"; + did_evidence_source?: "none" | "provided-current" | "resolved-current" | "resolved-history"; did_authorization?: "passed" | "failed" | "indeterminate"; + signature_key_status?: "active" | "archived"; timestamp_document_hash?: string; timestamp_ots_leaf?: string; timestamp_state?: "missing" | "pending" | "attested" | "verified" | "failed"; diff --git a/src/domain/verify.ts b/src/domain/verify.ts index 5dce0cc..16ef098 100644 --- a/src/domain/verify.ts +++ b/src/domain/verify.ts @@ -10,13 +10,14 @@ export interface LocalVerifyOptions { publicKeyHex?: string; didDocument?: unknown; bitcoinEvidence?: BitcoinBlockEvidence; - didEvidenceSource?: "provided-current" | "resolved-current"; + didEvidenceSource?: "provided-current" | "resolved-current" | "resolved-history"; + signatureKeyStatus?: "active" | "archived"; didResolutionWarning?: string; bitcoinResolutionWarning?: string; } export async function verifyLocal( - documentHash: string, + documentHash: string | undefined, gpr: Gpr, options: LocalVerifyOptions = {}, ): Promise { @@ -28,13 +29,16 @@ export async function verifyLocal( }, ]; - const hashMatches = documentHash === gpr.subject.file_hash; + const hashMatches = documentHash !== undefined && documentHash === gpr.subject.file_hash; checks.push({ name: "document_hash", - status: hashMatches ? "passed" : "failed", - message: hashMatches - ? "Document SHA-256 matches subject.file_hash" - : `Document SHA-256 does not match subject.file_hash`, + status: documentHash === undefined ? "skipped" : hashMatches ? "passed" : "failed", + message: + documentHash === undefined + ? "No original document supplied; file hash check was skipped" + : hashMatches + ? "Document SHA-256 matches subject.file_hash" + : `Document SHA-256 does not match subject.file_hash`, }); const authorization = authorizeDidKey( @@ -43,6 +47,7 @@ export async function verifyLocal( gpr.proof.public_key_hex, options.publicKeyHex, options.didEvidenceSource, + options.signatureKeyStatus, ); const verificationKey = options.publicKeyHex ?? authorization.publicKeyHex; const verificationKeySource = options.publicKeyHex @@ -94,7 +99,7 @@ export async function verifyLocal( }); const failed = - !hashMatches || + (documentHash !== undefined && !hashMatches) || signatureFailed || authorization.status === "failed" || timestampResult.status === "failed"; @@ -119,6 +124,7 @@ export async function verifyLocal( did_key_id: authorization.keyId, did_evidence_source: authorization.evidenceSource, did_authorization: authorization.status, + signature_key_status: authorization.signatureKeyStatus, timestamp_document_hash: timestampResult.canonicalHash, timestamp_ots_leaf: timestampResult.otsLeaf, timestamp_state: timestampResult.status, From 26ea78f93be1daf61d0c7d22d40eae7506b568f1 Mon Sep 17 00:00:00 2001 From: Chijioke Ugwuanyi Date: Fri, 25 Sep 2026 18:56:53 +0200 Subject: [PATCH 2/4] fix: remove runtime signature before Windows SEA injection --- docs/PORTABLE-RELEASES.md | 22 +++++++++++++--------- scripts/build-standalone.mjs | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/PORTABLE-RELEASES.md b/docs/PORTABLE-RELEASES.md index 8128ba9..540e24d 100644 --- a/docs/PORTABLE-RELEASES.md +++ b/docs/PORTABLE-RELEASES.md @@ -87,15 +87,19 @@ repository signature. 3. Have an independent user test the archive on the target OS without Node/npm. Test an original document/GPR pair, a changed document, offline mode, and JSON output. Exit 2 is an incomplete verification, not a successful full proof. -4. Publish the approved archives to a public release or copy them into the - website's `public/downloads/gami-verifier/` directory. The workflow deliberately - does not race the existing npm release workflow or attach unsigned previews - to a public release. Actions artifacts expire after 30 days. -5. Set the matching `archive`, `checksum`, and Linux `signature` URLs in - `AuthenticMemory_Website/src/data/verifier-downloads.ts`. Use versioned URLs. - Both Technology and For Institutions consume this single manifest. Null URLs - keep unavailable builds unlinked. Do not link private Actions artifact URLs. -6. Build the website and confirm that the public links work without GitHub login. +4. For production, bump `package.json` to a new version and push its matching + `vX.Y.Z` tag. Do not reuse `v0.2.0`. Once all builds and signature checks pass, + the standalone workflow publishes Windows/Linux files and the download + manifest to GitHub Releases. The npm workflow attaches its separate artifacts. + macOS previews never enter the published download manifest. +5. Configure the repository secret `NETLIFY_BUILD_HOOK` for the website's + production branch. The workflow triggers it after release publication. + The Netlify build verifies/downloads the release assets and enables links on + `/tools` in the same atomic deployment. Follow + `AuthenticMemory_Website/RELEASE-DOWNLOADS.md` for one-time setup. +6. Confirm the Netlify deployment succeeded and test the public download links. + A successful build-hook request alone does not prove the website deployed. + Test-tag artifacts expire after 30 days; stable GitHub release assets persist. ## User installation and verification diff --git a/scripts/build-standalone.mjs b/scripts/build-standalone.mjs index 2003b6c..26efcc1 100644 --- a/scripts/build-standalone.mjs +++ b/scripts/build-standalone.mjs @@ -1,6 +1,6 @@ import { build } from "esbuild"; import { inject } from "postject"; -import { mkdir, copyFile, readFile, writeFile, chmod } from "node:fs/promises"; +import { mkdir, copyFile, readFile, writeFile, chmod, readdir, access } from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; @@ -44,6 +44,22 @@ await writeFile( execFileSync(process.execPath, ["--experimental-sea-config", config], { stdio: "inherit" }); const executable = resolve(dir, process.platform === "win32" ? "gami-verify.exe" : "gami-verify"); await copyFile(process.execPath, executable); +if (process.platform === "win32") { + // Remove Node's Authenticode certificate before postject rewrites PE resources. + // Leaving it in place can produce an executable that runs but cannot be signed. + let signtool = process.env.SIGNTOOL_PATH; + if (!signtool) { + const sdk = resolve(process.env["ProgramFiles(x86)"] || "C:/Program Files (x86)", "Windows Kits/10/bin"); + const versions = (await readdir(sdk)).filter((name) => /^10\./.test(name)) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + for (const version of versions) { + const candidate = resolve(sdk, version, process.arch, "signtool.exe"); + try { await access(candidate); signtool = candidate; break; } catch { /* Try another SDK. */ } + } + } + if (!signtool) throw new Error("Install the Windows SDK or set SIGNTOOL_PATH before building the Windows executable"); + execFileSync(signtool, ["remove", "/s", executable], { stdio: "inherit" }); +} if (process.platform === "darwin") execFileSync("codesign", ["--remove-signature", executable]); await inject(executable, "NODE_SEA_BLOB", await readFile(blob), { sentinelFuse: "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2", From 499c7df6ac61515ca3bc660b49ffccb712c2fb4b Mon Sep 17 00:00:00 2001 From: Chijioke Ugwuanyi Date: Fri, 25 Sep 2026 18:58:48 +0200 Subject: [PATCH 3/4] docs: note Windows SDK requirement for portable builds --- docs/PORTABLE-RELEASES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/PORTABLE-RELEASES.md b/docs/PORTABLE-RELEASES.md index 540e24d..ab257a2 100644 --- a/docs/PORTABLE-RELEASES.md +++ b/docs/PORTABLE-RELEASES.md @@ -8,6 +8,10 @@ End users do not install Node.js, npm or project dependencies. Use Node 22 (CI pins 22.23.3) and pnpm 10.13.1: +Windows builds also require the Windows SDK's SignTool to remove the original +Node signature before embedding the verifier. The build finds installed SDK +versions automatically; set `SIGNTOOL_PATH` for a custom installation. + ```sh pnpm install --frozen-lockfile pnpm compile From 99939b855eedce5e21f9778dced90b0574448eec Mon Sep 17 00:00:00 2001 From: Chijioke Ugwuanyi Date: Tue, 29 Sep 2026 11:17:10 +0200 Subject: [PATCH 4/4] chore: align main version with v0.3.0 release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3cc31ae..b191ee8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@authenticmemory/gami", - "version": "0.2.0", + "version": "0.3.0", "description": "Independent command-line verifier for GAMI Proof Records.", "keywords": [ "archives",