diff --git a/.github/workflows/wai-catalogue.yml b/.github/workflows/wai-catalogue.yml index f7c5a56b8..4c09f89e4 100644 --- a/.github/workflows/wai-catalogue.yml +++ b/.github/workflows/wai-catalogue.yml @@ -33,6 +33,15 @@ on: # tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus. - 'wai/live-conformance/share/**' - 'wai/live-conformance/channels/**' + # tests/cli_key.rs signs a conformance record against this corpus and an upscale with this prior. + - 'wai/jwp-conformance/**' + - 'wai/live-conformance/upscale/**' + # tests/cli_toolchain.rs holds wai --version and the deprecations to these, and searches the text. + - 'wai/CHANGELOG.md' + - 'wai/VERSIONING.md' + - 'wai/DEPRECATIONS.md' + - 'wai/SPEC.md' + - 'wai/registry/**' - 'wai/REGISTRY.md' - 'wai/LICENSE' - 'wai/wai-quantum/**' @@ -58,6 +67,15 @@ on: # tests/cli_ops.rs builds the revenue share's --dry-run sample from this corpus. - 'wai/live-conformance/share/**' - 'wai/live-conformance/channels/**' + # tests/cli_key.rs signs a conformance record against this corpus and an upscale with this prior. + - 'wai/jwp-conformance/**' + - 'wai/live-conformance/upscale/**' + # tests/cli_toolchain.rs holds wai --version and the deprecations to these, and searches the text. + - 'wai/CHANGELOG.md' + - 'wai/VERSIONING.md' + - 'wai/DEPRECATIONS.md' + - 'wai/SPEC.md' + - 'wai/registry/**' - 'wai/REGISTRY.md' - 'wai/LICENSE' - 'wai/wai-quantum/**' @@ -132,7 +150,12 @@ jobs: # Create's (app), so it is held on wai app export, with the revenue share's # (revenue_share), so it is held on wai contract create|accept and wai statement create, and # with the opportunity report's (opportunity_report), so it is held on wai opportunity - # create and wai statement check. + # create and wai statement check. tests/cli_key.rs: wai key, its keystore, and --key on + # the operations that sign, first in the archive's build, then with the features whose + # operations read --key apart (the revenue share, the exact derivations) and with + # key_discovery, whose wai-keys reads what wai key exports. tests/cli_toolchain.rs: wai --version + # and the one version it names, wai docs search and wai registry show, the deprecation notice, + # and wai/DEPRECATIONS.md held to the catalogue. - name: The catalogue's rules, and the command line through it shell: bash working-directory: wai/wai-rs @@ -141,8 +164,10 @@ jobs: ran() { grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$1" || { echo "::error::no test ran in $1"; exit 1; }; } cargo test --locked --no-default-features --lib catalogue 2>&1 | tee "$RUNNER_TEMP/lib.log" ran "$RUNNER_TEMP/lib.log" - cargo test --locked --no-default-features --features quickstart --bin wai --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli.log" + cargo test --locked --no-default-features --features quickstart --bin wai --test cli_ops --test cli_key --test cli_toolchain 2>&1 | tee "$RUNNER_TEMP/cli.log" ran "$RUNNER_TEMP/cli.log" + cargo test --locked --no-default-features --features quickstart,key_discovery,revenue_share,exact_upscale,exact_tonemap --test cli_key 2>&1 | tee "$RUNNER_TEMP/cli-key.log" + ran "$RUNNER_TEMP/cli-key.log" cargo test --locked --no-default-features --features quickstart,generate,score,staged,video_int,audio_int,splat_int,exact_upscale,exact_tonemap --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-full.log" ran "$RUNNER_TEMP/cli-full.log" cargo test --locked --no-default-features --features quickstart,app --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-app.log" diff --git a/.github/workflows/wai-key-keychain.yml b/.github/workflows/wai-key-keychain.yml new file mode 100644 index 000000000..9f6a9fb06 --- /dev/null +++ b/.github/workflows/wai-key-keychain.yml @@ -0,0 +1,59 @@ +name: wai key · the real keychain + +# `wai key` keeps a secret in macOS's keychain through /usr/bin/security. The binary's other tests +# reach it through a stand-in, on Linux; this one runs the real tool, in a scratch keychain the test +# makes and removes (never the login keychain), and needs no dialog. +# +# The macOS runner is CI's bottleneck, so this runs on one thing alone: a pull request whose own diff +# changes wai/wai-rs/src/bin/wai_cli/dx/key.rs or this file. The forge's filter matches a push's +# delta, so a rebase can fire it; the gate below (on Linux) reads the pull request's own diff, and +# the macOS job does not start without it. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths: + - 'wai/wai-rs/src/bin/wai_cli/dx/key.rs' + - '.github/workflows/wai-key-keychain.yml' + +jobs: + relevant: + name: does this change touch it + runs-on: linux + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-key-keychain.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches wai key's keychain: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + keychain: + name: the real keychain · macos + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: macos + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + # launchd's PATH omits cargo on the macOS host runner. + - name: Put cargo on PATH + shell: bash + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: The one test + shell: bash + working-directory: wai/wai-rs + run: | + set -euo pipefail + nice -n 10 cargo test --locked --no-default-features --features quickstart --bin wai -- --exact dx::key::tests::the_real_keychain_keeps_reads_replaces_and_removes_a_secret 2>&1 | tee "$RUNNER_TEMP/keychain.log" + grep -qE '^test result: ok\. 1 passed; 0 failed' "$RUNNER_TEMP/keychain.log" || { echo "::error::the test did not run"; exit 1; } diff --git a/joule-code/joule-code-rs/crates/joule-moq-remote/Cargo.lock b/joule-code/joule-code-rs/crates/joule-moq-remote/Cargo.lock index 51322d2c8..fe922f00c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-remote/Cargo.lock +++ b/joule-code/joule-code-rs/crates/joule-moq-remote/Cargo.lock @@ -2302,6 +2302,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "wai-ops", + "windows-sys 0.61.2", ] [[package]] diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock index 2d982cd34..6dcde802b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock @@ -3742,6 +3742,7 @@ dependencies = [ "sha2 0.10.9", "wai-ops", "wai-quantum", + "windows-sys 0.61.2", ] [[package]] diff --git a/wai/DEPRECATIONS.md b/wai/DEPRECATIONS.md new file mode 100644 index 000000000..1e3218990 --- /dev/null +++ b/wai/DEPRECATIONS.md @@ -0,0 +1,10 @@ +# The WAI toolchain: deprecations + +Each item of the `wai` command line's public surface that is deprecated, the version that deprecated +it, its replacement, and the version that removed it. An item is listed here from the release that +deprecates it, and stays an alias for at least two minor releases and six months. What is public, +and how a deprecated item says so where it is used, is in [VERSIONING.md](VERSIONING.md). + +| Kind | Item | Deprecated in | Replacement | Removed in | +|---|---|---|---|---| +| flag | `--confirm` | 0.3.0 | `--yes` | | diff --git a/wai/VERSIONING.md b/wai/VERSIONING.md new file mode 100644 index 000000000..7529bc0c8 --- /dev/null +++ b/wai/VERSIONING.md @@ -0,0 +1,84 @@ +# The WAI toolchain: versions, stability and deprecations + +This page says what anyone building on the `wai` command line can rely on: which parts are public, +what a version number promises about each, and how a change is announced before it happens. It +covers the `wai` binary, the operation catalogue and the doors the catalogue names. WAI Quantum's +packages have their own page, [wai-quantum/VERSIONING.md](wai-quantum/VERSIONING.md). + +The deprecated items, each with the version that deprecated it and its replacement, are listed in +[DEPRECATIONS.md](DEPRECATIONS.md). + +## One version + +`wai`, the standard's text and the reference implementation are released together, under one +version: the `wai` crate's (`wai-rs/Cargo.toml`). The newest release heading of +[CHANGELOG.md](CHANGELOG.md) names it, the catalogue's export carries it, and a test holds the three +equal, so a version can be read from the binary and never has to be read from a page: + +```text +wai --version --json +``` + +| Member | What it says | +|---|---| +| `toolchain` | This binary's version | +| `catalogue` | The export's format, its operations, and the SHA-256 of `wai ops export`, which is [catalogue/ops.json](catalogue/ops.json) byte for byte | +| `envelope` | The version of the JSON envelope `--json` prints | +| `registry` | The registered capabilities, and the SHA-256 of [registry/capabilities.json](registry/capabilities.json) | +| `specification` | The release of the standard this binary implements, and the envelope formats it reads | +| `features` | The build features the catalogue names that this binary was built with, which decide the operations it holds | + +## What is public + +| Surface | What is public | What holds it in CI | +|---|---|---| +| The operation catalogue | Each operation's id, its command line and declared aliases, its flags and arguments, its effect, when it waits for confirmation, its stability and the schema of its `--json` `data`: [catalogue/ops.json](catalogue/ops.json), which `wai ops schema ` prints one entry of | `wai ops export --check`, and the catalogue's rules (`wai-catalogue.yml`). A removal shows in the export's diff; `wai breaking` does not read the catalogue | +| The agent rules | The exit codes, the envelope `envelope/1`, its `error.code` strings, `--json`, `--dry-run`, `--yes` and `--no-input` | `wai-rs/tests/cli_ops.rs` | +| The MCP door | Each tool's name, the operation of the catalogue it is, and its input schema | `wai-rs/tests/cli_mcp.rs` and the binary's tests, in `wai-conformance.yml`; `scripts/check-wai-ops-doors.py` | +| The project file | `wai.json`, as [schema/wai-json-1.schema.json](schema/wai-json-1.schema.json) states it | The reader's tests, which hold the two to each other | +| The C ABI, the Rust API of the library crates and the HTTP services' OpenAPI documents | `wai.h`, what the crates document, and each document's operations | `wai breaking`, category `api` (`wai-breaking.yml`) | +| The wire and the signed records | The registry's capabilities and wire identifiers, the conformance corpora, the signing domains and the published JSON Schemas | `wai breaking`, categories `wire` and `record`, and the corpora's generators and verifiers | + +What is not public: the text a command prints without `--json`, which is for people and may change +in any release; an operation marked `internal`, which is never published; and the keystore's own +files (`wai.key/1`), which only `wai key` reads and writes. + +An operation's `stability` says how far its entry is promised. A `stable` operation changes only +with a deprecation window. A `preview` operation is published and works, and its shape may still +change: a change to one is in the changelog, and the window below is not owed. + +## What a version number promises + +The toolchain follows [Semantic Versioning 2.0](https://semver.org). Every release so far is 0.y.z, +for which that promises nothing, so this page does: the first non-zero component is the major. + +- **A change of y** (0.4.0 to 0.5.0) may break a public surface. Every break is in the changelog + with what to do instead, and the deprecation rule below holds for a stable operation. +- **A change of z** adds or fixes, and breaks nothing a public surface promised. + +The wire format is the exception the standard states: until v1.0 freezes, it may change, and such a +change is recorded in the changelog. + +## Deprecation + +A renamed command, flag, route or tool stays as an alias for **at least two minor releases and six +months**, and says it is deprecated when used. Removing it after that is a break, in the changelog +with what to do instead. + +| Where it is used | How a deprecated item says so | +|---|---| +| A flag on the command line | It still works, and prints one line on standard error: `wai: deprecated: --confirm, use --yes instead` | +| An operation's id or spelling | Its catalogue entry's `deprecated` member (`since`, `use_instead`) | +| Help and the catalogue's export | `wai --help` and each operation's help name it, and `agent_flags` lists it with `deprecated` | +| The list | [DEPRECATIONS.md](DEPRECATIONS.md), from the release that deprecates it | + +A test holds [DEPRECATIONS.md](DEPRECATIONS.md) to the catalogue: every flag and operation the +catalogue marks deprecated is listed there, and every item listed there is marked in the catalogue +or has its removal named. + +## How a break is acknowledged + +`wai breaking --against ` reports what the working tree breaks, by category, and exits 1 on +a break the changelog does not acknowledge. A change acknowledges one with a `**Breaking:**` line +naming the finding's id in backticks, in its entry under [changes/](changes/README.md). CI runs it on +every pull request that touches WAI. diff --git a/wai/catalogue/ops.json b/wai/catalogue/ops.json index f70d733c8..0d2225687 100644 --- a/wai/catalogue/ops.json +++ b/wai/catalogue/ops.json @@ -752,7 +752,7 @@ " text codecs: any file", "wai encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|software] [--library ]", " [--device ] [--hardware yes|no] [--kbps ] [--size WxH] [--idr-every |", - " --idr-plan ] [--es ] [--record --key ] [--meter [--rounds ]]", + " --idr-plan ] [--es ] [--record --key ] [--meter [--rounds ]]", " (a build with `platform_encode`)" ], "placement": "local", @@ -1091,7 +1091,7 @@ "aliases": [], "usage": [ "wai upscale run --prior [--derivation sr2|sr4] -o ", - " [--record --envelope --key ] [--issued-at ] [--json]", + " [--record --envelope --key ] [--issued-at ] [--json]", " an 8-bit RGB PNG; --record signs what exact-upscale §7 recomputes from --envelope and the prior" ], "placement": "local", @@ -1158,7 +1158,7 @@ }, "key": { "type": "string", - "description": "The record's signing seed: 64 hexadecimal digits, or a file holding them.", + "description": "The record's signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them.", "x-cli": { "flag": "--key" } @@ -1293,7 +1293,7 @@ "aliases": [], "usage": [ "wai tonemap run --headrooms --out [--input-class ]", - " [--record --key ] [--issued-at ] [--json]", + " [--record --key ] [--issued-at ] [--json]", " a WAI2 of a PNG base and its gain map; a headroom is in 1/10000 of a stop; each rendition is RGB16" ], "placement": "local", @@ -1351,7 +1351,7 @@ }, "key": { "type": "string", - "description": "The record's signing seed: 64 hexadecimal digits, or a file holding them.", + "description": "The record's signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them.", "x-cli": { "flag": "--key" } @@ -2977,7 +2977,7 @@ "cli": "wai http seal-playlist", "aliases": [], "usage": [ - "wai http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]" + "wai http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]" ], "placement": "local", "effect": "writes-project", @@ -3004,8 +3004,7 @@ }, "key": { "type": "string", - "format": "path", - "description": "The signing seed.", + "description": "The signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them.", "x-cli": { "flag": "--key" } @@ -3650,7 +3649,7 @@ "cli": "wai enrolment create", "aliases": [], "usage": [ - "wai enrolment create --role --key [--label L] [--from µs] [--until µs] [--out F]", + "wai enrolment create --role --key [--label L] [--from µs] [--until µs] [--out F]", " [--target URL] [--yes] a node's enrolment (WFE1), signed with the organisation key, written to F", " (.wfe without --target) and, with --target, given to the fleet service there; changing the service", " or replacing a file waits for --yes (--confirm, deprecated, is the same)" @@ -3686,8 +3685,7 @@ }, "key": { "type": "string", - "format": "path", - "description": "The organisation key's file: 32 bytes, or 64 hexadecimal digits.", + "description": "The organisation key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.", "x-cli": { "flag": "--key" } @@ -4115,8 +4113,7 @@ }, "key": { "type": "string", - "format": "path", - "description": "The operator's key file: 32 bytes, or 64 hexadecimal digits.", + "description": "The operator's key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.", "x-cli": { "flag": "--key" } @@ -4867,18 +4864,20 @@ { "id": "registry.show", "summary": "One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it", - "cli": null, + "cli": "wai registry show", "aliases": [], "usage": [ - "wai (wai mcp: registry_show)" + "wai registry show [--spec-root ] one capability's registry entry, whether this build decodes it,", + " its role, and the specification lines that name it, where the specification's text is here (--spec-root,", + " $WAI_SPEC_ROOT, or the tree this binary was built from)" ], "placement": "local", "effect": "reads", "dry_run": false, - "confirm": null, + "confirm": "never", "route": null, "doors": { - "cli": false, + "cli": true, "mcp": true, "http": false }, @@ -4889,7 +4888,18 @@ "properties": { "capability": { "type": "string", - "description": "A capability string, e.g. wai.image.avif." + "description": "A capability string, e.g. wai.image.avif.", + "x-cli": { + "positional": true + } + }, + "spec_root": { + "type": "string", + "format": "path", + "description": "The wai/ directory whose text is read, on the command line (wai mcp has a --spec-root of its own).", + "x-cli": { + "flag": "--spec-root" + } } }, "required": [ @@ -4904,9 +4914,13 @@ "since": "0.3.0", "deprecated": null, "receipt": false, - "needs": null, + "needs": { + "all": [ + "mcp" + ] + }, "names": { - "cli": null, + "cli": "wai registry show", "mcp": "registry_show", "c": "wai_registry_show", "rust": "wai::registry::show", @@ -4916,18 +4930,20 @@ { "id": "docs.search", "summary": "Lines of the specification and its extensions holding every word of a query, each with its file, line and section", - "cli": null, + "cli": "wai docs search", "aliases": [], "usage": [ - "wai (wai mcp: docs_search)" + "wai docs search ... [--limit N] [--spec-root ] the lines of the specification and its extensions that hold", + " every word, with each one's file, line and section, offline; the text is --spec-root's, $WAI_SPEC_ROOT's, or the", + " tree this binary was built from" ], "placement": "local", "effect": "reads", "dry_run": false, - "confirm": null, + "confirm": "never", "route": null, "doors": { - "cli": false, + "cli": true, "mcp": true, "http": false }, @@ -4938,11 +4954,25 @@ "properties": { "query": { "type": "string", - "description": "Words that must all appear on a line." + "description": "Words that must all appear on a line.", + "x-cli": { + "positional": true + } }, "limit": { "type": "integer", - "description": "At most this many hits (20)." + "description": "At most this many hits (20).", + "x-cli": { + "flag": "--limit" + } + }, + "spec_root": { + "type": "string", + "format": "path", + "description": "The wai/ directory whose text is searched, on the command line (wai mcp has a --spec-root of its own).", + "x-cli": { + "flag": "--spec-root" + } } }, "required": [ @@ -4957,9 +4987,13 @@ "since": "0.3.0", "deprecated": null, "receipt": false, - "needs": null, + "needs": { + "all": [ + "mcp" + ] + }, "names": { - "cli": null, + "cli": "wai docs search", "mcp": "docs_search", "c": "wai_docs_search", "rust": "wai::docs::search", @@ -7211,7 +7245,7 @@ "cli": "wai contract create", "aliases": [], "usage": [ - "wai contract create --key [--out F] [--yes] the share contract (WRC1) of the terms, signed", + "wai contract create --key [--out F] [--yes] the share contract (WRC1) of the terms, signed", " with the operator key, every countersignature still to come, written to F (.wrc); each owner adds", " its own with `wai contract accept`; replacing a file waits for --yes (--confirm, deprecated, is the same)" ], @@ -7240,8 +7274,7 @@ }, "key": { "type": "string", - "format": "path", - "description": "The operator key's file: 32 bytes, or 64 hexadecimal digits.", + "description": "The operator key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.", "x-cli": { "flag": "--key" } @@ -7297,7 +7330,7 @@ "cli": "wai contract accept", "aliases": [], "usage": [ - "wai contract accept --key [--out F] [--yes] the owner's countersignature added to the contract,", + "wai contract accept --key [--out F] [--yes] the owner's countersignature added to the contract,", " written to F (the contract's own file when F is not given); replacing a file waits for --yes (--confirm,", " deprecated, is the same)" ], @@ -7326,8 +7359,7 @@ }, "key": { "type": "string", - "format": "path", - "description": "The owner key's file: 32 bytes, or 64 hexadecimal digits.", + "description": "The owner key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.", "x-cli": { "flag": "--key" } @@ -7383,7 +7415,7 @@ "cli": "wai statement create", "aliases": [], "usage": [ - "wai statement create --party --start µs --end µs --key [--out F] [--yes] the statement", + "wai statement create --party --start µs --end µs --key [--out F] [--yes] the statement", " (WRS1) of the party for [start, end), computed from a verification document without its statement (the", " contract, other contracts, runs and ledger documents), signed with the operator key and written with its", " document to F (.wai-share.json) for `wai statement verify`; replacing a file waits for --yes" @@ -7434,8 +7466,7 @@ }, "key": { "type": "string", - "format": "path", - "description": "The operator key's file: 32 bytes, or 64 hexadecimal digits.", + "description": "The operator key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.", "x-cli": { "flag": "--key" } @@ -7558,7 +7589,7 @@ "cli": "wai opportunity create", "aliases": [], "usage": [ - "wai opportunity create --party --start µs --end µs --key [--out F] [--yes] the report", + "wai opportunity create --party --start µs --end µs --key [--out F] [--yes] the report", " (WOR1) of the party for [start, end), computed from a verification document without its report (the", " contract, other contracts, runs and rooms), signed with the issuer key and written with its document to F", " (.wai-opportunity.json) for `wai opportunity verify`; replacing a file waits for --yes" @@ -7609,8 +7640,7 @@ }, "key": { "type": "string", - "format": "path", - "description": "The issuer key's file: 32 bytes, or 64 hexadecimal digits.", + "description": "The issuer key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.", "x-cli": { "flag": "--key" } @@ -7727,7 +7757,7 @@ "cli": "wai statement check", "aliases": [], "usage": [ - "wai statement check [--statement ] [--floor ppm] [--ceiling ppm] --key [--out F] [--yes]", + "wai statement check [--statement ] [--floor ppm] [--ceiling ppm] --key [--out F] [--yes]", " the check (WCK1) of a reported statement (JSON, or CSV for a file named .csv) beside a report's verification", " document and a signed statement's, at the bounds given (500000 and 2000000 when not), signed with the issuer", " key and written with its document to F (.wai-check.json); replacing a file waits for --yes" @@ -7787,8 +7817,7 @@ }, "key": { "type": "string", - "format": "path", - "description": "The issuer key's file: 32 bytes, or 64 hexadecimal digits.", + "description": "The issuer key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits.", "x-cli": { "flag": "--key" } @@ -7835,6 +7864,610 @@ "sdk": "wai.statement.check" } }, + { + "id": "key.create", + "summary": "A new Ed25519 signing key kept under a name, for one role, its secret in macOS's keychain or in a seed file; every signing operation's --key takes the name", + "cli": "wai key create", + "aliases": [], + "usage": [ + "wai key create --for [--store keychain|file] [--keystore DIR] a new signing key named ", + " (1 to 48 of a-z, 0-9, - and _), for one role: origin, signer, room, audit, ledger, release, remote-host,", + " quantum or dev. Its secret is kept in the login keychain (--store keychain, the default on macOS) or in a", + " file of the keystore (--store file, the default elsewhere: mode 0600 on Unix; on Windows an access list for", + " this account alone, inheritance off), and is never printed; a seed file another account may read is refused.", + " The keystore is DIR, $WAI_KEYSTORE, or ~/.config/wai/keys (%APPDATA%\\wai\\keys on Windows). --key signs", + " with it" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The key's name: 1 to 48 of a-z, 0-9, - and _, beginning with a letter or a digit.", + "x-cli": { + "positional": true + } + }, + "role": { + "type": "string", + "description": "The key's role: origin, signer, room, audit, ledger, release, remote-host, quantum or dev.", + "x-cli": { + "flag": "--for" + } + }, + "store": { + "type": "string", + "description": "Where the secret is kept: keychain (the default on macOS) or file (the default elsewhere: mode 0600 on Unix; on Windows, an access list for this account alone).", + "x-cli": { + "flag": "--store" + } + }, + "keystore": { + "type": "string", + "format": "path", + "description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).", + "x-cli": { + "flag": "--keystore" + } + } + }, + "required": [ + "name", + "role" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "name", + "role", + "id", + "key", + "store", + "keystore" + ], + "description": "The key kept: its name, role and key id, its public key in hexadecimal and its did:key (null in a dry run, which makes no key), where its secret is kept (store: keychain or file), and the keystore" + }, + "stream": null, + "since": "0.5.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "quickstart" + ] + }, + "names": { + "cli": "wai key create", + "mcp": null, + "c": "wai_key_create", + "rust": "wai::key::create", + "sdk": "wai.key.create" + } + }, + { + "id": "key.list", + "summary": "Every key of the keystore: its name, role, key id, public key, status and where its secret is kept", + "cli": "wai key list", + "aliases": [], + "usage": [ + "wai key list [--for ] [--keystore DIR] every key of the keystore, its newest version, and where its secret is kept" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "role": { + "type": "string", + "description": "Only the keys of this role.", + "x-cli": { + "flag": "--for" + } + }, + "keystore": { + "type": "string", + "format": "path", + "description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).", + "x-cli": { + "flag": "--keystore" + } + } + }, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "keystore", + "keys" + ], + "description": "Each key of the keystore: its name, role, newest key id, public key, did:key and status, and where its secret is kept" + }, + "stream": null, + "since": "0.5.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "quickstart" + ] + }, + "names": { + "cli": "wai key list", + "mcp": null, + "c": "wai_key_list", + "rust": "wai::key::list", + "sdk": "wai.key.list" + } + }, + { + "id": "key.show", + "summary": "One key: its public key and did:key, its role, every version with its status as a key log states it, and the version --key signs with", + "cli": "wai key show", + "aliases": [], + "usage": [ + "wai key show [--keystore DIR] one key's versions, each active, retired (until) or revoked (compromised since), as", + " jwp-receipts §7.2's key log states them, and the one --key signs with" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The key's name.", + "x-cli": { + "positional": true + } + }, + "keystore": { + "type": "string", + "format": "path", + "description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).", + "x-cli": { + "flag": "--keystore" + } + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "keystore", + "name", + "role", + "signs_with", + "versions" + ], + "description": "The key's versions, oldest first, each with its key id, public key, did:key, status (active, retired, revoked) and times as a key log states them (jwp-receipts §7.2), and where its secret is kept (store; null once destroyed); and the key id --key signs with" + }, + "stream": null, + "since": "0.5.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "quickstart" + ] + }, + "names": { + "cli": "wai key show", + "mcp": null, + "c": "wai_key_show", + "rust": "wai::key::show", + "sdk": "wai.key.show" + } + }, + { + "id": "key.rotate", + "summary": "A key's successor made under the same name and a new key id, the old version retired now and its secret destroyed", + "cli": "wai key rotate", + "aliases": [], + "usage": [ + "wai key rotate [--store keychain|file] [--keystore DIR] [--yes] a successor under a new key id (.),", + " which --key signs with from now; the old version is retired now and its secret destroyed (jwp-receipts", + " §7.6), so it waits for --yes" + ], + "placement": "local", + "effect": "destructive", + "dry_run": true, + "confirm": "always", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The key's name.", + "x-cli": { + "positional": true + } + }, + "store": { + "type": "string", + "description": "Where the successor's secret is kept (where the old one's was).", + "x-cli": { + "flag": "--store" + } + }, + "keystore": { + "type": "string", + "format": "path", + "description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).", + "x-cli": { + "flag": "--keystore" + } + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "name", + "previous", + "id", + "key", + "store" + ], + "description": "The version retired (previous, with until) and the successor made: its key id, public key (null in a dry run) and where its secret is kept (store)" + }, + "stream": null, + "since": "0.5.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "quickstart" + ] + }, + "names": { + "cli": "wai key rotate", + "mcp": null, + "c": "wai_key_rotate", + "rust": "wai::key::rotate", + "sdk": "wai.key.rotate" + } + }, + { + "id": "key.revoke", + "summary": "A key's version revoked as compromised from a time, its secret destroyed, so --key signs nothing with it", + "cli": "wai key revoke", + "aliases": [], + "usage": [ + "wai key revoke [--id ] [--compromised-since ] [--keystore DIR] [--yes] the key's newest", + " version (or --id's) revoked, compromised since the time given (now; YYYY-MM-DDTHH:MM:SSZ), and its secret", + " destroyed (jwp-receipts §7.6); it waits for --yes" + ], + "placement": "local", + "effect": "destructive", + "dry_run": true, + "confirm": "always", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The key's name.", + "x-cli": { + "positional": true + } + }, + "id": { + "type": "string", + "description": "The version to revoke, by its key id (the newest).", + "x-cli": { + "flag": "--id" + } + }, + "compromised_since": { + "type": "string", + "description": "From when another party may have held it: a date-time in UTC, YYYY-MM-DDTHH:MM:SSZ (now).", + "x-cli": { + "flag": "--compromised-since" + } + }, + "keystore": { + "type": "string", + "format": "path", + "description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).", + "x-cli": { + "flag": "--keystore" + } + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "name", + "id", + "compromised_since" + ], + "description": "The version revoked, the time from which it is held compromised, and whether its secret was destroyed" + }, + "stream": null, + "since": "0.5.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "quickstart" + ] + }, + "names": { + "cli": "wai key revoke", + "mcp": null, + "c": "wai_key_revoke", + "rust": "wai::key::revoke", + "sdk": "wai.key.revoke" + } + }, + { + "id": "key.import", + "summary": "A key file's seed kept under a name, as wai key create keeps a new one; a published test seed is refused unless named one", + "cli": "wai key import", + "aliases": [], + "usage": [ + "wai key import --for --from [--store keychain|file] [--published-test-seed] [--keystore DIR]", + " the seed of a key file (64 hexadecimal digits or the 32 bytes, as every --key file holds) kept as wai key create", + " keeps one; a seed of one byte repeated 32 times, the form of every seed WAI publishes, is refused unless", + " --published-test-seed names it a test key" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The key's name.", + "x-cli": { + "positional": true + } + }, + "role": { + "type": "string", + "description": "The key's role: origin, signer, room, audit, ledger, release, remote-host, quantum or dev.", + "x-cli": { + "flag": "--for" + } + }, + "from": { + "type": "string", + "format": "path", + "description": "The key file: 64 hexadecimal digits, or the 32 bytes.", + "x-cli": { + "flag": "--from" + } + }, + "store": { + "type": "string", + "description": "Where the secret is kept: keychain (the default on macOS) or file.", + "x-cli": { + "flag": "--store" + } + }, + "published_test_seed": { + "type": "boolean", + "description": "Keep a published test seed, marked as one.", + "x-cli": { + "flag": "--published-test-seed" + } + }, + "keystore": { + "type": "string", + "format": "path", + "description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).", + "x-cli": { + "flag": "--keystore" + } + } + }, + "required": [ + "name", + "role", + "from" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "name", + "role", + "id", + "key", + "store", + "keystore" + ], + "description": "The key kept: its name, role and key id, its public key in hexadecimal and its did:key (null in a dry run, which makes no key), where its secret is kept (store: keychain or file), and the keystore" + }, + "stream": null, + "since": "0.5.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "quickstart" + ] + }, + "names": { + "cli": "wai key import", + "mcp": null, + "c": "wai_key_import", + "rust": "wai::key::import", + "sdk": "wai.key.import" + } + }, + { + "id": "key.export", + "summary": "A key's public half, never its secret: in hexadecimal, as a did:key, as key-log entries, or as the line wai-keys lists it by", + "cli": "wai key export", + "aliases": [], + "usage": [ + "wai key export [--format hex|did-key|entry|wai-keys] [--may-sign ,...] [--out F] [--keystore DIR]", + " the public half alone: hex (the default), did-key, entry (each version as a key-log entry, jwp-receipts §7.2) or", + " wai-keys (the newest as wai-keys init --key and append --add take it); entry and wai-keys name the authorities", + " an origin gives it (--may-sign); to stdout, or to F (replacing a file waits for --yes)" + ], + "placement": "local", + "effect": "writes-project", + "dry_run": true, + "confirm": "when-replacing-a-file", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The key's name.", + "x-cli": { + "positional": true + } + }, + "format": { + "type": "string", + "description": "hex (the default), did-key, entry or wai-keys.", + "x-cli": { + "flag": "--format" + } + }, + "may_sign": { + "type": "string", + "description": "With entry and wai-keys: the authorities an origin gives the key (jwp-receipts §7.5), comma-separated.", + "x-cli": { + "flag": "--may-sign" + } + }, + "out": { + "type": "string", + "format": "path", + "description": "Write it to this file.", + "x-cli": { + "flag": "--out" + } + }, + "keystore": { + "type": "string", + "format": "path", + "description": "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys).", + "x-cli": { + "flag": "--keystore" + } + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "name", + "id", + "format", + "text" + ], + "description": "The key's public half in the format asked for (the text, and for entry the key-log entries), and the file it went to" + }, + "stream": null, + "since": "0.5.0", + "deprecated": null, + "receipt": false, + "needs": { + "all": [ + "quickstart" + ] + }, + "names": { + "cli": "wai key export", + "mcp": null, + "c": "wai_key_export", + "rust": "wai::key::export", + "sdk": "wai.key.export" + } + }, { "id": "ops.list", "summary": "Every operation of this catalogue: its id, command line, effect and whether this build holds it", @@ -8100,6 +8733,61 @@ "rust": "wai::ops::export", "sdk": "wai.ops.export" } + }, + { + "id": "toolchain.show", + "summary": "This wai's version, the digests of its catalogue and its registry, the release of the standard it implements, and the build features it holds", + "cli": "wai --version", + "aliases": [ + "wai version" + ], + "usage": [ + "wai --version [--json] this binary's version, the SHA-256 of its catalogue (wai/catalogue/ops.json) and of its", + " registry (wai/registry/capabilities.json), the release of the standard it implements with the envelope formats", + " it reads, and the build features it holds (also wai version; wai/VERSIONING.md says what each promises)" + ], + "placement": "local", + "effect": "reads", + "dry_run": false, + "confirm": "never", + "route": null, + "doors": { + "cli": true, + "mcp": false, + "http": false + }, + "toolset": "core", + "stability": "preview", + "input": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + }, + "output": { + "type": "object", + "required": [ + "toolchain", + "catalogue", + "envelope", + "registry", + "specification", + "features" + ], + "description": "This binary's version, its catalogue's format, operations and SHA-256, the JSON envelope's version, its registry's capabilities and SHA-256, the release of the standard it implements with the envelope formats it reads, and the build features it holds" + }, + "stream": null, + "since": "0.5.0", + "deprecated": null, + "receipt": false, + "needs": null, + "names": { + "cli": "wai toolchain show", + "mcp": null, + "c": "wai_toolchain_show", + "rust": "wai::toolchain::show", + "sdk": "wai.toolchain.show" + } } ] } diff --git a/wai/changes/2026-11-05-wai-conform-command-line-on-windows.md b/wai/changes/2026-11-05-wai-conform-command-line-on-windows.md new file mode 100644 index 000000000..6ddfb21f1 --- /dev/null +++ b/wai/changes/2026-11-05-wai-conform-command-line-on-windows.md @@ -0,0 +1,7 @@ +### `wai conform` runs a command line on Windows as it is written + +On Windows, `--impl`'s command line went to `cmd` with its quotes escaped, so a placeholder's quoted +path reached the implementation with the quotes as part of the path, and every case of a command +adapter failed ("no verdict"). `cmd` now gets the line as it is written, a quoted program first +included. A test on Windows holds both forms, and `wai conform --key ` ran there against the +JWP corpus. diff --git a/wai/changes/2026-11-05-wai-key-signing-keys-one-keystore.md b/wai/changes/2026-11-05-wai-key-signing-keys-one-keystore.md new file mode 100644 index 000000000..bb667bfb7 --- /dev/null +++ b/wai/changes/2026-11-05-wai-key-signing-keys-one-keystore.md @@ -0,0 +1,50 @@ +### `wai key`: a developer's signing keys in one keystore, and `--key ` on the operations that sign + +- **Seven operations**, in the catalogue and in `wai/catalogue/ops.json`: `key.create`, `key.list`, + `key.show`, `key.rotate`, `key.revoke`, `key.import` and `key.export` (`wai key + create|list|show|rotate|revoke|import|export`), each with `--json`, and with `--dry-run` where + it changes something; `--yes` is never implied. They run on this machine, in a build with + `quickstart`, which the release archive's `wai` is. `wai mcp` does not serve them. +- **Where keys live.** One keystore, a directory: `--keystore DIR`, `$WAI_KEYSTORE`, + `$XDG_CONFIG_HOME/wai/keys` or `~/.config/wai/keys` (`%APPDATA%\wai\keys` on Windows), made with + mode 0700. Each key is a record, `.json` (format `wai.key/1`), holding public halves alone. + Each secret is kept where `--store` says: `keychain`, the default on macOS, in the login + keychain through `/usr/bin/security`, the secret on the tool's standard input and never on a + command line; or `file`, the default elsewhere, `.seed`. No operation prints a secret. + `--store keychain` on another system is refused (`keychain-unavailable`): the keychain of macOS + is the only one `wai` uses, and on Linux and Windows the secret is in the file. +- **What protects a seed file.** On Unix, its mode: it is made with 0600, the keystore with 0700. On + Windows, an access list: the seed, the record and a keystore `wai` makes each have this account as + owner and as their one entry, with inheritance off, so a keystore in `C:\Users\Public` gets what + one in `%APPDATA%` gets. SYSTEM and Administrators are not listed: nothing here needs them. Either + way the call that makes the file restricts it, before the secret is written. A seed file another + account may read is refused, exit 3, `key-file-exposed`, naming the file and the command that fixes + it: on Unix any mode bit for group or other, and on Windows any entry granting another account + anything, read by SID, or another account as its owner. The seed is not encrypted on either system. +- **The keychain path runs against the real tool.** A test on macOS drives `/usr/bin/security` in a + scratch keychain it makes and removes in every case, never the login keychain: an item kept, read + back, replaced through `-U` and removed, no secret on any process argument, and the search list + and the default keychain as they were. `wai-key-keychain.yml` runs that test alone, on a pull + request whose own diff changes `dx/key.rs` or the workflow. +- **WAI's own key rules** (extensions/jwp-receipts.md §7). A key's first version has its name as + its key id, and each rotation adds `.`. A version's status is the key log's: `active`, + `retired` with `until`, or `revoked` with `compromised_since`. `wai key rotate` makes the + successor under a new id and retires the old version at that moment (§7.6); `wai key revoke` + revokes the newest version or `--id`'s, from `--compromised-since` or now, and its time never + changes after. Each destroys the secret of the version it ends, so each waits for `--yes`. No two + keys of a keystore hold one public key (`key-duplicate`). +- **A published seed.** `wai key import` refuses a seed of one byte repeated 32 times, the form of + every seed WAI publishes (§7.8), unless `--published-test-seed` names it a test key, and the + record says so. +- **Into a key log.** `wai key export` gives the public half alone: `hex`, `did-key`, `entry` (each + version as a key-log entry, with the authorities `--may-sign` names) or `wai-keys` (the newest, + as `wai-keys init --key` and `append --add` take it). `wai-keys` itself is unchanged, and still + makes no key. A test builds a log from what `wai key` exports and verifies it. +- **How other commands find a key.** `--key ` signs with the key's newest version on `wai + enrolment create`, `wai channel import`, `wai contract create|accept`, `wai statement + create|check`, `wai opportunity create`, `wai upscale run`, `wai tonemap run`, `wai encode avc|hevc`, + `wai http seal-playlist` and `wai conform`. A path that exists is still read as a key file first, + so a command that ran before runs the same. A revoked key signs nothing (exit 2, `key-revoked`). +- **Not folded.** `wai package build` hands `--key` to `wai-pack`, which reads a file. `wai-keys`, + `wai-room keygen`, `wai-remote-host key`, `wai-key-proxy keygen`, `wai-audit key`, `wai-ledger + key` and `wai-quantum key` keep their own keys and verbs, and `wai keylog` is not built. diff --git a/wai/changes/2026-11-05-wai-version-docs-registry-versioning.md b/wai/changes/2026-11-05-wai-version-docs-registry-versioning.md new file mode 100644 index 000000000..5ddf63734 --- /dev/null +++ b/wai/changes/2026-11-05-wai-version-docs-registry-versioning.md @@ -0,0 +1,23 @@ +### `wai --version`, `wai docs search` and `wai registry show`, and what a version of the toolchain promises + +- **`wai --version`** (`toolchain.show`, also `wai version`), and with `--json` its envelope: this + binary's version; the catalogue's format, operations and the SHA-256 of `wai/catalogue/ops.json`; + the envelope's version; the registry's capabilities and the SHA-256 of + `wai/registry/capabilities.json`; the release of the standard it implements with the envelope + formats it reads; and the build features the catalogue names that it holds. A test holds the + crate's version, the export's and the newest release of [CHANGELOG.md](CHANGELOG.md) equal. +- **`wai docs search ...`** and **`wai registry show `**: `docs.search` and + `registry.show` have a command line now, each the same tool `wai mcp` serves, and a test holds + each door's answer to the other's. The specification's text is `--spec-root`'s, `$WAI_SPEC_ROOT`'s, + or the tree the binary was built from; without it `wai docs search` exits 5 (`spec-not-found`), and + `wai registry show` answers from the registry and says the text is not here. A capability the + registry does not hold exits 5 (`capability-not-found`). Both now name the feature `mcp`, which + the release archive's `wai` holds. +- **[VERSIONING.md](VERSIONING.md)** says what is public, what holds each surface in CI, what a + version number promises and how a change is announced, and states what the catalogue's check does + not catch: `wai breaking` does not read the catalogue, so an operation removed from it shows in + the export's diff and fails nothing. **[DEPRECATIONS.md](DEPRECATIONS.md)** lists what is + deprecated, and a test holds it to the catalogue both ways. +- **A deprecated flag says so when used.** `--confirm` still does what `--yes` does, and now prints + one line on standard error, `wai: deprecated: --confirm, use --yes instead`. Standard output, the + envelope and the exit code are as they were. diff --git a/wai/tools/ops-gpu-bench/Cargo.lock b/wai/tools/ops-gpu-bench/Cargo.lock index a7ef28d52..7b1696874 100644 --- a/wai/tools/ops-gpu-bench/Cargo.lock +++ b/wai/tools/ops-gpu-bench/Cargo.lock @@ -1034,6 +1034,7 @@ dependencies = [ "sha2", "wai-ops", "wai-ops-gpu", + "windows-sys", ] [[package]] diff --git a/wai/wai-audit/Cargo.lock b/wai/wai-audit/Cargo.lock index dcd68525a..0e72120e5 100644 --- a/wai/wai-audit/Cargo.lock +++ b/wai/wai-audit/Cargo.lock @@ -982,6 +982,7 @@ dependencies = [ "sha2", "wai-ops", "wai-quantum", + "windows-sys", ] [[package]] diff --git a/wai/wai-fleet/Cargo.lock b/wai/wai-fleet/Cargo.lock index bb63fc42f..8bc1512fd 100644 --- a/wai/wai-fleet/Cargo.lock +++ b/wai/wai-fleet/Cargo.lock @@ -2473,6 +2473,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "wai-ops", + "windows-sys 0.61.2", ] [[package]] diff --git a/wai/wai-key-proxy/Cargo.lock b/wai/wai-key-proxy/Cargo.lock index 08ef6ce25..27463703e 100644 --- a/wai/wai-key-proxy/Cargo.lock +++ b/wai/wai-key-proxy/Cargo.lock @@ -964,6 +964,7 @@ dependencies = [ "serde_json", "sha2", "wai-quantum", + "windows-sys", ] [[package]] diff --git a/wai/wai-ledger/Cargo.lock b/wai/wai-ledger/Cargo.lock index c1435c57f..4e3d8585b 100644 --- a/wai/wai-ledger/Cargo.lock +++ b/wai/wai-ledger/Cargo.lock @@ -986,6 +986,7 @@ dependencies = [ "sha2", "wai-ops", "wai-quantum", + "windows-sys", ] [[package]] diff --git a/wai/wai-live-web/Cargo.lock b/wai/wai-live-web/Cargo.lock index 7ba744524..cdefe5c20 100644 --- a/wai/wai-live-web/Cargo.lock +++ b/wai/wai-live-web/Cargo.lock @@ -780,6 +780,7 @@ dependencies = [ "serde_json", "sha2", "wai-ops", + "windows-sys", ] [[package]] diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index b610823d8..c036172b3 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -1227,6 +1227,7 @@ dependencies = [ "sha2", "wai-ops", "wai-quantum", + "windows-sys", ] [[package]] diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index 4bcfc2832..d2fde6854 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -595,6 +595,7 @@ dependencies = [ "serde_json", "sha2", "wai-quantum", + "windows-sys", ] [[package]] diff --git a/wai/wai-relay/Cargo.lock b/wai/wai-relay/Cargo.lock index 7ee56c8f6..ff7c59174 100644 --- a/wai/wai-relay/Cargo.lock +++ b/wai/wai-relay/Cargo.lock @@ -1096,6 +1096,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "windows-sys", ] [[package]] diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index ca778ae8a..3d0f64c09 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4735,6 +4735,7 @@ dependencies = [ "wai-ops", "wai-ops-gpu", "wai-quantum", + "windows-sys 0.61.2", "xz2", "zstd", "zstd-safe", diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 87a945c99..3550795c5 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -129,6 +129,12 @@ wai-ops = { path = "../wai-ops", optional = true } # by default, so a default build has no GPU interface in it. wai-ops-gpu = { path = "../wai-ops-gpu", optional = true } +# Windows' access lists, for a secret's file (src/bin/wai_cli/win_acl.rs): made for this account +# alone, and refused when it lets another in, by SID. Declarations only; every lockfile that holds +# this crate already held this version. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] } + [features] default = ["codecs", "identify"] # The zeroth-condition codec wrappers (image/audio/video/text) + the C @@ -1680,6 +1686,20 @@ name = "cli_fleet" path = "tests/cli_fleet.rs" required-features = ["quickstart"] +# `wai key`: a developer's signing keys in one keystore, and `--key ` on the operations +# that sign. Its test that wai-keys reads what it exports runs with `key_discovery` too. +[[test]] +name = "cli_key" +path = "tests/cli_key.rs" +required-features = ["quickstart"] + +# `wai --version`, the command line's doors to `docs.search` and `registry.show`, and the toolchain's +# VERSIONING.md and DEPRECATIONS.md held to the catalogue. +[[test]] +name = "cli_toolchain" +path = "tests/cli_toolchain.rs" +required-features = ["quickstart"] + [[test]] name = "jpegai_decode_symbols" path = "tests/jpegai_decode_symbols.rs" diff --git a/wai/wai-rs/fuzz/Cargo.lock b/wai/wai-rs/fuzz/Cargo.lock index f74d17115..d78eb8365 100644 --- a/wai/wai-rs/fuzz/Cargo.lock +++ b/wai/wai-rs/fuzz/Cargo.lock @@ -1623,6 +1623,7 @@ dependencies = [ "serde_json", "sha2", "wai-ops", + "windows-sys", "xz2", "zstd", ] diff --git a/wai/wai-rs/src/bin/wai_cli/derive.rs b/wai/wai-rs/src/bin/wai_cli/derive.rs index adb66980b..2e4438b44 100644 --- a/wai/wai-rs/src/bin/wai_cli/derive.rs +++ b/wai/wai-rs/src/bin/wai_cli/derive.rs @@ -41,14 +41,10 @@ fn path_of<'a>(args: &'a [String], flags: &[&str]) -> Option<&'a str> { None } -/// `--key`: 64 lowercase hexadecimal digits, or a file holding them. +/// `--key`: a key's name in the keystore, 64 hexadecimal digits, or a file holding them. fn signing_key(args: &[String]) -> Result { let s = opt(args, "--key").ok_or("--record needs --key")?; - let text = if s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()) { s.to_string() } else { String::from_utf8_lossy(&read(s)?).trim().to_string() }; - let seed: Vec = (text.len() == 64 && text.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))) - .then(|| (0..32).map(|i| u8::from_str_radix(&text[2 * i..2 * i + 2], 16).expect("hexadecimal")).collect()) - .ok_or("--key is 64 lowercase hexadecimal digits, or a file holding them")?; - Ok(SigningKey::from_bytes(&seed.try_into().expect("32 bytes"))) + crate::dx::key::signing_key(s, true).map_err(|e| e.message) } fn issued_at(args: &[String]) -> String { diff --git a/wai/wai-rs/src/bin/wai_cli/door.rs b/wai/wai-rs/src/bin/wai_cli/door.rs index d39c8a726..a4aa1d265 100644 --- a/wai/wai-rs/src/bin/wai_cli/door.rs +++ b/wai/wai-rs/src/bin/wai_cli/door.rs @@ -75,6 +75,17 @@ pub fn main() -> ExitCode { argv: raw.iter().filter(|a| *a != "--json").cloned().collect(), }; let json = c.json; + // A deprecated flag still works, and says so in one line (wai/VERSIONING.md); a child says + // nothing, since its parent has. + if !child { + for f in catalogue::AGENT_FLAGS { + if let Some((alias, _)) = f.deprecated_alias + && has(alias) + { + eprintln!("wai: deprecated: {alias}, use {} instead", f.flag); + } + } + } out::set_dry_run(c.dry_run); agent::set(c); let args: Vec = raw.iter().filter(|a| !global(a) || (child && *a == "--json")).cloned().collect(); @@ -265,7 +276,7 @@ pub fn not_built(op: &Op) -> OpError { /// The help `wai --help` prints: every operation, the developer's path first. pub fn usage_text() -> String { let mut s = String::new(); - s += "wai — the WAI toolchain. Every operation has an id, noun.verb, and `wai ` runs it\n"; + s += "wai — the .WAI™ toolchain. Every operation has an id, noun.verb, and `wai ` runs it\n"; s += "as well as the command shown.\n\nStart here:\n"; let line = |o: &Op| -> String { let cmd = o.command(); @@ -434,6 +445,59 @@ fn ops_export(a: &[String]) -> Result { } } +// ─────────────────────────────── wai --version ─────────────────────────────── + +fn sha256_hex(b: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect() +} + +/// `wai --version`: one version, the crate's, which the standard's text and the reference +/// implementation are released under (wai/VERSIONING.md, and a test holds wai/CHANGELOG.md's newest +/// release to it); the digests of what this binary was built with; and the build features the +/// catalogue names that it holds. +fn toolchain_show(a: &[String]) -> Result { + if let Some(x) = a.first() { + return Err(OpError::usage(format!("wai --version: {x}: takes no argument"), "")); + } + let version = env!("CARGO_PKG_VERSION"); + let catalogue_sha = sha256_hex(catalogue::export_text().as_bytes()); + let registry = wai::codecs::registry::capabilities_json(); + let registry_sha = sha256_hex(registry.as_bytes()); + let capabilities = serde_json::from_str::(®istry).ok().and_then(|v| v["capabilities"].as_array().map(Vec::len)).unwrap_or(0); + let formats = [wai::container::MAGIC, wai::container::MAGIC_V2].map(|m| String::from_utf8_lossy(m).into_owned()); + let mut features: Vec<&str> = OPS + .iter() + .flat_map(|o| match o.needs { + catalogue::Needs::Nothing => &[][..], + catalogue::Needs::All(f) | catalogue::Needs::Any(f) => f, + }) + .copied() + .filter(|f| catalogue::feature_on(f) == Some(true)) + .collect(); + features.sort_unstable(); + features.dedup(); + if agent::ctx().json { + let data = json!({ + "toolchain": version, + "catalogue": {"format": catalogue::FORMAT, "operations": OPS.len(), "sha256": catalogue_sha}, + "envelope": catalogue::ENVELOPE, + "registry": {"capabilities": capabilities, "sha256": registry_sha}, + "specification": {"version": version, "formats": formats}, + "features": features, + }); + agent::print(&agent::envelope_with("toolchain.show", true, data, None)); + } else { + println!("wai {version}"); + println!("catalogue {}, {} operations, sha256 {catalogue_sha}", catalogue::FORMAT, OPS.len()); + println!("envelope {}", catalogue::ENVELOPE); + println!("registry {capabilities} capabilities, sha256 {registry_sha}"); + println!("specification {version}, reading {}", formats.join(" and ")); + println!("features {}", if features.is_empty() { "none the catalogue names".to_string() } else { features.join(", ") }); + } + Ok(true) +} + // ─────────────────────────────── the other implementations ─────────────────────────────── type R = Result; @@ -675,6 +739,7 @@ pub const IMPLS: &[Impl] = &[ Impl { id: "ops.search", run: ops_search, json: Json::Native }, Impl { id: "ops.show", run: ops_schema, json: Json::Native }, Impl { id: "ops.export", run: ops_export, json: Json::Native }, + Impl { id: "toolchain.show", run: toolchain_show, json: Json::Native }, ]; #[cfg(test)] diff --git a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs index 1ac107454..3a2226b49 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/channel.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/channel.rs @@ -126,14 +126,8 @@ pub fn import(args: &[String]) -> R { ]; agent::check_args(args, &with_value, &with_value, 0, &usage)?; let channel = super::opt(args, "--channel").ok_or_else(|| OpError::usage("--channel: the channel id", &usage))?; - let key_path = super::opt(args, "--key").ok_or_else(|| OpError::usage("--key: the operator's key file (32 bytes, or 64 hexadecimal digits)", &usage))?; - let raw = read(key_path, "the operator's key file")?; - let seed: [u8; 32] = if raw.len() == 32 { - raw.try_into().expect("32") - } else { - std::str::from_utf8(&raw).ok().and_then(|s| unhex(s.trim())).and_then(|b| b.try_into().ok()).ok_or_else(|| OpError::usage(format!("{key_path}: 32 bytes, or 64 hexadecimal digits"), "the operator key's seed"))? - }; - let key = ed25519_dalek::SigningKey::from_bytes(&seed); + let key_arg = super::opt(args, "--key").ok_or_else(|| OpError::usage("--key: the operator's key: its name in the keystore, or its file (32 bytes, or 64 hexadecimal digits)", &usage))?; + let key = super::key::signing_key(key_arg, false)?; let mrss = all(args, "--mrss"); let owners = all(args, "--mrss-owner"); if !owners.is_empty() && owners.len() != mrss.len() { diff --git a/wai/wai-rs/src/bin/wai_cli/dx/conform/adapter.rs b/wai/wai-rs/src/bin/wai_cli/dx/conform/adapter.rs index 9bb61a70b..0cfa6b092 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/conform/adapter.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/conform/adapter.rs @@ -49,11 +49,18 @@ pub fn fill(cmd: &str, with: &[(&str, &Path)]) -> String { /// killed, can hold the output open, and the run does not wait for it. The command stays in /// the caller's process group, so an interrupt of `wai conform` reaches it too. pub fn run(cmd: &str, stdin: Option<&[u8]>, timeout: Duration) -> Result { - let mut c = if cfg!(windows) { + #[cfg(windows)] + let mut c = { + // `Command::arg` would escape the quotes `quote` writes, and cmd would hand them on as part + // of each path. So cmd's line goes as it is: /S takes the outer pair off and nothing else, + // whatever the command line begins with. + use std::os::windows::process::CommandExt; let mut c = Command::new("cmd"); - c.arg("/C").arg(cmd); + c.raw_arg("/S /C \"").raw_arg(cmd).raw_arg("\""); c - } else { + }; + #[cfg(not(windows))] + let mut c = { let mut c = Command::new("sh"); c.arg("-c").arg(cmd); c @@ -182,6 +189,22 @@ pub fn program_found(cmd: &str) -> bool { mod tests { use super::*; + /// A quoted path reaches the command as the path, and the program itself may be quoted. + #[cfg(windows)] + #[test] + fn cmd_gets_the_command_line_as_it_is_written() { + let d = std::env::temp_dir().join(format!("wai-conform-cmd-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let f = d.join("a b.txt"); + std::fs::write(&f, "held\r\n").unwrap(); + let r = run(&fill("type {f}", &[("f", &f)]), None, Duration::from_secs(20)).unwrap(); + assert_eq!((r.code, first_line(&r.stdout).trim()), (Some(0), "held"), "{}", String::from_utf8_lossy(&r.stderr)); + let findstr = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join("System32").join("findstr.exe"); + let r = run(&fill("{p} held {f}", &[("p", &findstr), ("f", &f)]), None, Duration::from_secs(20)).unwrap(); + assert_eq!((r.code, first_line(&r.stdout).trim()), (Some(0), "held"), "{}", String::from_utf8_lossy(&r.stderr)); + let _ = std::fs::remove_dir_all(&d); + } + #[cfg(unix)] #[test] fn placeholders_are_quoted_paths() { diff --git a/wai/wai-rs/src/bin/wai_cli/dx/conform/record.rs b/wai/wai-rs/src/bin/wai_cli/dx/conform/record.rs index 4154a6f15..a02e88b1e 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/conform/record.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/conform/record.rs @@ -13,24 +13,10 @@ pub use wai::conform_result::FORMAT; /// The suffix of a result record's file name, which `wai verify` looks for. pub const SUFFIX: &str = ".wai-conform.json"; -fn unhex(s: &str) -> Option> { - if !s.len().is_multiple_of(2) { - return None; - } - (0..s.len() / 2).map(|i| u8::from_str_radix(s.get(2 * i..2 * i + 2)?, 16).ok()).collect() -} - -/// A signing key from `--key`'s file: 64 hexadecimal digits (a 32-byte seed), or the 32 -/// bytes themselves. +/// A signing key from `--key`: its name in the keystore, or a key file (64 hexadecimal digits, a +/// 32-byte seed, or the 32 bytes themselves). pub fn key_from_file(path: &std::path::Path) -> Result { - let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; - let t = String::from_utf8_lossy(&b); - let seed: Vec = match unhex(t.trim()) { - Some(s) if s.len() == 32 => s, - _ if b.len() == 32 => b, - _ => return Err(format!("{}: a key file holds a 32-byte Ed25519 seed, as 64 hexadecimal digits or 32 bytes", path.display())), - }; - Ok(SigningKey::from_bytes(&seed.try_into().expect("32 bytes"))) + super::super::key::signing_key(&path.to_string_lossy(), false).map_err(|e| e.message) } /// A key made for one run. diff --git a/wai/wai-rs/src/bin/wai_cli/dx/fleet.rs b/wai/wai-rs/src/bin/wai_cli/dx/fleet.rs index bf1f5ef30..c8a9475dd 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/fleet.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/fleet.rs @@ -157,14 +157,7 @@ pub fn enrolment_create(args: &[String]) -> R { let r = role::code(role_arg) .or_else(|| role_arg.parse::().ok().filter(|r| role::name(*r).is_some())) .ok_or_else(|| OpError::usage(format!("--role {role_arg}: relay, compositor, ingest, room, ledger or audit"), &usage))?; - let key_path = required(args, "--key", "the organisation key's file (32 bytes, or 64 hexadecimal digits)", &usage)?; - let raw = std::fs::read(key_path).map_err(|e| io_fail(key_path, &e, "give the organisation key's file"))?; - let seed: [u8; 32] = if raw.len() == 32 { - raw.try_into().expect("32") - } else { - std::str::from_utf8(&raw).ok().and_then(|s| unhex(s.trim())).and_then(|b| b.try_into().ok()).ok_or_else(|| OpError::usage(format!("{key_path}: 32 bytes, or 64 hexadecimal digits"), "the organisation key's seed"))? - }; - let key = ed25519_dalek::SigningKey::from_bytes(&seed); + let key = super::key::signing_key(required(args, "--key", "the organisation key: its name in the keystore, or its file (32 bytes, or 64 hexadecimal digits)", &usage)?, false)?; let label = super::opt(args, "--label").unwrap_or(""); let (from, until) = (number(args, "--from", 0)?, number(args, "--until", NO_END)?); let e = Enrolment::new(key.verifying_key().to_bytes(), node, r, from, until, label).sign(&key); diff --git a/wai/wai-rs/src/bin/wai_cli/dx/key.rs b/wai/wai-rs/src/bin/wai_cli/dx/key.rs new file mode 100644 index 000000000..543850932 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/key.rs @@ -0,0 +1,1367 @@ +//! `wai key` (operations `key.create`, `key.list`, `key.show`, `key.rotate`, `key.revoke`, +//! `key.import` and `key.export`): a developer's signing keys, kept in one keystore and named, so +//! that every signing operation finds a key by its name (WAI toolchain v1, step 4: `--key +//! `). +//! +//! **Where keys live.** One keystore, a directory: `--keystore DIR`, else `$WAI_KEYSTORE`, else +//! `$XDG_CONFIG_HOME/wai/keys`, else `~/.config/wai/keys` (`%APPDATA%\wai\keys` on Windows), made +//! with mode 0700 on Unix, and an access list for this account alone on Windows. Each key is a record, +//! `.json` (format `wai.key/1`), which holds its public halves and nothing secret. Each secret is +//! kept where `--store` says: +//! +//! - `keychain`, the default on macOS: in the login keychain, a generic password of the service +//! `science.transaction.wai.key` whose account is the key's public key in hexadecimal, written +//! and read through `/usr/bin/security` with the secret on its standard input, never on a +//! command line; +//! - `file`, the default elsewhere: `.seed` in the keystore, 64 hexadecimal digits. On Unix +//! it is made with mode 0600, and one whose mode lets another user read it is refused (exit 3). On +//! Windows it is made under an access list for this account alone, inheritance off, in whichever +//! folder it is, and one whose list lets any other account in, by SID, or that another account +//! owns, is refused the same way (`../win_acl.rs`). Either way the file is restricted by the call +//! that makes it, before the secret is written. It is not encrypted on either system. +//! +//! No operation prints a secret, writes one anywhere but its store, or takes one on its command +//! line. +//! +//! **Names, ids, roles and statuses.** A name is 1 to 48 of `a-z`, `0-9`, `-` and `_`, beginning +//! with a letter or a digit. A key's first version has the name as its key id, and each rotation +//! adds a version whose id is `.`: both are key ids of jwp-receipts §7.1, so an origin +//! lists them in its key log as they are (`wai key export --format wai-keys` gives the argument +//! `wai-keys init --key` and `wai-keys append --add` take). A version's status is the key log's +//! (§7.2): `active`; `retired`, with `until`; or `revoked`, with `compromised_since` (and `until` +//! if it was retired first). Rotation follows §7.6: the successor is made under a new id and the +//! old version retired at that moment; nothing signs with it after, so its secret is destroyed. A +//! revoked version's secret is destroyed too. A role (`--for`) is one of [`ROLES`]. No two keys +//! of one keystore hold the same public key (§7.2: no two entries share a key). +//! +//! **How other commands find them** ([`signing_key`]): every signing operation of this binary +//! takes `--key `. A path that exists is read as a key file, as before (64 hexadecimal +//! digits or the 32 bytes); otherwise the argument names a key of the keystore (`$WAI_KEYSTORE`, +//! or the default), whose newest version signs when it is active. A revoked key signs nothing +//! (exit 2, `key-revoked`). +//! +//! **Published seeds.** A seed that is one byte repeated 32 times has the form of every seed this +//! repository publishes (`key_log::published_test_keys`, jwp-receipts §7.8): it attests nothing. +//! `wai key import` refuses one unless `--published-test-seed` says the key is a test key, and the +//! record says so. +//! +//! Every operation keeps the agent rules of [`super::envelope`]: the exit codes, `--json`, +//! `--dry-run` (every file and every keychain item named in the plan; nothing written, no key +//! made), and `--yes` before a rotation or a revocation, which destroy a secret. + +#![cfg_attr(not(feature = "quickstart"), allow(dead_code, unused_imports))] + +use super::envelope::{self as agent, EXIT_INTERNAL, EXIT_NOT_FOUND, EXIT_PERMISSION, EXIT_USAGE, OpError}; +use crate::out; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// The format of a key's record. +pub const FORMAT: &str = "wai.key/1"; + +/// The roles a key is made for (`--for`), one each. +pub const ROLES: &[&str] = &["origin", "signer", "room", "audit", "ledger", "release", "remote-host", "quantum", "dev"]; + +/// The keychain service a key's secret is kept under; its account is the key's public key. +pub const SERVICE: &str = "science.transaction.wai.key"; + +/// macOS's keychain tool. +const SECURITY: &str = "/usr/bin/security"; + +/// The longest name: its key ids (`.`) stay within jwp-receipts §7.1's 64 characters. +const NAME_MAX: usize = 48; + +type R = Result; + +// ─────────────────────────────── the keystore ─────────────────────────────── + +/// Where a secret is kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Store { + Keychain, + File, +} + +impl Store { + pub fn label(self) -> &'static str { + match self { + Store::Keychain => "keychain", + Store::File => "file", + } + } + + fn parse(s: &str) -> Option { + match s { + "keychain" => Some(Store::Keychain), + "file" => Some(Store::File), + _ => None, + } + } + + /// Where a secret is kept when `--store` does not say: the keychain on macOS, a file elsewhere. + fn default_here() -> Store { + if cfg!(target_os = "macos") { Store::Keychain } else { Store::File } + } +} + +/// A key's record, `.json`. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Record { + pub format: String, + pub name: String, + pub role: String, + pub versions: Vec, +} + +/// One version of a key: its key id, its public key and its status, as a key log lists them. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Version { + pub id: String, + /// The Ed25519 public key, 64 lowercase hexadecimal digits. + pub key: String, + pub created: String, + /// `active`, `retired` or `revoked`. + pub status: String, + pub until: Option, + pub compromised_since: Option, + /// Where its secret is kept (`keychain`, `file`); `null` once destroyed. + pub store: Option, + pub published_test_seed: bool, +} + +/// The keystore's directory: `--keystore`, else `$WAI_KEYSTORE`, else the user's configuration +/// directory's `wai/keys`. +pub fn keystore(given: Option<&str>) -> Result { + if let Some(d) = given { + return Ok(PathBuf::from(d)); + } + if let Some(d) = std::env::var_os("WAI_KEYSTORE").filter(|d| !d.is_empty()) { + return Ok(PathBuf::from(d)); + } + if cfg!(windows) + && let Some(a) = std::env::var_os("APPDATA").filter(|d| !d.is_empty()) + { + return Ok(PathBuf::from(a).join("wai").join("keys")); + } + if let Some(x) = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).filter(|p| p.is_absolute()) { + return Ok(x.join("wai").join("keys")); + } + if let Some(h) = std::env::var_os("HOME").filter(|h| !h.is_empty()) { + return Ok(PathBuf::from(h).join(".config").join("wai").join("keys")); + } + Err(OpError::new(EXIT_USAGE, "no-keystore", "no keystore: there is no home directory to keep one in", "give --keystore DIR, or set WAI_KEYSTORE")) +} + +/// Whether `n` is a key's name. +pub fn name_ok(n: &str) -> bool { + !n.is_empty() + && n.len() <= NAME_MAX + && n.as_bytes()[0].is_ascii_alphanumeric() + && n.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' || c == b'_') +} + +fn record_path(dir: &Path, name: &str) -> PathBuf { + dir.join(format!("{name}.json")) +} + +fn seed_path(dir: &Path, id: &str) -> PathBuf { + dir.join(format!("{id}.seed")) +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn unhex32(s: &str) -> Option<[u8; 32]> { + if s.len() != 64 || !s.bytes().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let mut out = [0u8; 32]; + for (i, o) in out.iter_mut().enumerate() { + *o = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?; + } + Some(out) +} + +fn io_fail(path: &Path, e: &std::io::Error) -> OpError { + match e.kind() { + std::io::ErrorKind::NotFound => OpError::new(EXIT_NOT_FOUND, "not-found", format!("{}: {e}", path.display()), ""), + std::io::ErrorKind::PermissionDenied => OpError::new(EXIT_PERMISSION, "permission-denied", format!("{}: {e}", path.display()), "the keystore's owner runs this"), + _ => OpError::new(EXIT_INTERNAL, "io-error", format!("{}: {e}", path.display()), ""), + } +} + +fn not_found(name: &str, dir: &Path) -> OpError { + OpError::new(EXIT_NOT_FOUND, "key-not-found", format!("no key {name} in the keystore {}", dir.display()), "wai key list names the keystore's keys; wai key create --for makes one") +} + +/// A record whose form this build does not read. +fn malformed(path: &Path, why: String) -> OpError { + OpError::new(EXIT_USAGE, "key-record-malformed", format!("{}: not a key record of {FORMAT}: {why}", path.display()), "a key's record is written by wai key alone") +} + +/// The record of the key `name`. +pub fn read_record(dir: &Path, name: &str) -> Result { + let p = record_path(dir, name); + let bytes = match std::fs::read(&p) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(not_found(name, dir)), + Err(e) => return Err(io_fail(&p, &e)), + }; + let r: Record = serde_json::from_slice(&bytes).map_err(|e| malformed(&p, e.to_string()))?; + check_record(&r, name).map_err(|m| malformed(&p, m))?; + Ok(r) +} + +/// Whether a record holds to its rules: its format, its name, its role, and its versions' ids, +/// keys and statuses as a key log's entries hold theirs (jwp-receipts §7.2), the newest alone +/// active. +fn check_record(r: &Record, name: &str) -> Result<(), String> { + if r.format != FORMAT { + return Err(format!("its format is {:?}", r.format)); + } + if r.name != name { + return Err(format!("it names the key {:?}, and its file {name}", r.name)); + } + if !ROLES.contains(&r.role.as_str()) { + return Err(format!("its role {:?} is none of {}", r.role, ROLES.join(", "))); + } + if r.versions.is_empty() { + return Err("it has no version".into()); + } + for (i, v) in r.versions.iter().enumerate() { + let want = if i == 0 { name.to_string() } else { format!("{name}.{}", i + 1) }; + if v.id != want { + return Err(format!("version {} has the id {:?}, not {want}", i + 1, v.id)); + } + if unhex32(&v.key).is_none() || v.key.bytes().any(|c| c.is_ascii_uppercase()) { + return Err(format!("{}: its key is not 64 lowercase hexadecimal digits", v.id)); + } + let newest = i + 1 == r.versions.len(); + let ok = match (v.status.as_str(), &v.until, &v.compromised_since) { + ("active", None, None) => newest, + ("retired", Some(u), None) => log_time_ok(u), + ("revoked", u, Some(c)) => log_time_ok(c) && u.as_deref().is_none_or(log_time_ok), + _ => false, + }; + if !ok { + return Err(format!("{}: status {:?} with until {:?} and compromised_since {:?}", v.id, v.status, v.until, v.compromised_since)); + } + if !matches!(v.store.as_deref(), None | Some("keychain" | "file")) { + return Err(format!("{}: its secret is kept in {:?}", v.id, v.store)); + } + } + Ok(()) +} + +/// Whether `s` is a log date-time of jwp-receipts §7.2: `YYYY-MM-DDTHH:MM:SSZ`, in UTC, a second +/// of at most 59, a day its month has. +pub fn log_time_ok(s: &str) -> bool { + let b = s.as_bytes(); + if b.len() != 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' || b[19] != b'Z' { + return false; + } + let n = |a: usize, c: usize| -> Option { s.get(a..c).filter(|t| t.bytes().all(|x| x.is_ascii_digit())).and_then(|t| t.parse().ok()) }; + let (Some(y), Some(mo), Some(d), Some(h), Some(mi), Some(se)) = (n(0, 4), n(5, 7), n(8, 10), n(11, 13), n(14, 16), n(17, 19)) else { + return false; + }; + let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; + let days = match mo { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return false, + }; + (1..=days).contains(&d) && h < 24 && mi < 60 && se < 60 +} + +/// A key's name in the keystore, and its record or why that does not read. +type Named = (String, Result); + +/// Every key's record in the keystore, by name. +pub fn records(dir: &Path) -> Result, OpError> { + let rd = match std::fs::read_dir(dir) { + Ok(rd) => rd, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(io_fail(dir, &e)), + }; + let mut names: Vec = rd + .filter_map(Result::ok) + .filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".json")).map(str::to_string)) + .filter(|n| name_ok(n)) + .collect(); + names.sort(); + Ok(names.into_iter().map(|n| (n.clone(), read_record(dir, &n))).collect()) +} + +/// The record's text, as it is written. +fn record_text(r: &Record) -> String { + serde_json::to_string_pretty(r).expect("a record is JSON") + "\n" +} + +/// A version as the operations print it, with its `did:key`. +fn version_json(v: &Version) -> Value { + let did = unhex32(&v.key).map(|k| wai::signer_id::did_key(&k)); + json!({ + "id": v.id, "key": v.key, "did_key": did, "created": v.created, "status": v.status, "until": v.until, + "compromised_since": v.compromised_since, "store": v.store, "published_test_seed": v.published_test_seed, + }) +} + +/// macOS's keychain, through its tool. A secret goes to the tool on its standard input, inside +/// the tool's own command language (`security -i`), never in its arguments. +pub struct Keychain { + tool: PathBuf, +} + +impl Keychain { + /// The login keychain, through `/usr/bin/security`. + pub fn system() -> Keychain { + Keychain { tool: PathBuf::from(SECURITY) } + } + + /// The keychain this one's `tool` keeps (the tests' stand-in and wrapper, which are shell scripts). + #[cfg(all(test, unix))] + pub fn with_tool(tool: &Path) -> Keychain { + Keychain { tool: tool.to_path_buf() } + } + + fn fail(&self, what: &str, detail: &str) -> OpError { + OpError::new( + EXIT_PERMISSION, + "keychain-unavailable", + format!("the keychain {what}: {}", detail.trim()), + "unlock the login keychain (security unlock-keychain), or keep the secret in the keystore with --store file", + ) + } + + /// Keep `secret` (hexadecimal) under `account`, then read it back. + pub fn add(&self, account: &str, label: &str, secret: &str) -> Result<(), OpError> { + let mut child = Command::new(&self.tool) + .arg("-i") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| self.fail("tool did not start", &format!("{}: {e}", self.tool.display())))?; + { + let mut stdin = child.stdin.take().expect("piped"); + let line = format!("add-generic-password -U -s {SERVICE} -a {account} -l {label} -w {secret}\n"); + let wrote = stdin.write_all(line.as_bytes()); + let mut line = line.into_bytes(); + line.fill(0); + wrote.map_err(|e| self.fail("did not take the key", &e.to_string()))?; + } + let o = child.wait_with_output().map_err(|e| self.fail("tool did not finish", &e.to_string()))?; + // `security -i` exits with the status of a command that failed in it (45 for an item that + // exists without -U), and says why on standard error, naming no secret; what the keychain + // holds is read back as well. + if !o.status.success() { + return Err(self.fail("did not keep the key", &String::from_utf8_lossy(&o.stderr).replace(secret, ""))); + } + match self.find(account)? { + Some(mut s) => { + let same = s == secret; + wipe(&mut s); + if same { Ok(()) } else { Err(self.fail("holds another secret for this key", account)) } + } + None => Err(self.fail("did not keep the key", &String::from_utf8_lossy(&o.stderr))), + } + } + + /// The secret kept under `account`; `None` when the keychain holds none. + pub fn find(&self, account: &str) -> Result, OpError> { + let o = Command::new(&self.tool) + .args(["find-generic-password", "-s", SERVICE, "-a", account, "-w"]) + .stdin(Stdio::null()) + .output() + .map_err(|e| self.fail("tool did not start", &format!("{}: {e}", self.tool.display())))?; + match o.status.code() { + Some(0) => { + let mut b = o.stdout; + let s = String::from_utf8_lossy(&b).trim().to_string(); + b.fill(0); + Ok(Some(s)) + } + // errSecItemNotFound + Some(44) => Ok(None), + _ => Err(self.fail("was not read", &String::from_utf8_lossy(&o.stderr))), + } + } + + /// The secret under `account` removed; whether there was one. + pub fn delete(&self, account: &str) -> Result { + let o = Command::new(&self.tool) + .args(["delete-generic-password", "-s", SERVICE, "-a", account]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .output() + .map_err(|e| self.fail("tool did not start", &format!("{}: {e}", self.tool.display())))?; + match o.status.code() { + Some(0) => Ok(true), + Some(44) => Ok(false), + _ => Err(self.fail("did not remove the key", &String::from_utf8_lossy(&o.stderr))), + } + } +} + +/// A string's bytes overwritten before it is dropped. +fn wipe(s: &mut String) { + let mut b = std::mem::take(s).into_bytes(); + b.fill(0); +} + +/// A seed from a key file: 64 hexadecimal digits (around white space), or the 32 bytes. +fn seed_from_file(path: &Path) -> Result<[u8; 32], OpError> { + let mut b = std::fs::read(path).map_err(|e| io_fail(path, &e))?; + let seed = match std::str::from_utf8(&b).ok().and_then(|t| unhex32(t.trim())) { + Some(s) => Some(s), + None if b.len() == 32 => b.as_slice().try_into().ok(), + None => None, + }; + b.fill(0); + seed.ok_or_else(|| { + OpError::new(EXIT_USAGE, "key-file-malformed", format!("{}: a key file holds a 32-byte Ed25519 seed, as 64 hexadecimal digits or 32 bytes", path.display()), "") + }) +} + +/// The secret of version `v`, from where it is kept, checked against its public key. +#[cfg(feature = "ed25519")] +fn load_seed(dir: &Path, v: &Version, kc: &Keychain) -> Result<[u8; 32], OpError> { + let seed = match v.store.as_deref() { + None => return Err(OpError::new(EXIT_NOT_FOUND, "key-secret-destroyed", format!("the secret of {} was destroyed", v.id), "")), + Some("keychain") => { + let mut s = kc.find(&v.key)?.ok_or_else(|| { + OpError::new(EXIT_NOT_FOUND, "key-secret-missing", format!("the login keychain holds no secret for {} ({})", v.id, v.key), "wai key show names where each secret is kept") + })?; + let seed = unhex32(&s); + wipe(&mut s); + seed.ok_or_else(|| OpError::new(EXIT_USAGE, "key-mismatch", format!("the keychain's secret for {} is not a seed", v.id), ""))? + } + Some(_) => { + let p = seed_path(dir, &v.id); + private_enough(&p)?; + seed_from_file(&p)? + } + }; + if hex(&ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes()) != v.key { + return Err(OpError::new(EXIT_USAGE, "key-mismatch", format!("the secret kept for {} is not the key its record names ({})", v.id, v.key), "the record and its secret were changed apart")); + } + Ok(seed) +} + +/// A seed file no other account may read or write: mode 0600 or narrower on Unix, and on Windows an +/// access list that lets no other account in, and that another account does not own. +fn private_enough(p: &Path) -> Result<(), OpError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let m = std::fs::metadata(p).map_err(|e| io_fail(p, &e))?; + let mode = m.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + return Err(OpError::new( + EXIT_PERMISSION, + "key-file-exposed", + format!("{}: its mode is {mode:o}, so another user may read the secret; wai uses no seed file but its owner's alone", p.display()), + format!("chmod 600 {}", p.display()), + )); + } + } + #[cfg(windows)] + { + use crate::out::win_acl; + let acl = win_acl::read(p).map_err(|e| io_fail(p, &e))?; + let me = win_acl::current_user().map_err(|e| OpError::internal(format!("this account's SID: {e}")))?; + if let Some(x) = win_acl::exposure(&acl, &me) { + return Err(OpError::new( + EXIT_PERMISSION, + "key-file-exposed", + format!("{}: {}, so another account may read the secret; wai uses no seed file but its owner's alone", p.display(), x.describe()), + x.fix_command(&p.display().to_string(), &me), + )); + } + } + #[cfg(not(any(unix, windows)))] + let _ = p; + Ok(()) +} + +// ─────────────────────────────── --key ─────────────────────────────── + +/// The signing key `--key` names (WAI toolchain v1, rule N6): a key file that exists (64 +/// hexadecimal digits or the 32 bytes); with `literal`, 64 hexadecimal digits given as they are, +/// where the operation took them before; else the name of a key in the keystore +/// (`$WAI_KEYSTORE`, or the default), whose newest version signs when it is active. +#[cfg(feature = "ed25519")] +#[cfg_attr(not(feature = "quickstart"), allow(dead_code))] +pub fn signing_key(arg: &str, literal: bool) -> Result { + let p = Path::new(arg); + if p.is_file() { + let mut seed = seed_from_file(p)?; + let k = ed25519_dalek::SigningKey::from_bytes(&seed); + seed.fill(0); + return Ok(k); + } + if literal && let Some(mut seed) = unhex32(arg) { + let k = ed25519_dalek::SigningKey::from_bytes(&seed); + seed.fill(0); + return Ok(k); + } + if !name_ok(arg) { + return Err(OpError::new( + EXIT_NOT_FOUND, + "key-not-found", + format!("--key {arg} is not a file, nor a key's name"), + "--key takes a key's name (wai key list) or a key file: 64 hexadecimal digits or the 32 bytes", + )); + } + let dir = keystore(None)?; + let r = read_record(&dir, arg).map_err(|e| { + if e.code == "key-not-found" { + OpError::new(EXIT_NOT_FOUND, "key-not-found", format!("--key {arg} is not a file, nor a key of the keystore {}", dir.display()), e.hint) + } else { + e + } + })?; + let v = r.versions.last().expect("a record has a version"); + if v.status != "active" { + return Err(OpError::new( + EXIT_USAGE, + "key-revoked", + format!("--key {arg}: its key {} is revoked (compromised since {}) and signs nothing", v.id, v.compromised_since.as_deref().unwrap_or("?")), + format!("wai key rotate {arg} makes it a successor"), + )); + } + let mut seed = load_seed(&dir, v, &Keychain::system())?; + let k = ed25519_dalek::SigningKey::from_bytes(&seed); + seed.fill(0); + Ok(k) +} + +// ─────────────────────────────── the operations ─────────────────────────────── + +/// What an operation did: its envelope with `--json`, its text otherwise; in a dry run of one that +/// changes something, with the plan of what it would have done. +fn done(op: &str, writes: bool, data: Value, text: &str) -> R { + let plan = (writes && out::dry_run()).then(out::plan); + if agent::ctx().json { + agent::print(&agent::envelope_with(op, true, data, plan)); + } else { + println!("{text}"); + if plan.is_some() { + println!("dry run: nothing was written and no key was made. Without --dry-run, wai would:"); + for l in out::plan_lines() { + println!(" {l}"); + } + } + } + Ok(true) +} + +fn name_arg<'a>(pos: &[&'a str], usage: &str) -> Result<&'a str, OpError> { + let n = *pos.first().ok_or_else(|| OpError::usage("the key's name", usage))?; + if !name_ok(n) { + return Err(OpError::usage(format!("{n}: a key's name is 1 to {NAME_MAX} of a-z, 0-9, - and _, beginning with a letter or a digit"), usage)); + } + Ok(n) +} + +fn role_arg<'a>(args: &'a [String], usage: &str) -> Result<&'a str, OpError> { + let r = super::opt(args, "--for").ok_or_else(|| OpError::usage(format!("--for: the key's role, one of {}", ROLES.join(", ")), usage))?; + if !ROLES.contains(&r) { + return Err(OpError::usage(format!("--for {r}: the key's role is one of {}", ROLES.join(", ")), usage)); + } + Ok(r) +} + +/// `--store`, or `default`; the keychain on macOS alone. +fn store_arg(args: &[String], default: Store, usage: &str) -> Result { + let s = match super::opt(args, "--store") { + None => default, + Some(s) => Store::parse(s).ok_or_else(|| OpError::usage(format!("--store {s}: keychain or file"), usage))?, + }; + if s == Store::Keychain && !cfg!(target_os = "macos") { + return Err(OpError::new( + EXIT_USAGE, + "keychain-unavailable", + "--store keychain: wai keeps keys in macOS's keychain alone", + format!("--store file keeps the secret in a file of the keystore ({})", seed_file_protection()), + )); + } + Ok(s) +} + +/// The keystore's directory, made (mode 0700) when it is not there. +fn make_keystore(dir: &Path) -> Result<(), OpError> { + if out::dry_run() || dir.is_dir() { + return out::create_dir_all(dir).map_err(|e| io_fail(dir, &e)); + } + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir).map_err(|e| io_fail(dir, &e)) + } + #[cfg(windows)] + { + crate::out::win_acl::create_dir(dir).map_err(|e| io_fail(dir, &e)) + } + #[cfg(not(any(unix, windows)))] + { + std::fs::create_dir_all(dir).map_err(|e| io_fail(dir, &e)) + } +} + +/// The key that holds the public key `key` in the keystore, if one does. +fn holder_of(dir: &Path, key: &str) -> Result, OpError> { + for (n, r) in records(dir)? { + if r.is_ok_and(|r| r.versions.iter().any(|v| v.key == key)) { + return Ok(Some(n)); + } + } + Ok(None) +} + +/// Keep version `v`'s secret where `store` says (a dry run names this with [`plan_keep`] instead). +fn keep_secret(dir: &Path, v: &Version, store: Store, seed: &[u8; 32], kc: &Keychain) -> Result<(), OpError> { + let mut text = hex(seed); + let r = match store { + Store::Keychain => kc.add(&v.key, &format!("wai.key.{}", v.id), &text), + Store::File => { + let p = seed_path(dir, &v.id); + out::write_new_private(&p, format!("{text}\n")).map_err(|e| io_fail(&p, &e)) + } + }; + wipe(&mut text); + r +} + +/// Version `v`'s secret, as a dry run would name its keeping. +fn plan_keep(dir: &Path, id: &str, store: Store) { + match store { + Store::Keychain => out::would(json!({"keychain": "add", "service": SERVICE, "id": id})), + Store::File => out::would_write(seed_path(dir, id)), + } +} + +/// Version `v`'s secret destroyed, wherever it is kept; `v.store` is then `null`. +fn destroy_secret(dir: &Path, v: &mut Version, kc: &Keychain) -> Result<(), OpError> { + match v.store.as_deref() { + None => {} + Some("keychain") => { + if out::dry_run() { + out::would(json!({"keychain": "delete", "service": SERVICE, "account": v.key, "id": v.id})); + } else { + kc.delete(&v.key)?; + } + } + Some(_) => { + let p = seed_path(dir, &v.id); + if !out::dry_run() && p.is_file() { + // Its bytes overwritten before it is removed, where the file system keeps them in place. + if let Ok(mut f) = std::fs::OpenOptions::new().write(true).open(&p) { + let _ = f.write_all(&[0u8; 65]); + let _ = f.sync_all(); + } + } + match out::remove_file(&p) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(io_fail(&p, &e)), + } + } + } + if !out::dry_run() { + v.store = None; + } + Ok(()) +} + +/// The record written over the one there. +fn write_record(dir: &Path, r: &Record) -> Result<(), OpError> { + let p = record_path(dir, &r.name); + out::replace_private(&p, record_text(r)).map_err(|e| io_fail(&p, &e)) +} + +#[cfg(feature = "quickstart")] +fn now() -> String { + let s = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + wai::claim_receipt::rfc3339_utc(s) +} + +/// 32 bytes from the platform's random generator, never one byte repeated (a published seed's form). +#[cfg(feature = "quickstart")] +fn fresh_seed() -> Result<[u8; 32], OpError> { + loop { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|e| OpError::internal(format!("the platform's random generator failed: {e}")))?; + if !published_form(&seed) { + return Ok(seed); + } + } +} + +/// Whether a seed has the form of every seed WAI publishes: one byte repeated 32 times. +fn published_form(seed: &[u8; 32]) -> bool { + seed.iter().all(|b| *b == seed[0]) +} + +/// What a new key is kept as, and the operation that keeps it. +#[cfg(feature = "quickstart")] +struct NewKey<'a> { + op: &'a str, + dir: &'a Path, + name: &'a str, + role: &'a str, + store: Store, + published: bool, + /// A line more for the person, after what is said of its secret. + note: &'a str, +} + +/// A key made from `seed` (none in a dry run, which makes none) and kept as `k` says. +#[cfg(feature = "quickstart")] +fn add_key(k: NewKey, seed: Option<[u8; 32]>) -> R { + let NewKey { op, dir, name, role, store, published, note } = k; + let file = record_path(dir, name); + if file.exists() { + return Err(OpError::new(EXIT_USAGE, "key-exists", format!("the keystore {} already holds a key {name}", dir.display()), format!("wai key show {name} describes it; wai key rotate {name} gives it a new key"))); + } + if out::dry_run() { + make_keystore(dir)?; + out::would_write(&file); + plan_keep(dir, name, store); + let data = json!({"name": name, "role": role, "id": name, "key": null, "store": store.label(), "keystore": dir.display().to_string(), "published_test_seed": published}); + return done(op, true, data, &format!("would keep the key {name} (role {role}) in {}, its secret in {}", dir.display(), where_kept(store))); + } + let mut seed = seed.expect("a seed outside a dry run"); + let pk = hex(&ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes()); + if let Some(other) = holder_of(dir, &pk)? { + seed.fill(0); + return Err(OpError::new(EXIT_USAGE, "key-duplicate", format!("the keystore already holds this key, as {other}"), "a keystore lists each key once (jwp-receipts §7.2)")); + } + make_keystore(dir)?; + let v = Version { + id: name.to_string(), + key: pk.clone(), + created: now(), + status: "active".into(), + until: None, + compromised_since: None, + store: Some(store.label().into()), + published_test_seed: published, + }; + let kc = Keychain::system(); + let kept = keep_secret(dir, &v, store, &seed, &kc); + seed.fill(0); + kept?; + let r = Record { format: FORMAT.into(), name: name.into(), role: role.into(), versions: vec![v] }; + if let Err(e) = out::write_new_private(&file, record_text(&r)) { + let mut v = r.versions[0].clone(); + let _ = destroy_secret(dir, &mut v, &kc); + return Err(io_fail(&file, &e)); + } + let v = &r.versions[0]; + let did = wai::signer_id::did_key(&unhex32(&v.key).expect("hex")); + let data = json!({"name": name, "role": role, "id": v.id, "key": v.key, "did_key": did, "store": store.label(), "keystore": dir.display().to_string(), "published_test_seed": published}); + let text = format!( + "the key {name} (role {role}): Ed25519 {}\n {did}\n its secret is in {}, and is never printed{note}\n\ + sign with it: --key {name}. Its line in an origin's key log: wai key export {name} --format wai-keys --may-sign ", + v.key, + where_kept_at(store, dir, &v.id), + ); + done(op, true, data, &text) +} + +/// What protects a seed file here, as a person is told it. +fn seed_file_protection() -> &'static str { + if cfg!(unix) { + "mode 0600" + } else if cfg!(windows) { + "an access list for this account alone" + } else { + "a plain file" + } +} + +fn where_kept(store: Store) -> String { + match store { + Store::Keychain => "the login keychain".into(), + Store::File => format!("a file of the keystore ({})", seed_file_protection()), + } +} + +fn where_kept_at(store: Store, dir: &Path, id: &str) -> String { + match store { + Store::Keychain => "the login keychain".into(), + Store::File => format!("{} ({})", seed_path(dir, id).display(), seed_file_protection()), + } +} + +/// `wai key create --for [--store keychain|file] [--keystore DIR]`. +#[cfg(feature = "quickstart")] +pub fn create(args: &[String]) -> R { + let usage = super::usage("key.create"); + let with_value = ["--for", "--store", "--keystore"]; + let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?; + let name = name_arg(&pos, &usage)?; + let role = role_arg(args, &usage)?; + let store = store_arg(args, Store::default_here(), &usage)?; + let dir = keystore(super::opt(args, "--keystore"))?; + let seed = if out::dry_run() || record_path(&dir, name).exists() { None } else { Some(fresh_seed()?) }; + add_key(NewKey { op: "key.create", dir: &dir, name, role, store, published: false, note: "" }, seed) +} + +/// `wai key import --for --from [--published-test-seed] [--store S] [--keystore DIR]`. +#[cfg(feature = "quickstart")] +pub fn import(args: &[String]) -> R { + let usage = super::usage("key.import"); + let with_value = ["--for", "--from", "--store", "--keystore"]; + let mut known = with_value.to_vec(); + known.push("--published-test-seed"); + let pos = agent::check_args(args, &known, &with_value, 1, &usage)?; + let name = name_arg(&pos, &usage)?; + let role = role_arg(args, &usage)?; + let store = store_arg(args, Store::default_here(), &usage)?; + let from = super::opt(args, "--from").ok_or_else(|| OpError::usage("--from: the key file to import (64 hexadecimal digits or the 32 bytes)", &usage))?; + let dir = keystore(super::opt(args, "--keystore"))?; + let mut seed = seed_from_file(Path::new(from))?; + let published = published_form(&seed); + if published && !super::flag(args, "--published-test-seed") { + seed.fill(0); + return Err(OpError::new( + EXIT_USAGE, + "published-test-seed", + format!("{from} holds a published test seed (one byte repeated 32 times, the form of every seed WAI publishes): a key anyone may hold attests nothing"), + "import it as a test key with --published-test-seed, or make a key of your own with wai key create", + )); + } + let note = format!("\n {from} still holds the seed: remove it once you no longer need it there"); + let r = add_key(NewKey { op: "key.import", dir: &dir, name, role, store, published, note: ¬e }, Some(seed)); + seed.fill(0); + r +} + +/// `wai key list [--for ] [--keystore DIR]`. +#[cfg(feature = "quickstart")] +pub fn list(args: &[String]) -> R { + let usage = super::usage("key.list"); + let with_value = ["--for", "--keystore"]; + agent::check_args(args, &with_value, &with_value, 0, &usage)?; + let role = super::opt(args, "--for"); + if let Some(r) = role + && !ROLES.contains(&r) + { + return Err(OpError::usage(format!("--for {r}: the key's role is one of {}", ROLES.join(", ")), &usage)); + } + let dir = keystore(super::opt(args, "--keystore"))?; + let mut rows = Vec::new(); + let mut text = format!("the keystore {}", dir.display()); + for (n, r) in records(&dir)? { + match r { + Ok(r) => { + if role.is_some_and(|w| w != r.role) { + continue; + } + let v = r.versions.last().expect("a version"); + let did = unhex32(&v.key).map(|k| wai::signer_id::did_key(&k)); + text.push_str(&format!("\n {:<20} {:<12} {:<24} {:<8} {}… secret: {}", r.name, r.role, v.id, v.status, &v.key[..16], v.store.as_deref().unwrap_or("destroyed"))); + rows.push(json!({"name": r.name, "role": r.role, "id": v.id, "key": v.key, "did_key": did, "status": v.status, "store": v.store, "versions": r.versions.len()})); + } + Err(e) => { + text.push_str(&format!("\n {n:<20} does not read: {}", e.message)); + rows.push(json!({"name": n, "error": e.to_json()})); + } + } + } + if rows.is_empty() { + text.push_str(": no key; wai key create --for makes one"); + } + done("key.list", false, json!({"keystore": dir.display().to_string(), "keys": rows}), &text) +} + +/// `wai key show [--keystore DIR]`. +#[cfg(feature = "quickstart")] +pub fn show(args: &[String]) -> R { + let usage = super::usage("key.show"); + let with_value = ["--keystore"]; + let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?; + let name = name_arg(&pos, &usage)?; + let dir = keystore(super::opt(args, "--keystore"))?; + let r = read_record(&dir, name)?; + let newest = r.versions.last().expect("a version"); + let signs_with = (newest.status == "active").then(|| newest.id.clone()); + let mut text = format!("{name} (role {}), in {}", r.role, dir.display()); + for v in r.versions.iter().rev() { + let when = match v.status.as_str() { + "retired" => format!("until {}", v.until.as_deref().unwrap_or("?")), + "revoked" => format!("compromised since {}", v.compromised_since.as_deref().unwrap_or("?")), + _ => format!("since {}", v.created), + }; + let did = unhex32(&v.key).map(|k| wai::signer_id::did_key(&k)).unwrap_or_default(); + let test = if v.published_test_seed { " (a published test seed)" } else { "" }; + text.push_str(&format!("\n {:<24} {:<8} {when}\n Ed25519 {}\n {did}\n secret: {}{test}", v.id, v.status, v.key, v.store.as_deref().unwrap_or("destroyed"))); + } + text.push_str(&match &signs_with { + Some(id) => format!("\n--key {name} signs with {id}"), + None => format!("\n--key {name} signs nothing: its newest version is {}", newest.status), + }); + let data = json!({ + "keystore": dir.display().to_string(), "name": r.name, "role": r.role, "signs_with": signs_with, + "versions": r.versions.iter().map(version_json).collect::>(), + }); + done("key.show", false, data, &text) +} + +/// `wai key rotate [--store keychain|file] [--keystore DIR]`: a successor under a new key +/// id, the old version retired now and its secret destroyed (jwp-receipts §7.6). +#[cfg(feature = "quickstart")] +pub fn rotate(args: &[String]) -> R { + let usage = super::usage("key.rotate"); + let with_value = ["--store", "--keystore"]; + let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?; + let name = name_arg(&pos, &usage)?; + let dir = keystore(super::opt(args, "--keystore"))?; + let mut r = read_record(&dir, name)?; + let old = r.versions.last().expect("a version").clone(); + let keep = old.store.as_deref().and_then(Store::parse).unwrap_or_else(Store::default_here); + let store = store_arg(args, keep, &usage)?; + let id = format!("{name}.{}", r.versions.len() + 1); + let at = now(); + let retire = old.status == "active"; + let mut what = format!("the key {name} would be rotated: {id} made"); + if retire { + what.push_str(&format!(", {} retired now", old.id)); + } + if old.store.is_some() { + what.push_str(&format!(" and its secret destroyed ({})", old.store.as_deref().unwrap_or(""))); + } + agent::confirm(&what, vec![json!({"rotate": name, "new": id, "retire": retire.then_some(&old.id), "until": retire.then_some(&at), "destroy_secret": old.store.is_some().then_some(&old.id)})])?; + let kc = Keychain::system(); + if out::dry_run() { + plan_keep(&dir, &id, store); + out::would_write(record_path(&dir, name)); + let mut o = old.clone(); + destroy_secret(&dir, &mut o, &kc)?; + let data = json!({"name": name, "previous": old.id, "id": id, "key": null, "until": retire.then_some(&at), "store": store.label()}); + return done("key.rotate", true, data, &format!("would rotate {name}: {id} made, {} retired", old.id)); + } + let mut seed = fresh_seed()?; + let pk = hex(&ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes()); + let new = Version { id: id.clone(), key: pk, created: at.clone(), status: "active".into(), until: None, compromised_since: None, store: Some(store.label().into()), published_test_seed: false }; + let kept = keep_secret(&dir, &new, store, &seed, &kc); + seed.fill(0); + kept?; + if retire { + let last = r.versions.last_mut().expect("a version"); + last.status = "retired".into(); + last.until = Some(at.clone()); + } + r.versions.push(new.clone()); + if let Err(e) = write_record(&dir, &r) { + let mut n = new; + let _ = destroy_secret(&dir, &mut n, &kc); + return Err(e); + } + // The record names the old secret until it is gone, so it never says a secret is destroyed + // that is not. + let i = r.versions.len() - 2; + let destroyed = r.versions[i].store.is_some(); + destroy_secret(&dir, &mut r.versions[i], &kc)?; + write_record(&dir, &r)?; + let did = wai::signer_id::did_key(&unhex32(&new.key).expect("hex")); + let data = json!({ + "name": name, "previous": old.id, "until": retire.then_some(&at), "secret_destroyed": destroyed, + "id": new.id, "key": new.key, "did_key": did, "store": store.label(), "keystore": dir.display().to_string(), + }); + let text = format!( + "rotated {name}: {} signs from now, Ed25519 {}\n {did}\n its secret is in {}; {}{}\n\ + an origin's key log lists it with: wai-keys append LOG ... --add $(wai key export {name} --format wai-keys --may-sign ){}", + new.id, + new.key, + where_kept_at(store, &dir, &new.id), + if retire { format!("{} is retired from {at}", old.id) } else { format!("{} stays {}", old.id, old.status) }, + if destroyed { ", and its secret is destroyed" } else { "" }, + if retire { format!(" --retire {}@{at}", old.id) } else { String::new() }, + ); + done("key.rotate", true, data, &text) +} + +/// `wai key revoke [--id ] [--compromised-since ] [--keystore DIR]`. +#[cfg(feature = "quickstart")] +pub fn revoke(args: &[String]) -> R { + let usage = super::usage("key.revoke"); + let with_value = ["--id", "--compromised-since", "--keystore"]; + let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?; + let name = name_arg(&pos, &usage)?; + let dir = keystore(super::opt(args, "--keystore"))?; + let mut r = read_record(&dir, name)?; + let i = match super::opt(args, "--id") { + None => r.versions.len() - 1, + Some(id) => r.versions.iter().position(|v| v.id == id).ok_or_else(|| { + OpError::new(EXIT_NOT_FOUND, "key-not-found", format!("the key {name} has no version {id}"), format!("wai key show {name} lists its versions")) + })?, + }; + let since = match super::opt(args, "--compromised-since") { + Some(t) if log_time_ok(t) => t.to_string(), + Some(t) => return Err(OpError::usage(format!("--compromised-since {t}: a date-time in UTC, YYYY-MM-DDTHH:MM:SSZ"), &usage)), + None => now(), + }; + let v = r.versions[i].clone(); + if v.status == "revoked" { + return Err(OpError::new( + EXIT_USAGE, + "key-already-revoked", + format!("{} was revoked already, compromised since {}", v.id, v.compromised_since.as_deref().unwrap_or("?")), + "a revocation's time never changes once set (jwp-receipts §7.2 rule 6)", + )); + } + let mut what = format!("{} would be revoked, compromised since {since}", v.id); + if v.store.is_some() { + what.push_str(&format!(", and its secret destroyed ({})", v.store.as_deref().unwrap_or(""))); + } + agent::confirm(&what, vec![json!({"revoke": v.id, "compromised_since": since, "destroy_secret": v.store.is_some()})])?; + let kc = Keychain::system(); + if out::dry_run() { + out::would_write(record_path(&dir, name)); + let mut o = v.clone(); + destroy_secret(&dir, &mut o, &kc)?; + return done("key.revoke", true, json!({"name": name, "id": v.id, "compromised_since": since}), &format!("would revoke {}", v.id)); + } + r.versions[i].status = "revoked".into(); + r.versions[i].compromised_since = Some(since.clone()); + write_record(&dir, &r)?; + let destroyed = r.versions[i].store.is_some(); + destroy_secret(&dir, &mut r.versions[i], &kc)?; + write_record(&dir, &r)?; + let data = json!({"name": name, "id": v.id, "key": v.key, "compromised_since": since, "secret_destroyed": destroyed, "keystore": dir.display().to_string()}); + let newest = r.versions.last().expect("a version"); + let text = format!( + "revoked {} (Ed25519 {}), compromised since {since}{}\n{}\n\ + an origin's key log revokes it with: wai-keys append LOG ... --revoke {}@{since}", + v.id, + v.key, + if destroyed { "; its secret is destroyed" } else { "" }, + if newest.status == "active" { format!("--key {name} signs with {}", newest.id) } else { format!("--key {name} signs nothing until wai key rotate {name} makes a successor") }, + v.id, + ); + done("key.revoke", true, data, &text) +} + +/// Whether `a` is an authority of jwp-receipts §7.2: segments of `A-Z a-z 0-9 - _ .` joined by +/// `/`, at most 128 characters. +fn authority_ok(a: &str) -> bool { + !a.is_empty() + && a.len() <= 128 + && a.split('/').all(|s| !s.is_empty() && s.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_.".contains(&c))) +} + +/// `wai key export [--format hex|did-key|entry|wai-keys] [--may-sign A,...] [--out F] [--keystore DIR]`: +/// a key's public halves, never its secret. +#[cfg(feature = "quickstart")] +pub fn export(args: &[String]) -> R { + let usage = super::usage("key.export"); + let with_value = ["--format", "--may-sign", "--out", "--keystore"]; + let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?; + let name = name_arg(&pos, &usage)?; + let format = super::opt(args, "--format").unwrap_or("hex"); + let may_sign: Option> = super::opt(args, "--may-sign").map(|m| m.split(',').map(str::trim).collect()); + if let Some(m) = &may_sign + && let Some(bad) = m.iter().find(|a| !authority_ok(a)) + { + return Err(OpError::usage(format!("--may-sign {bad}: an authority is segments of A-Z, a-z, 0-9, -, _ and ., joined by / (jwp-receipts §7.5)"), &usage)); + } + let dir = keystore(super::opt(args, "--keystore"))?; + let r = read_record(&dir, name)?; + let v = r.versions.last().expect("a version"); + let need_authorities = |f: &str| -> Result, OpError> { + may_sign.clone().ok_or_else(|| OpError::usage(format!("--format {f} needs --may-sign: the authorities an origin gives the key (jwp-receipts §7.5), comma-separated"), &usage)) + }; + let mut data = json!({"name": name, "id": v.id, "format": format}); + let text = match format { + "hex" | "did-key" => { + if may_sign.is_some() { + return Err(OpError::usage("--may-sign goes with --format entry or wai-keys", &usage)); + } + if format == "hex" { format!("{}\n", v.key) } else { format!("{}\n", wai::signer_id::did_key(&unhex32(&v.key).expect("hex"))) } + } + "entry" => { + let m = need_authorities(format)?; + let entries: Vec = r + .versions + .iter() + .map(|v| json!({"id": v.id, "key": v.key, "may_sign": m, "status": v.status, "until": v.until, "compromised_since": v.compromised_since})) + .collect(); + data["entries"] = Value::Array(entries.clone()); + serde_json::to_string_pretty(&entries).expect("JSON") + "\n" + } + "wai-keys" => { + let m = need_authorities(format)?; + if v.status != "active" { + return Err(OpError::new(EXIT_USAGE, "key-revoked", format!("{} is {}: an origin lists no new entry for it", v.id, v.status), format!("wai key rotate {name} makes a successor"))); + } + format!("id={},key={},may_sign={}\n", v.id, v.key, m.join("|")) + } + f => return Err(OpError::usage(format!("--format {f}: hex, did-key, entry or wai-keys"), &usage)), + }; + data["text"] = json!(text); + match super::opt(args, "--out") { + Some(f) => { + if Path::new(f).exists() { + agent::confirm(&format!("{f} exists, and the export would replace it"), vec![json!({"write": f, "replaces": true})])?; + } + out::write(f, &text).map_err(|e| io_fail(Path::new(f), &e))?; + data["file"] = json!(f); + done("key.export", true, data, &format!("the public half of {} ({format}) {} {f}", v.id, if out::dry_run() { "would go to" } else { "written to" })) + } + None => done("key.export", false, data, text.trim_end()), + } +} + +#[cfg(not(feature = "quickstart"))] +macro_rules! not_in_build { + ($($f:ident => $id:literal),*) => {$( + pub fn $f(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op($id).expect("listed"))) + } + )*}; +} +#[cfg(not(feature = "quickstart"))] +not_in_build!(create => "key.create", list => "key.list", show => "key.show", rotate => "key.rotate", revoke => "key.revoke", import => "key.import", export => "key.export"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_name_is_lowercase_and_short_enough_for_its_key_ids() { + for n in ["signer", "a", "room-1", "dev_key", "0day"] { + assert!(name_ok(n), "{n}"); + } + for n in ["", "Signer", "-x", "_x", "a.b", "a/b", "a b", &"x".repeat(NAME_MAX + 1)] { + assert!(!name_ok(n), "{n:?}"); + } + // The longest name's ids stay key ids of jwp-receipts §7.1 (at most 64 characters). + assert!(format!("{}.9999999", "x".repeat(NAME_MAX)).len() <= 64); + } + + #[test] + fn a_log_date_time_is_the_key_logs() { + for t in ["2026-11-05T10:00:00Z", "2024-02-29T23:59:59Z", "2000-02-29T00:00:00Z"] { + assert!(log_time_ok(t), "{t}"); + } + for t in ["2026-11-05T10:00:00", "2026-11-05 10:00:00Z", "2026-11-05T10:00:60Z", "2026-02-29T00:00:00Z", "1900-02-29T00:00:00Z", "2026-13-01T00:00:00Z", "2026-11-05T10:00:00.5Z", "2026-11-05t10:00:00z"] { + assert!(!log_time_ok(t), "{t}"); + } + } + + #[test] + fn an_authority_is_jwp_receipts() { + for a in ["delivery", "energy/OnChipCounter", "live/room", "module/decoder", "a.b-c_d"] { + assert!(authority_ok(a), "{a}"); + } + for a in ["", "/x", "x/", "a//b", "a b", "a|b", &"a".repeat(129)] { + assert!(!authority_ok(a), "{a:?}"); + } + } + + fn version(id: &str, status: &str, until: Option<&str>, since: Option<&str>) -> Version { + Version { + id: id.into(), + key: "ab".repeat(32), + created: "2026-11-05T10:00:00Z".into(), + status: status.into(), + until: until.map(str::to_string), + compromised_since: since.map(str::to_string), + store: Some("file".into()), + published_test_seed: false, + } + } + + #[test] + fn a_record_holds_to_the_key_logs_rules() { + let t = "2026-11-05T11:00:00Z"; + let ok = Record { format: FORMAT.into(), name: "k".into(), role: "signer".into(), versions: vec![version("k", "retired", Some(t), None), version("k.2", "active", None, None)] }; + assert_eq!(check_record(&ok, "k"), Ok(())); + let broken = |f: &dyn Fn(&mut Record)| { + let mut r = ok.clone(); + f(&mut r); + check_record(&r, "k").unwrap_err() + }; + assert!(broken(&|r| r.format = "wai.key/2".into()).contains("format")); + assert!(broken(&|r| r.role = "boss".into()).contains("role")); + assert!(broken(&|r| r.versions[1].id = "k.3".into()).contains("not k.2")); + assert!(broken(&|r| r.versions[0].status = "active".into()).contains("status")); + assert!(broken(&|r| r.versions[1].until = Some(t.into())).contains("status")); + assert!(broken(&|r| r.versions[0].key = "AB".repeat(32)).contains("lowercase")); + assert!(broken(&|r| r.versions[1].store = Some("cloud".into())).contains("kept")); + assert!(broken(&|r| r.versions.clear()).contains("no version")); + // A revoked version keeps the time it was retired at. + let mut rev = ok.clone(); + rev.versions[0].status = "revoked".into(); + rev.versions[0].compromised_since = Some(t.into()); + assert_eq!(check_record(&rev, "k"), Ok(())); + // And a record names the file it is in. + assert!(check_record(&ok, "other").unwrap_err().contains("names the key")); + } + + /// The keychain, through a stand-in for macOS's `security` that keeps each generic password in + /// a file and logs every command line it is given: the secret goes in on standard input and + /// comes back, never on a command line, and a removed one is gone. + #[cfg(unix)] + #[test] + fn the_keychain_takes_a_secret_on_standard_input_alone() { + use std::os::unix::fs::PermissionsExt; + let d = std::env::temp_dir().join(format!("wai-key-keychain-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let tool = d.join("security"); + std::fs::write( + &tool, + r#"#!/bin/sh +d="$(dirname "$0")/items"; mkdir -p "$d" +echo "$*" >> "$(dirname "$0")/argv.log" +item() { svc=; acct=; while [ $# -gt 0 ]; do case "$1" in -s) svc=$2; shift 2;; -a) acct=$2; shift 2;; -l) shift 2;; -w) pw=$2; shift; [ -n "$pw" ] && shift;; *) shift;; esac; done; } +if [ "$1" = "-i" ]; then + while read -r cmd rest; do + [ "$cmd" = add-generic-password ] || { echo "unknown command $cmd" >&2; continue; } + pw=; item $rest + case "$acct" in fa*) echo "add-generic-password: returned -25299" >&2; status=45; continue;; esac + printf '%s' "$pw" > "$d/$svc.$acct" + done + exit ${status:-0} +fi +cmd=$1; shift; item "$@"; f="$d/$svc.$acct" +case "$cmd" in + find-generic-password) [ -f "$f" ] || { echo "security: The specified item could not be found in the keychain." >&2; exit 44; }; cat "$f"; echo;; + delete-generic-password) [ -f "$f" ] || exit 44; rm "$f";; + *) exit 2;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + let kc = Keychain::with_tool(&tool); + let (account, secret) = ("cd".repeat(32), "5e".repeat(32)); + assert_eq!(kc.find(&account).unwrap(), None); + kc.add(&account, "wai.key.k", &secret).unwrap(); + assert_eq!(kc.find(&account).unwrap().as_deref(), Some(secret.as_str())); + let log = std::fs::read_to_string(d.join("argv.log")).unwrap(); + assert!(!log.contains(&secret), "the secret was on a command line: {log}"); + assert!(log.lines().any(|l| l == "-i"), "{log}"); + assert!(kc.delete(&account).unwrap()); + assert_eq!(kc.find(&account).unwrap(), None); + assert!(!kc.delete(&account).unwrap()); + // A command that fails inside `-i` ends the tool with its status, as the real one does. + let failed = kc.add(&"fa".repeat(32), "wai.key.k", &secret).unwrap_err(); + assert_eq!((failed.exit, failed.code), (EXIT_PERMISSION, "keychain-unavailable")); + assert!(failed.message.contains("-25299") && !failed.message.contains(&secret), "{}", failed.message); + // A tool that is not there is the keychain unavailable (exit 3). + let none = Keychain::with_tool(&d.join("no-such-tool")); + assert_eq!(none.find(&account).unwrap_err().exit, EXIT_PERMISSION); + let _ = std::fs::remove_dir_all(&d); + } + + /// The real `/usr/bin/security`, in a scratch keychain made for this test and removed in every + /// case. Nothing here names the login keychain, and every command the wrapper below passes on + /// names the scratch one, so none can fall through to the login keychain, the default. It needs + /// no dialog: the scratch keychain is unlocked with its throwaway password, and each of its + /// items is made by the tool that reads it. + #[cfg(all(target_os = "macos", feature = "quickstart"))] + #[test] + fn the_real_keychain_keeps_reads_replaces_and_removes_a_secret() { + use std::os::unix::fs::PermissionsExt; + let security = |args: &[&str]| Command::new(SECURITY).args(args).stdin(Stdio::null()).output().expect("/usr/bin/security runs"); + let search_state = || (security(&["list-keychains"]).stdout, security(&["default-keychain"]).stdout); + let before = search_state(); + let d = std::env::temp_dir().join(format!("wai-key-real-keychain-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let keychain = d.join("scratch.keychain-db"); + let scratch = keychain.to_str().expect("a temporary path of UTF-8"); + + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let throwaway = format!("throwaway-{}", std::process::id()); + let o = security(&["create-keychain", "-p", &throwaway, scratch]); + assert!(o.status.success(), "create-keychain: {}", String::from_utf8_lossy(&o.stderr)); + let o = security(&["unlock-keychain", "-p", &throwaway, scratch]); + assert!(o.status.success(), "unlock-keychain: {}", String::from_utf8_lossy(&o.stderr)); + + // Each command to the real tool, naming the scratch keychain: as the last argument of + // the two it runs directly, and at the end of each line it gives `-i`. Anything else is + // refused, so the code under test cannot reach another keychain through it. + let wrapper = d.join("security"); + let log = d.join("argv.log"); + std::fs::write( + &wrapper, + format!( + r#"#!/bin/sh +printf '%s\n' "$*" >> '{log}' +case "$1" in + -i) while IFS= read -r line; do printf '%s %s\n' "$line" '{scratch}'; done | {SECURITY} -i; exit $? ;; + find-generic-password|delete-generic-password) exec {SECURITY} "$@" '{scratch}' ;; + *) echo "the test's wrapper passes on no $1" >&2; exit 2 ;; +esac +"#, + log = log.display(), + ), + ) + .unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let kc = Keychain::with_tool(&wrapper); + + let (account, one, two) = ("cd".repeat(32), "5e".repeat(32), "6f".repeat(32)); + // None there: find answers None (the tool's exit 44), and so does a removal. + assert_eq!(kc.find(&account).unwrap(), None); + assert!(!kc.delete(&account).unwrap()); + // Kept, and read back. + kc.add(&account, "wai.key.k", &one).unwrap(); + assert_eq!(kc.find(&account).unwrap().as_deref(), Some(one.as_str())); + // In the scratch keychain, by the tool itself. + let o = security(&["find-generic-password", "-s", SERVICE, "-a", &account, scratch]); + assert!(o.status.success(), "the item is not in the scratch keychain: {}", String::from_utf8_lossy(&o.stderr)); + // Replaced: `-U` updates the item, and no second one is made. + kc.add(&account, "wai.key.k", &two).unwrap(); + assert_eq!(kc.find(&account).unwrap().as_deref(), Some(two.as_str())); + let dump = String::from_utf8_lossy(&security(&["dump-keychain", scratch]).stdout).into_owned(); + assert_eq!(dump.matches(SERVICE).count(), 1, "one item: {dump}"); + // Neither secret was ever a process argument. + let argv = std::fs::read_to_string(&log).unwrap(); + assert!(!argv.contains(&one) && !argv.contains(&two), "a secret was on a command line: {argv}"); + assert!(argv.lines().any(|l| l == "-i"), "{argv}"); + // Removed: find answers None, and a second removal finds nothing. + assert!(kc.delete(&account).unwrap()); + assert_eq!(kc.find(&account).unwrap(), None); + assert!(!kc.delete(&account).unwrap()); + + // A key through the store's own route: kept, loaded against its public key, destroyed. + let seed: [u8; 32] = core::array::from_fn(|i| 100 + i as u8); + let pk = hex(&ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes()); + let mut v = Version { id: "k".into(), key: pk, created: "2026-11-05T10:00:00Z".into(), status: "active".into(), until: None, compromised_since: None, store: Some("keychain".into()), published_test_seed: false }; + keep_secret(&d, &v, Store::Keychain, &seed, &kc).unwrap(); + assert_eq!(load_seed(&d, &v, &kc).unwrap(), seed); + destroy_secret(&d, &mut v, &kc).unwrap(); + assert_eq!(v.store, None); + assert_eq!(kc.find(&v.key).unwrap(), None); + })); + + // In every case, a failure above included: the keychain removed, and the search list and + // the default as they were. + let deleted = security(&["delete-keychain", scratch]); + let _ = std::fs::remove_dir_all(&d); + let after = search_state(); + if let Err(p) = outcome { + if after != before { + eprintln!("the search list or the default keychain changed, as well as the failure below"); + } + std::panic::resume_unwind(p); + } + assert!(deleted.status.success(), "delete-keychain: {}", String::from_utf8_lossy(&deleted.stderr)); + assert_eq!(after, before, "the search list and the default keychain are as they were"); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/lookup.rs b/wai/wai-rs/src/bin/wai_cli/dx/lookup.rs new file mode 100644 index 000000000..d982e0c11 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/lookup.rs @@ -0,0 +1,111 @@ +//! `wai docs search` (`docs.search`) and `wai registry show` (`registry.show`): the command line's +//! door to two operations `wai mcp` serves. Each runs the same tool (`../mcp/tools.rs`) and prints +//! its answer: the envelope's `data` with `--json`, lines for a person otherwise. Nothing leaves +//! the machine. + +#![cfg_attr(not(feature = "mcp"), allow(dead_code, unused_imports))] + +use super::envelope::{self as agent, EXIT_NOT_FOUND, OpError}; +use serde_json::{Value, json}; +use std::path::PathBuf; + +type R = Result; + +/// A tool's error, as the command line's: a specification that is not here, or a capability the +/// registry does not hold, is not found (exit 5). +fn failure(op: &str, e: String) -> OpError { + if e.contains("no specification tree") || e.contains("holds no SPEC.md") { + let m = e.split(": start the server").next().unwrap_or(&e).to_string(); + return OpError::new(EXIT_NOT_FOUND, "spec-not-found", format!("{op}: {m}"), "give --spec-root , or set WAI_SPEC_ROOT"); + } + if let Some((c, _)) = e.split_once(" is not in the registry") { + return OpError::new(EXIT_NOT_FOUND, "capability-not-found", format!("{c} is not in the registry"), "wai capabilities lists them"); + } + OpError::from(e) +} + +/// The tool's answer, as the envelope with `--json` and as `text` of it otherwise. +#[cfg(feature = "mcp")] +fn answer(op: &str, args: &Value, spec_root: Option, text: impl Fn(&Value) -> String) -> R { + let v = super::mcp::run_once(op, args, spec_root).map_err(|e| failure(op, e))?; + if agent::ctx().json { + agent::print(&agent::envelope_with(op, true, v, None)); + } else { + println!("{}", text(&v)); + } + Ok(true) +} + +/// `wai docs search ... [--limit N] [--spec-root ]`. +#[cfg(feature = "mcp")] +pub fn docs_search(args: &[String]) -> R { + let usage = super::usage("docs.search"); + let with_value = ["--limit", "--spec-root"]; + let words = agent::check_args(args, &with_value, &with_value, usize::MAX, &usage)?; + if words.is_empty() { + return Err(OpError::usage("the words to search for", &usage)); + } + let mut a = json!({"query": words.join(" ")}); + if let Some(l) = super::opt(args, "--limit") { + let n = l.parse::().ok().filter(|n| *n >= 1).ok_or_else(|| OpError::usage(format!("--limit {l}: a whole number, at least 1"), &usage))?; + a["limit"] = json!(n); + } + answer("docs.search", &a, super::opt(args, "--spec-root").map(PathBuf::from), |v| { + let hits = v["hits"].as_array().cloned().unwrap_or_default(); + let more = if v["truncated"] == true { "; there may be more (--limit)" } else { "" }; + let mut t = format!("{} line(s) in {} file(s) under {}{more}", hits.len(), v["files_searched"], v["root"].as_str().unwrap_or("?")); + for h in &hits { + t.push_str(&format!("\n{}:{} {}\n {}", h["file"].as_str().unwrap_or("?"), h["line"], h["section"].as_str().unwrap_or(""), h["text"].as_str().unwrap_or(""))); + } + t + }) +} + +/// `wai registry show [--spec-root ]`. +#[cfg(feature = "mcp")] +pub fn registry_show(args: &[String]) -> R { + let usage = super::usage("registry.show"); + let with_value = ["--spec-root"]; + let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?; + let c = *pos.first().ok_or_else(|| OpError::usage("the capability", &usage))?; + answer("registry.show", &json!({"capability": c}), super::opt(args, "--spec-root").map(PathBuf::from), |v| { + let r = &v["registry"]; + let member = |k: &str| r[k].as_str().unwrap_or("none").to_string(); + let yes = |k: &str| if v[k] == true { "yes" } else { "no" }; + let mut t = format!( + "{c}\n kind {}, status {}, media {}, role {}, tier {}, licence {}, replicate {}\n {}\n decoded natively by this build: {}; dispatched standalone: {}; companion only: {}; a candidate: {}", + member("kind"), + member("status"), + member("media"), + member("role"), + member("tier"), + member("licence"), + member("replicate"), + member("spec_section"), + yes("decoded_natively_here"), + yes("dispatched_standalone"), + yes("companion_only"), + yes("candidate"), + ); + match v["spec_lines"].as_array() { + Some(lines) => { + t.push_str("\n in the specification:"); + for h in lines { + t.push_str(&format!("\n {}:{} {}", h["file"].as_str().unwrap_or("?"), h["line"], h["text"].as_str().unwrap_or(""))); + } + } + None => t.push_str("\n the specification's text is not here (--spec-root, or WAI_SPEC_ROOT)"), + } + t + }) +} + +#[cfg(not(feature = "mcp"))] +pub fn docs_search(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("docs.search").expect("listed"))) +} + +#[cfg(not(feature = "mcp"))] +pub fn registry_show(_: &[String]) -> R { + Err(crate::door::not_built(wai::catalogue::op("registry.show").expect("listed"))) +} diff --git a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs index aed0a6449..f4c7cf15f 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs @@ -34,6 +34,12 @@ //! `statement.check` (`wai statement check`): the ad opportunities a channel's signed records //! prove for an owner, and a platform's reported figures checked against them //! (extensions/opportunity-report.md §12; `opportunity.rs`). +//! - `key.create`, `key.list`, `key.show`, `key.rotate`, `key.revoke`, `key.import` and +//! `key.export` (`wai key create|list|show|rotate|revoke|import|export`): a developer's signing +//! keys in one keystore, each named, so that every signing operation's `--key` takes its name +//! (`key.rs`). +//! - `docs.search` (`wai docs search`) and `registry.show` (`wai registry show`): the specification +//! searched and one capability shown, by the tools `wai mcp` serves them with (`lookup.rs`). //! //! [`envelope`] holds the agent rules every command keeps: the exit codes, the JSON envelope, //! `--dry-run`, `--yes`. @@ -58,6 +64,9 @@ mod hello; mod opportunity; mod share; mod verify; +// A developer's signing keys (`wai key`), and the keys every signing operation's `--key` names. +pub(crate) mod key; +mod lookup; #[cfg(feature = "mcp")] #[path = "../mcp/mod.rs"] @@ -101,6 +110,17 @@ pub const VERBS: &[Impl] = &[ Impl { id: "opportunity.create", run: opportunity::opportunity_create, json: Json::Native }, Impl { id: "opportunity.verify", run: opportunity::opportunity_verify, json: Json::Native }, Impl { id: "statement.check", run: opportunity::statement_check, json: Json::Native }, + // A developer's signing keys, in one keystore (`key.rs`). + Impl { id: "key.create", run: key::create, json: Json::Native }, + Impl { id: "key.list", run: key::list, json: Json::Native }, + Impl { id: "key.show", run: key::show, json: Json::Native }, + Impl { id: "key.rotate", run: key::rotate, json: Json::Native }, + Impl { id: "key.revoke", run: key::revoke, json: Json::Native }, + Impl { id: "key.import", run: key::import, json: Json::Native }, + Impl { id: "key.export", run: key::export, json: Json::Native }, + // Two operations whose tool `wai mcp` also serves (`lookup.rs`). + Impl { id: "docs.search", run: lookup::docs_search, json: Json::Native }, + Impl { id: "registry.show", run: lookup::registry_show, json: Json::Native }, ]; /// An operation's synopsis, from the catalogue, for a usage error. diff --git a/wai/wai-rs/src/bin/wai_cli/dx/share.rs b/wai/wai-rs/src/bin/wai_cli/dx/share.rs index 47f2e605d..d74f72a33 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/share.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/share.rs @@ -59,22 +59,11 @@ pub(super) fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } -/// A key file: 32 bytes, or 64 hexadecimal digits. +/// A signing key by its name in the keystore, or from a key file (32 bytes, or 64 hexadecimal +/// digits): what `--key` takes on every signing operation. #[cfg(feature = "revenue_share")] -pub(super) fn read_key(path: &str, what: &str) -> Result { - let raw = std::fs::read(path).map_err(|e| io_fail(path, &e, what))?; - let seed: [u8; 32] = if raw.len() == 32 { - raw.try_into().expect("32") - } else { - std::str::from_utf8(&raw) - .ok() - .map(str::trim) - .filter(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit())) - .map(|s| (0..32).map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).expect("hex")).collect::>()) - .and_then(|b| b.try_into().ok()) - .ok_or_else(|| OpError::usage(format!("{path}: 32 bytes, or 64 hexadecimal digits"), what))? - }; - Ok(ed25519_dalek::SigningKey::from_bytes(&seed)) +pub(super) fn read_key(arg: &str, what: &str) -> Result { + super::key::signing_key(arg, false).map_err(|e| if e.hint.is_empty() { e.with_hint(what) } else { e }) } #[cfg(feature = "revenue_share")] diff --git a/wai/wai-rs/src/bin/wai_cli/encode.rs b/wai/wai-rs/src/bin/wai_cli/encode.rs index 43de7a1e2..18ba50a9a 100644 --- a/wai/wai-rs/src/bin/wai_cli/encode.rs +++ b/wai/wai-rs/src/bin/wai_cli/encode.rs @@ -18,19 +18,6 @@ fn write(p: &str, b: &[u8]) -> Result<(), String> { crate::out::write(p, b).map_err(|e| format!("{p}: {e}")) } -fn seed(s: &str) -> Result<[u8; 32], String> { - let text = if s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()) { s.to_string() } else { String::from_utf8_lossy(&read(Path::new(s))?).trim().to_string() }; - let ok = text.len() == 64 && text.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)); - if !ok { - return Err("--key is 64 lowercase hexadecimal digits, or a file holding them".into()); - } - let mut out = [0u8; 32]; - for (i, o) in out.iter_mut().enumerate() { - *o = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).map_err(|e| e.to_string())?; - } - Ok(out) -} - fn options(args: &[String]) -> Result { let hardware = match opt(args, "--hardware") { None => None, @@ -145,7 +132,7 @@ pub fn encode(args: &[String]) -> Result<(), String> { println!("energy {} µJ ({}, {}, {}, counter {}, ±{} ppm over {} µs)", e.joules_micro, e.class, e.coverage, e.attribution, e.counter, e.uncertainty.map_or(0, |u| u.0), e.uncertainty.map_or(0, |u| u.1)); } if let Some(path) = opt(rest, "--record") { - let key = ed25519_dalek::SigningKey::from_bytes(&seed(opt(rest, "--key").ok_or("--record needs --key")?)?); + let key = crate::dx::key::signing_key(opt(rest, "--key").ok_or("--record needs --key")?, true).map_err(|e| e.message)?; let issued = opt(rest, "--issued-at").map(String::from).unwrap_or_else(record::now_rfc3339); let body = record::body(&enc, &prepared.cfg, &prepared.source, &idr, energy.as_ref(), None, &issued); let signed = record::sign(body, &key); diff --git a/wai/wai-rs/src/bin/wai_cli/http_playlists.rs b/wai/wai-rs/src/bin/wai_cli/http_playlists.rs index 0d448e609..5daf1447d 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_playlists.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_playlists.rs @@ -10,7 +10,7 @@ //! left out: the classical MPD's period, timescale, t and d (its first period and //! the first adaptation set with a timeline, or those named), checked adjacent to //! it; with no classical MPD, the classical HLS timeline at timescale 1000 from 0 -//! wai http seal-playlist --key +//! wai http seal-playlist --key //! [--signer-id ID] [--content-id ID] [--parent ] //! [--grant ] [--out ] //! seal a WAI playlist in its rendered form, under the publisher's grant (a JSON @@ -239,20 +239,13 @@ pub fn mpd(args: &[String]) -> Result<(), String> { Ok(()) } -/// A 32-byte seed: 64 hexadecimal digits, given or in a file. -#[cfg(feature = "video_manifest")] -fn seed(s: &str) -> Result<[u8; 32], String> { - let text = if wai::http_binding::unhex32(s.trim()).is_some() { s.to_string() } else { String::from_utf8_lossy(&read(Path::new(s))?).to_string() }; - wai::http_binding::unhex32(text.trim()).ok_or_else(|| "--key is 64 lowercase hexadecimal digits, or a file holding them".to_string()) -} - #[cfg(feature = "video_manifest")] pub fn seal(args: &[String]) -> Result<(), String> { use wai::http_binding::playlist_receipt::seal_playlist; use wai::wai_video_manifest::ManifestReceipt; let path = args.first().filter(|a| !a.starts_with("--")).ok_or("seal-playlist needs the playlist")?; let text = String::from_utf8(read(Path::new(path))?).map_err(|_| format!("{path}: not UTF-8"))?; - let key = ed25519_dalek::SigningKey::from_bytes(&seed(opt(args, "--key").ok_or("seal-playlist needs --key")?)?); + let key = crate::dx::key::signing_key(opt(args, "--key").ok_or("seal-playlist needs --key")?, true).map_err(|e| e.message)?; let parent = opt(args, "--parent").map(|p| ManifestReceipt::from_json(&String::from_utf8_lossy(&read(Path::new(p))?)).map_err(|e| format!("{p}: {e}"))).transpose()?; let stem = Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("playlist"); // The publisher's grant (Appendix H's form), or none: a grant with no ceilings. diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs b/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs index 78ea3e7a4..08955047c 100644 --- a/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs @@ -367,6 +367,43 @@ fn options(args: &[String]) -> Result { Ok(Options { toolsets, spec_root, room_bin, project, read_only }) } +/// One tool that only reads, run once for the command line's door to the same operation: one +/// implementation behind both doors. `spec_root` is what `--spec-root` is to the server, and the +/// specification's text is found the same way without it. +pub(crate) fn run_once(op: &str, args: &Value, spec_root: Option) -> Result { + let project = std::env::current_dir().map_err(|e| format!("the current directory: {e}"))?; + let mut server = Server::new(vec![tools::Toolset::Core], spec_root, None, project, true); + tools::call(&mut server, &op.replace('.', "_"), args).unwrap_or_else(|| Err(format!("{op}: wai mcp offers no such tool to read with"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The operations with a door here and a tool there take the catalogue's input in both: the + /// tool's arguments are the catalogue's, less what the command line alone takes (the server has + /// its own `--spec-root`). + #[test] + fn an_operation_of_both_doors_takes_the_catalogues_input_in_both() { + for id in ["docs.search", "registry.show"] { + let o = wai::catalogue::op(id).expect("a catalogue operation"); + assert!(o.mcp && o.cli.is_some(), "{id} has both doors"); + let t = tools::TOOLS.iter().find(|t| t.op == id).expect("a tool"); + let schema = (t.schema)(); + let mut have: Vec<&str> = schema["properties"].as_object().unwrap().keys().map(String::as_str).collect(); + let mut want: Vec<&str> = o.input.iter().map(|a| a.name).filter(|n| *n != "spec_root").collect(); + have.sort(); + want.sort(); + assert_eq!(have, want, "{id}: the tool's arguments are the catalogue's input"); + let mut req: Vec<&str> = schema["required"].as_array().unwrap().iter().map(|r| r.as_str().unwrap()).collect(); + let mut want_req: Vec<&str> = o.input.iter().filter(|a| a.required).map(|a| a.name).collect(); + req.sort(); + want_req.sort(); + assert_eq!(req, want_req, "{id}: the required arguments"); + } + } +} + /// `wai mcp --dry-run`: the tools it would serve, and nothing served (no bridge started). pub fn plan(args: &[String]) -> Result { let o = options(args)?; diff --git a/wai/wai-rs/src/bin/wai_cli/out.rs b/wai/wai-rs/src/bin/wai_cli/out.rs index b68c74951..6779a12a5 100644 --- a/wai/wai-rs/src/bin/wai_cli/out.rs +++ b/wai/wai-rs/src/bin/wai_cli/out.rs @@ -3,6 +3,11 @@ //! what the command reports it would have done. A request that would change a service the //! command line names (a fleet service) is named in the plan the same way, and not sent. +// A secret's file on Windows: an access list for this account alone. Here because every binary +// that includes this file makes its private files through it. +#[path = "win_acl.rs"] +pub mod win_acl; + use serde_json::{Value, json}; use std::io::{self, Write}; use std::path::Path; @@ -27,7 +32,8 @@ fn note(v: Value) { } /// What a dry run would have done, in order: `{"write": path, "bytes": n}`, -/// `{"create-dir": path}`, `{"remove": path}`, `{"request": "METHOD /path", "target": url}`. +/// `{"create-dir": path}`, `{"remove": path}`, `{"request": "METHOD /path", "target": url}`, +/// `{"keychain": "add" | "delete", "service", "id"}`. pub fn plan() -> Vec { PLAN.lock().unwrap_or_else(|e| e.into_inner()).clone() } @@ -78,6 +84,77 @@ pub fn write_new(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result< f.write_all(b) } +/// A new file only its owner may read or write, which must not exist yet, open for writing: mode +/// 0600 on Unix, and on Windows an access list for this account alone (`win_acl.rs`). The one call +/// that makes the file sets its protection, so the file is never under anything wider, not even +/// before a byte is in it, and nothing can hold it open from a moment when it was. +fn create_private(p: &Path) -> io::Result { + #[cfg(windows)] + { + win_acl::create_new_file(p) + } + #[cfg(not(windows))] + { + let mut o = std::fs::OpenOptions::new(); + o.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + o.open(p) + } +} + +/// A new file only its owner may read or write (a key's secret, a key's record), which must not +/// exist yet. +#[cfg_attr(not(feature = "quickstart"), allow(dead_code))] +pub fn write_new_private(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { + let (p, b) = (path.as_ref(), bytes.as_ref()); + if dry_run() { + if p.exists() { + return Err(io::Error::new(io::ErrorKind::AlreadyExists, "exists")); + } + note(json!({"write": shown(p), "bytes": b.len(), "replaces": false})); + return Ok(()); + } + let mut f = create_private(p)?; + f.write_all(b)?; + f.sync_all() +} + +/// A file only its owner may read or write, replaced whole: written beside it, then renamed over +/// it, so that a reader finds the old file or the new one and never part of either. +#[cfg_attr(not(feature = "quickstart"), allow(dead_code))] +pub fn replace_private(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result<()> { + let (p, b) = (path.as_ref(), bytes.as_ref()); + if dry_run() { + note(json!({"write": shown(p), "bytes": b.len(), "replaces": p.exists()})); + return Ok(()); + } + let mut name = p.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "not a file name"))?.to_os_string(); + name.push(format!(".{}.new", std::process::id())); + let tmp = p.with_file_name(name); + let _ = std::fs::remove_file(&tmp); + let written = create_private(&tmp).and_then(|mut f| f.write_all(b).and_then(|_| f.sync_all())); + match written.and_then(|_| std::fs::rename(&tmp, p)) { + Ok(()) => Ok(()), + Err(e) => { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + } +} + +/// A step a dry run would take that is not a file of this machine's (a secret kept in the +/// keychain, or removed from it): named in the plan, in a dry run alone. +#[cfg_attr(not(feature = "quickstart"), allow(dead_code))] +pub fn would(step: Value) { + if dry_run() { + note(step); + } +} + /// `std::fs::create_dir_all`. pub fn create_dir_all(path: impl AsRef) -> io::Result<()> { let p = path.as_ref(); @@ -127,9 +204,33 @@ pub fn plan_lines() -> Vec { format!("remove {p}") } else if let (Some(r), Some(t)) = (v["request"].as_str(), v["target"].as_str()) { format!("send {r} to {t}") + } else if let (Some(k), Some(id)) = (v["keychain"].as_str(), v["id"].as_str()) { + let into = if k == "add" { "keep the secret of" } else { "remove the secret of" }; + format!("{into} {id} {} the login keychain", if k == "add" { "in" } else { "from" }) } else { v.to_string() } }) .collect() } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + /// The file is the owner's alone before a byte of the secret is in it. + #[test] + fn a_private_file_is_restricted_before_it_holds_anything() { + use std::os::unix::fs::PermissionsExt; + let d = std::env::temp_dir().join(format!("wai-out-private-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let p = d.join("k.seed"); + let f = create_private(&p).unwrap(); + let m = std::fs::metadata(&p).unwrap(); + assert_eq!((m.len(), m.permissions().mode() & 0o777), (0, 0o600)); + drop(f); + assert_eq!(create_private(&p).unwrap_err().kind(), io::ErrorKind::AlreadyExists, "never over a file that exists"); + std::fs::remove_dir_all(&d).unwrap(); + } +} diff --git a/wai/wai-rs/src/bin/wai_cli/win_acl.rs b/wai/wai-rs/src/bin/wai_cli/win_acl.rs new file mode 100644 index 000000000..419fe0489 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/win_acl.rs @@ -0,0 +1,460 @@ +//! A secret's file on Windows: an access list for this account alone, from the file's creation, and +//! the check that refuses a seed file whose list lets anyone else in. What mode 0600 and its check +//! are on Unix (`out.rs`, `dx/key.rs`). +//! +//! Everything is by SID, read through the system's calls in numeric form, never by a name, which is +//! localised, and never from `icacls`' output. All of this binary's unsafe code for access lists is +//! the block below marked `cfg(windows)`; what decides ([`exposure`], [`private_descriptor`], +//! [`Exposure::fix_command`]) is ordinary code over plain values, so its tests run on every system. +//! +//! **The list.** Owner: this account. One entry: this account, all rights. Inheritance off, so the +//! folder's entries never reach the file, whichever folder it is. SYSTEM and Administrators are not +//! listed. Nothing real needs them: a backup or a virus scanner reads through its privileges rather +//! than through the list, an administrator can still take ownership, as root can read a file of mode +//! 0600, and the account itself reads it whether or not it is elevated. So the rule is one rule. +//! +//! **The order.** A file is made by one call that carries its list (`CreateFileW`, with the security +//! descriptor), so it never exists under a wider list, not even empty, and no other account can hold +//! a handle to it from a moment before its list was set. + +#![cfg_attr(not(all(windows, feature = "quickstart")), allow(dead_code, unused_imports))] + +/// One entry of an access list, as its header states it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + /// The ACE type. + pub kind: u8, + /// The ACE flags. + pub flags: u8, + /// The rights. + pub mask: u32, + /// Whom it is for, numeric; `None` for a kind whose layout this code does not read. + pub sid: Option, +} + +/// A file's owner and access list. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Acl { + pub owner: Option, + /// Whether inheritance is off. + pub protected: bool, + /// The entries; `None` when the file has no list at all, which lets everyone in. + pub entries: Option>, +} + +const ACCESS_ALLOWED: u8 = 0; +const ACCESS_DENIED: u8 = 1; +const ACCESS_DENIED_OBJECT: u8 = 6; +const ACCESS_ALLOWED_CALLBACK: u8 = 9; +const ACCESS_DENIED_CALLBACK: u8 = 10; +const ACCESS_DENIED_CALLBACK_OBJECT: u8 = 12; +const INHERIT_ONLY_ACE: u8 = 8; + +/// The security descriptor of a file (or a directory, whose children would inherit its one entry) +/// for the account `me` alone, in the system's own descriptor language: `P` is inheritance off, `FA` +/// all of a file's rights, and `OICI` that files and directories inside inherit the entry. +pub fn private_descriptor(me: &str, directory: bool) -> String { + format!("O:{me}D:P(A;{};FA;;;{me})", if directory { "OICI" } else { "" }) +} + +/// Who, besides the account a file is for, its list lets in. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Exposure { + /// The other accounts an entry grants anything to, numeric. + pub granted: Vec, + /// The file has no list at all. + pub no_list: bool, + /// An entry that grants something, of a kind this code cannot read the account of. + pub unread_entry: bool, + /// Another account owns it, and an owner may change the list. + pub other_owner: Option, + /// Whether the file inherits from its folder. + pub inherits: bool, +} + +/// What lets anyone but `me` in, or `None`. As Unix's check refuses any bit of a seed's mode for +/// group or other, this refuses an entry that grants another account anything at all. A deny entry +/// grants nothing, and an entry that only children inherit is not this file's. +pub fn exposure(acl: &Acl, me: &str) -> Option { + let mut x = Exposure { inherits: !acl.protected, ..Exposure::default() }; + match &acl.entries { + None => x.no_list = true, + Some(entries) => { + for e in entries { + let deny = matches!(e.kind, ACCESS_DENIED | ACCESS_DENIED_OBJECT | ACCESS_DENIED_CALLBACK | ACCESS_DENIED_CALLBACK_OBJECT); + if deny || e.flags & INHERIT_ONLY_ACE != 0 || e.mask == 0 { + continue; + } + match (&e.sid, e.kind) { + (Some(s), ACCESS_ALLOWED | ACCESS_ALLOWED_CALLBACK) => { + if s != me && !x.granted.contains(s) { + x.granted.push(s.clone()); + } + } + _ => x.unread_entry = true, + } + } + } + } + if acl.owner.as_deref() != Some(me) { + x.other_owner = Some(acl.owner.clone().unwrap_or_else(|| "no account".into())); + } + (x.no_list || x.unread_entry || !x.granted.is_empty() || x.other_owner.is_some()).then_some(x) +} + +/// A well-known account's English name, for a person reading a message; nothing decides by it. +fn known(sid: &str) -> Option<&'static str> { + Some(match sid { + "S-1-1-0" => "Everyone", + "S-1-5-3" => "BATCH", + "S-1-5-4" => "INTERACTIVE", + "S-1-5-6" => "SERVICE", + "S-1-5-11" => "Authenticated Users", + "S-1-5-18" => "SYSTEM", + "S-1-5-32-544" => "Administrators", + "S-1-5-32-545" => "Users", + _ => return None, + }) +} + +fn shown(sid: &str) -> String { + match known(sid) { + Some(n) => format!("{n} ({sid})"), + None => sid.to_string(), + } +} + +impl Exposure { + /// What is wrong, for the message that names the file. + pub fn describe(&self) -> String { + let mut what = Vec::new(); + if self.no_list { + what.push("it has no access list, which lets everyone in".to_string()); + } + if !self.granted.is_empty() { + what.push(format!("its access list lets {} in", self.granted.iter().map(|s| shown(s)).collect::>().join(", "))); + } + if self.unread_entry { + what.push("its access list holds an entry of a kind wai does not read".to_string()); + } + if let Some(o) = &self.other_owner { + what.push(format!("it is owned by {}, who may change who reads it", shown(o))); + } + what.join("; ") + } + + /// The command that makes the file the account's alone, naming every account by its SID. + pub fn fix_command(&self, path: &str, me: &str) -> String { + let mut c = String::new(); + if self.other_owner.is_some() { + c.push_str(&format!("icacls \"{path}\" /setowner *{me} && ")); + } + c.push_str(&format!("icacls \"{path}\"")); + if self.inherits { + c.push_str(" /inheritance:r"); + } + if !self.granted.is_empty() { + c.push_str(" /remove"); + for s in &self.granted { + c.push_str(&format!(" *{s}")); + } + } + c.push_str(&format!(" /grant:r *{me}:F")); + c + } +} + +// ─────────────────────────────── the system's calls ─────────────────────────────── + +#[cfg(windows)] +mod sys { + use super::{Acl, Entry}; + use std::ffi::{OsStr, c_void}; + use std::io; + use std::os::windows::ffi::OsStrExt; + use std::path::Path; + use std::ptr::null_mut; + use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, LocalFree}; + use windows_sys::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + ACE_HEADER, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, DACL_SECURITY_INFORMATION, GetAce, GetAclInformation, GetSecurityDescriptorControl, GetTokenInformation, OWNER_SECURITY_INFORMATION, PSID, + SE_DACL_PROTECTED, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::Storage::FileSystem::{CREATE_NEW, CreateDirectoryW, CreateFileW, FILE_ATTRIBUTE_NORMAL}; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + /// Memory the system gave with `LocalAlloc`, freed when dropped. + struct Local(*mut c_void); + + impl Drop for Local { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the pointer came from a call that says to free it with LocalFree, once. + unsafe { LocalFree(self.0) }; + } + } + } + + fn wide(s: &OsStr) -> io::Result> { + let mut w: Vec = s.encode_wide().collect(); + if w.contains(&0) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "a NUL in a name")); + } + w.push(0); + Ok(w) + } + + /// A SID's numeric text. + /// + /// # Safety + /// `sid` points at a valid SID. + unsafe fn sid_text(sid: PSID) -> io::Result { + let mut text: *mut u16 = null_mut(); + // SAFETY: the caller's SID; `text` is written by the call. + if unsafe { ConvertSidToStringSidW(sid, &mut text) } == 0 { + return Err(io::Error::last_os_error()); + } + let _free = Local(text.cast()); + let mut n = 0; + // SAFETY: the system's text ends with a NUL, which this stops at. + while unsafe { *text.add(n) } != 0 { + n += 1; + } + // SAFETY: `n` units were just read from it. + Ok(String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, n) })) + } + + /// The SID of the account this process runs as. + pub fn current_user() -> io::Result { + let mut token: HANDLE = null_mut(); + // SAFETY: the current process's pseudo-handle; `token` is written by the call. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let mut len = 0u32; + // SAFETY: a query for the length alone; it fails with the length written. + unsafe { GetTokenInformation(token, TokenUser, null_mut(), 0, &mut len) }; + // u64s, for TOKEN_USER's alignment. + let mut buf = vec![0u64; (len as usize).div_ceil(8).max(1)]; + // SAFETY: `buf` holds `len` bytes. + let ok = unsafe { GetTokenInformation(token, TokenUser, buf.as_mut_ptr().cast(), len, &mut len) }; + let err = io::Error::last_os_error(); + // SAFETY: the handle opened above, closed once. + unsafe { CloseHandle(token) }; + if ok == 0 { + return Err(err); + } + // SAFETY: the call wrote a TOKEN_USER at the start of `buf`, whose SID lies inside `buf`. + unsafe { sid_text((*buf.as_ptr().cast::()).User.Sid) } + } + + /// `f`, given security attributes that carry the descriptor `text`. + fn with_descriptor(text: &str, f: impl FnOnce(*const SECURITY_ATTRIBUTES) -> T) -> io::Result { + let w = wide(OsStr::new(text))?; + let mut sd: *mut c_void = null_mut(); + // SAFETY: `w` ends with a NUL; `sd` is written by the call and freed by `_free`. + if unsafe { ConvertStringSecurityDescriptorToSecurityDescriptorW(w.as_ptr(), SDDL_REVISION_1, &mut sd, null_mut()) } == 0 { + return Err(io::Error::last_os_error()); + } + let _free = Local(sd); + let sa = SECURITY_ATTRIBUTES { nLength: size_of::() as u32, lpSecurityDescriptor: sd, bInheritHandle: 0 }; + Ok(f(&sa)) + } + + /// A new file, which must not exist yet, under the account's own list from the moment it exists. + pub fn create_new_file(path: &Path) -> io::Result { + use std::os::windows::io::{FromRawHandle, RawHandle}; + let name = wide(path.as_os_str())?; + let descriptor = super::private_descriptor(¤t_user()?, false); + with_descriptor(&descriptor, |sa| { + // SAFETY: `name` ends with a NUL, and `sa` and what it points at live for the call. + let h = unsafe { CreateFileW(name.as_ptr(), GENERIC_WRITE, 0, sa, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, null_mut()) }; + if h == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: a handle just opened, which nothing else owns. + Ok(unsafe { std::fs::File::from_raw_handle(h as RawHandle) }) + })? + } + + /// A directory under the account's own list, its parents made as usual; one that exists stays as + /// it is, as a directory that exists does on Unix. + pub fn create_dir(path: &Path) -> io::Result<()> { + if path.is_dir() { + return Ok(()); + } + if let Some(p) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(p)?; + } + let name = wide(path.as_os_str())?; + let descriptor = super::private_descriptor(¤t_user()?, true); + with_descriptor(&descriptor, |sa| { + // SAFETY: `name` ends with a NUL, and `sa` and what it points at live for the call. + if unsafe { CreateDirectoryW(name.as_ptr(), sa) } != 0 { + return Ok(()); + } + let e = io::Error::last_os_error(); + if path.is_dir() { Ok(()) } else { Err(e) } + })? + } + + /// A file's owner and access list. + pub fn read(path: &Path) -> io::Result { + let name = wide(path.as_os_str())?; + let (mut owner, mut dacl, mut sd): (PSID, *mut ACL, *mut c_void) = (null_mut(), null_mut(), null_mut()); + // SAFETY: `name` ends with a NUL; the out pointers point into the descriptor `sd`, freed below. + let e = unsafe { GetNamedSecurityInfoW(name.as_ptr(), SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &mut owner, null_mut(), &mut dacl, null_mut(), &mut sd) }; + if e != 0 { + return Err(io::Error::from_raw_os_error(e as i32)); + } + let _free = Local(sd); + let (mut control, mut revision) = (0u16, 0u32); + // SAFETY: the descriptor the call above returned. + if unsafe { GetSecurityDescriptorControl(sd, &mut control, &mut revision) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: the descriptor's owner, when it has one. + let owner = if owner.is_null() { None } else { Some(unsafe { sid_text(owner) }?) }; + let mut entries = None; + if !dacl.is_null() { + let mut info = ACL_SIZE_INFORMATION { AceCount: 0, AclBytesInUse: 0, AclBytesFree: 0 }; + // SAFETY: the descriptor's list; `info` is the size the class asks for. + if unsafe { GetAclInformation(dacl.cast_const(), (&raw mut info).cast(), size_of::() as u32, AclSizeInformation) } == 0 { + return Err(io::Error::last_os_error()); + } + let mut list = Vec::with_capacity(info.AceCount as usize); + for i in 0..info.AceCount { + let mut ace: *mut c_void = null_mut(); + // SAFETY: an index the list says it holds. + if unsafe { GetAce(dacl.cast_const(), i, &mut ace) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: an ACE begins with its header. + let (kind, flags) = unsafe { ((*ace.cast::()).AceType, (*ace.cast::()).AceFlags) }; + // SAFETY: every ACE holds its mask after the header's four bytes. + let mask = unsafe { ace.cast::().add(4).cast::().read_unaligned() }; + // These kinds hold the SID after the mask; the object kinds put more before it. + let sid = if matches!(kind, super::ACCESS_ALLOWED | super::ACCESS_DENIED | super::ACCESS_ALLOWED_CALLBACK | super::ACCESS_DENIED_CALLBACK) { + // SAFETY: the SID of an ACE of such a kind, eight bytes in. + Some(unsafe { sid_text(ace.cast::().add(8).cast()) }?) + } else { + None + }; + list.push(Entry { kind, flags, mask, sid }); + } + entries = Some(list); + } + Ok(Acl { owner, protected: control & SE_DACL_PROTECTED != 0, entries }) + } +} + +#[cfg(windows)] +#[allow(unused_imports)] // A binary that includes `out.rs` for its other writes calls none of these. +pub use sys::{create_dir, create_new_file, current_user, read}; + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(windows)] + use std::io; + + const ME: &str = "S-1-5-21-1-2-3-1001"; + const FILE_ALL: u32 = 0x1f01ff; + + fn allow(sid: &str, mask: u32) -> Entry { + Entry { kind: ACCESS_ALLOWED, flags: 0, mask, sid: Some(sid.into()) } + } + + fn acl(entries: Vec) -> Acl { + Acl { owner: Some(ME.into()), protected: true, entries: Some(entries) } + } + + #[test] + fn the_descriptor_is_this_account_alone_with_inheritance_off() { + assert_eq!(private_descriptor(ME, false), format!("O:{ME}D:P(A;;FA;;;{ME})")); + assert_eq!(private_descriptor(ME, true), format!("O:{ME}D:P(A;OICI;FA;;;{ME})")); + } + + #[test] + fn only_this_account_is_let_in_or_the_file_is_exposed() { + assert_eq!(exposure(&acl(vec![allow(ME, FILE_ALL)]), ME), None); + // Each account named, SYSTEM and Administrators as any other, whatever the right. + for sid in ["S-1-1-0", "S-1-5-18", "S-1-5-32-544", "S-1-5-32-545", "S-1-5-21-1-2-3-1002"] { + let x = exposure(&acl(vec![allow(ME, FILE_ALL), allow(sid, 0x120089)]), ME).unwrap_or_else(|| panic!("{sid}")); + assert_eq!(x.granted, [sid]); + let x = exposure(&acl(vec![allow(sid, 0x100000)]), ME).unwrap_or_else(|| panic!("{sid}")); + assert_eq!(x.granted, [sid], "any right, as any bit of a mode"); + } + // No list at all lets everyone in. + let x = exposure(&Acl { entries: None, ..acl(vec![]) }, ME).unwrap(); + assert!(x.no_list && x.describe().contains("no access list")); + // Another owner may change the list. + let x = exposure(&Acl { owner: Some("S-1-5-32-544".into()), ..acl(vec![allow(ME, FILE_ALL)]) }, ME).unwrap(); + assert_eq!(x.other_owner.as_deref(), Some("S-1-5-32-544")); + assert!(x.describe().contains("Administrators (S-1-5-32-544)"), "{}", x.describe()); + assert!(exposure(&Acl { owner: None, ..acl(vec![allow(ME, FILE_ALL)]) }, ME).is_some()); + // An entry that grants, of a kind whose account is not read, is refused, not passed. + for kind in [5u8, 11, 4, 200] { + let x = exposure(&acl(vec![Entry { kind, flags: 0, mask: 1, sid: None }]), ME).unwrap_or_else(|| panic!("{kind}")); + assert!(x.unread_entry && x.describe().contains("does not read"), "{kind}"); + } + // A callback entry is read like an allow entry. + let x = exposure(&acl(vec![Entry { kind: ACCESS_ALLOWED_CALLBACK, flags: 0, mask: 1, sid: Some("S-1-1-0".into()) }]), ME).unwrap(); + assert_eq!(x.granted, ["S-1-1-0"]); + } + + #[test] + fn what_grants_nothing_to_this_file_is_not_an_exposure() { + let denied = Entry { kind: ACCESS_DENIED, flags: 0, mask: FILE_ALL, sid: Some("S-1-1-0".into()) }; + assert_eq!(exposure(&acl(vec![denied, allow(ME, FILE_ALL)]), ME), None); + for kind in [ACCESS_DENIED_OBJECT, ACCESS_DENIED_CALLBACK, ACCESS_DENIED_CALLBACK_OBJECT] { + assert_eq!(exposure(&acl(vec![Entry { kind, flags: 0, mask: 1, sid: None }]), ME), None, "{kind}"); + } + let for_children = Entry { kind: ACCESS_ALLOWED, flags: INHERIT_ONLY_ACE, mask: FILE_ALL, sid: Some("S-1-1-0".into()) }; + assert_eq!(exposure(&acl(vec![for_children]), ME), None); + assert_eq!(exposure(&acl(vec![allow("S-1-1-0", 0)]), ME), None, "no right at all"); + // Inheriting from a folder whose entries are this account's alone lets no one else in. + assert_eq!(exposure(&Acl { protected: false, ..acl(vec![allow(ME, FILE_ALL)]) }, ME), None); + } + + #[test] + fn the_fix_names_every_account_by_its_sid() { + let f = "C:\\ks\\k.seed"; + let wide = exposure(&Acl { protected: false, ..acl(vec![allow("S-1-1-0", 1), allow("S-1-5-18", 1), allow(ME, FILE_ALL)]) }, ME).unwrap(); + assert_eq!(wide.fix_command(f, ME), format!("icacls \"{f}\" /inheritance:r /remove *S-1-1-0 *S-1-5-18 /grant:r *{ME}:F")); + let granted = exposure(&acl(vec![allow("S-1-1-0", 1)]), ME).unwrap(); + assert_eq!(granted.fix_command(f, ME), format!("icacls \"{f}\" /remove *S-1-1-0 /grant:r *{ME}:F")); + let owned = exposure(&Acl { owner: Some("S-1-5-32-544".into()), ..acl(vec![allow(ME, FILE_ALL)]) }, ME).unwrap(); + assert_eq!(owned.fix_command(f, ME), format!("icacls \"{f}\" /setowner *{ME} && icacls \"{f}\" /grant:r *{ME}:F")); + } + + /// The system's calls: a file made under its list before a byte is in it, read back by SID. + #[cfg(windows)] + #[test] + fn a_file_and_a_directory_are_this_accounts_from_their_creation() { + let d = std::env::temp_dir().join(format!("wai-win-acl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + let inner = d.join("parent").join("ks"); + create_dir(&inner).unwrap(); + create_dir(&inner).unwrap(); + let me = current_user().unwrap(); + let a = read(&inner).unwrap(); + assert!(a.protected, "{a:?}"); + assert_eq!(exposure(&a, &me), None, "{a:?}"); + // Its entry reaches what is made inside by any means. + let p = inner.join("k.seed"); + let f = create_new_file(&p).unwrap(); + // Before a byte is written: empty, and already under its list alone. + assert_eq!(std::fs::metadata(&p).unwrap().len(), 0); + let a = read(&p).unwrap(); + assert!(a.protected, "{a:?}"); + assert_eq!(a.owner.as_deref(), Some(me.as_str()), "{a:?}"); + let entries = a.entries.clone().unwrap(); + assert_eq!(entries.len(), 1, "{a:?}"); + assert_eq!((entries[0].kind, entries[0].sid.as_deref()), (ACCESS_ALLOWED, Some(me.as_str())), "{a:?}"); + assert_eq!(exposure(&a, &me), None, "{a:?}"); + drop(f); + assert_eq!(create_new_file(&p).unwrap_err().kind(), io::ErrorKind::AlreadyExists); + std::fs::remove_dir_all(&d).unwrap(); + } +} diff --git a/wai/wai-rs/src/catalogue.rs b/wai/wai-rs/src/catalogue.rs index 467281870..aa4fdc785 100644 --- a/wai/wai-rs/src/catalogue.rs +++ b/wai/wai-rs/src/catalogue.rs @@ -255,6 +255,8 @@ const fn member(name: &'static str, ty: Ty, required: bool, doc: &'static str) - /// The toolchain version the catalogue's first entries carry. const V0: &str = "0.3.0"; +/// The toolchain version that first carries the operations added after 0.4.0. +const V5: &str = "0.5.0"; const STAGE: Needs = Needs::All(&["staged", "video_int", "audio_int", "splat_int"]); const HTTP: Needs = Needs::All(&["http"]); @@ -319,6 +321,14 @@ const S_CHANNEL_IMPORT: &str = r#"{"type":"object","required":["channel","receip const S_CHANNEL_GUIDE: &str = r#"{"type":"object","required":["file","format","schedule_root","lineup"],"description":"The guide's file and format (xmltv or now-next), the sources it names, and its range or instant"}"#; const S_CHANNEL_VERIFY: &str = r#"{"type":"object","required":["verdict","part","receipt","lineup","guide","entry"],"description":"What extensions/channel-lineup.md §8.3 states: the verdict and the part it is about, the receipt's identity, the lineup's digest, and the guide's programmes, each with whether it aired"}"#; const S_STAGE_APPLY: &str = r#"{"description":"The trace of every stage reached, as `wai stage apply --json` prints it"}"#; +const S_TOOLCHAIN: &str = r#"{"type":"object","required":["toolchain","catalogue","envelope","registry","specification","features"],"description":"This binary's version, its catalogue's format, operations and SHA-256, the JSON envelope's version, its registry's capabilities and SHA-256, the release of the standard it implements with the envelope formats it reads, and the build features it holds"}"#; +// A developer's signing keys (`wai key`). +const S_KEY: &str = r#"{"type":"object","required":["name","role","id","key","store","keystore"],"description":"The key kept: its name, role and key id, its public key in hexadecimal and its did:key (null in a dry run, which makes no key), where its secret is kept (store: keychain or file), and the keystore"}"#; +const S_KEYS: &str = r#"{"type":"object","required":["keystore","keys"],"description":"Each key of the keystore: its name, role, newest key id, public key, did:key and status, and where its secret is kept"}"#; +const S_KEY_SHOW: &str = r#"{"type":"object","required":["keystore","name","role","signs_with","versions"],"description":"The key's versions, oldest first, each with its key id, public key, did:key, status (active, retired, revoked) and times as a key log states them (jwp-receipts §7.2), and where its secret is kept (store; null once destroyed); and the key id --key signs with"}"#; +const S_KEY_ROTATE: &str = r#"{"type":"object","required":["name","previous","id","key","store"],"description":"The version retired (previous, with until) and the successor made: its key id, public key (null in a dry run) and where its secret is kept (store)"}"#; +const S_KEY_REVOKE: &str = r#"{"type":"object","required":["name","id","compromised_since"],"description":"The version revoked, the time from which it is held compromised, and whether its secret was destroyed"}"#; +const S_KEY_EXPORT: &str = r#"{"type":"object","required":["name","id","format","text"],"description":"The key's public half in the format asked for (the text, and for entry the key-log entries), and the file it went to"}"#; /// Every operation, in the order help lists them: the developer's path first. pub const OPS: &[Op] = &[ @@ -472,7 +482,7 @@ pub const OPS: &[Op] = &[ text codecs: any file encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|software] [--library ] [--device ] [--hardware yes|no] [--kbps ] [--size WxH] [--idr-every | - --idr-plan ] [--es ] [--record --key ] [--meter [--rounds ]] + --idr-plan ] [--es ] [--record --key ] [--meter [--rounds ]] (a build with `platform_encode`)", effect: Effect::WritesProject, input: &[ @@ -532,7 +542,7 @@ encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof summary: "Upscale a PNG exactly, by an integer network under a pinned prior, with a signed record a verifier recomputes", cli: Some("upscale run"), usage: "upscale run --prior [--derivation sr2|sr4] -o - [--record --envelope --key ] [--issued-at ] [--json] + [--record --envelope --key ] [--issued-at ] [--json] an 8-bit RGB PNG; --record signs what exact-upscale §7 recomputes from --envelope and the prior", effect: Effect::WritesProject, input: &[ @@ -542,7 +552,7 @@ encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof flag("out", "-o", Ty::Path, true, "The picture, a PNG."), flag("record", "--record", Ty::Path, false, "The signed upscale record."), flag("envelope", "--envelope", Ty::Path, false, "The WAI1 of the input, which the record names."), - flag("key", "--key", Ty::String, false, "The record's signing seed: 64 hexadecimal digits, or a file holding them."), + flag("key", "--key", Ty::String, false, "The record's signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them."), ], output: Output::Json(S_DERIVED), receipt: true, @@ -570,7 +580,7 @@ encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof summary: "Render a still's gain map exactly at the headrooms named, with a signed record a verifier recomputes", cli: Some("tonemap run"), usage: "tonemap run --headrooms --out [--input-class ] - [--record --key ] [--issued-at ] [--json] + [--record --key ] [--issued-at ] [--json] a WAI2 of a PNG base and its gain map; a headroom is in 1/10000 of a stop; each rendition is RGB16", effect: Effect::WritesProject, input: &[ @@ -579,7 +589,7 @@ encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof flag("out", "--out", Ty::Path, true, "The directory the renditions are written in."), flag("input_class", "--input-class", Ty::String, false, "The input's class (captured)."), flag("record", "--record", Ty::Path, false, "The signed rendition record."), - flag("key", "--key", Ty::String, false, "The record's signing seed: 64 hexadecimal digits, or a file holding them."), + flag("key", "--key", Ty::String, false, "The record's signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them."), ], output: Output::Json(S_DERIVED), receipt: true, @@ -858,10 +868,10 @@ encode avc|hevc [-o out.wai] [--encoder auto|platform|va|gpu-driver|sof id: "playlist.lock", summary: "A media playlist sealed with a signed manifest receipt", cli: Some("http seal-playlist"), - usage: "http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]", + usage: "http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]", effect: Effect::WritesProject, stability: Stability::Preview, - input: &[pos("playlist", Ty::Path, true, "The playlist."), flag("key", "--key", Ty::Path, true, "The signing seed.")], + input: &[pos("playlist", Ty::Path, true, "The playlist."), flag("key", "--key", Ty::String, true, "The signing key: its name in the keystore (wai key list), 64 hexadecimal digits, or a file holding them.")], receipt: true, needs: Needs::All(&["http", "video_manifest"]), ..OP @@ -980,7 +990,7 @@ http vectors diff|fuzz --seed S --count N --out F", id: "enrolment.create", summary: "A node's enrolment signed with the organisation key, written to a file and, with --target, given to a fleet service", cli: Some("enrolment create"), - usage: "enrolment create --role --key [--label L] [--from µs] [--until µs] [--out F] + usage: "enrolment create --role --key [--label L] [--from µs] [--until µs] [--out F] [--target URL] [--yes] a node's enrolment (WFE1), signed with the organisation key, written to F (.wfe without --target) and, with --target, given to the fleet service there; changing the service or replacing a file waits for --yes (--confirm, deprecated, is the same)", @@ -990,7 +1000,7 @@ http vectors diff|fuzz --seed S --count N --out F", input: &[ pos("node", Ty::String, true, "The node's Ed25519 public key, 64 hexadecimal digits."), flag("role", "--role", Ty::String, true, "relay, compositor, ingest, room, ledger or audit."), - flag("key", "--key", Ty::Path, true, "The organisation key's file: 32 bytes, or 64 hexadecimal digits."), + flag("key", "--key", Ty::String, true, "The organisation key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."), flag("label", "--label", Ty::String, false, "The node's label, at most 64 bytes."), flag("from", "--from", Ty::Integer, false, "The start of the span it holds, microseconds since 1970 (0)."), flag("until", "--until", Ty::Integer, false, "The end of that span, microseconds since 1970 (none)."), @@ -1085,7 +1095,7 @@ http vectors diff|fuzz --seed S --count N --out F", flag("mrss_owner", "--mrss-owner", Ty::Strings, false, "The rights holder of each --mrss document's items, in their order: none, or one each."), flag("feed", "--feed", Ty::Path, false, "The JSON form of a schedule, its catalogue and its breaks."), flag("playlist", "--playlist", Ty::Path, false, "The channel's HLS Media Playlist, whose SCTE-35 date ranges are its breaks."), - flag("key", "--key", Ty::Path, true, "The operator's key file: 32 bytes, or 64 hexadecimal digits."), + flag("key", "--key", Ty::String, true, "The operator's key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."), flag("owner", "--owner", Ty::String, false, "The rights holder of every asset no other input gives one."), flag("signer_id", "--signer-id", Ty::String, false, "The receipt's signer id (the operator key's did:key)."), flag("id_system", "--id-system", Ty::String, false, "The XMLTV episode-num system that names a programme's asset (wai-asset-id)."), @@ -1240,28 +1250,41 @@ http vectors diff|fuzz --seed S --count N --out F", needs: Needs::All(&["app"]), ..OP }, - // ─── served by wai mcp alone ─── + // ─── the command line and wai mcp, one tool ─── Op { id: "registry.show", summary: "One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it", - usage: "(wai mcp: registry_show)", + cli: Some("registry show"), + usage: "registry show [--spec-root ] one capability's registry entry, whether this build decodes it, + its role, and the specification lines that name it, where the specification's text is here (--spec-root, + $WAI_SPEC_ROOT, or the tree this binary was built from)", mcp: true, - input: &[member("capability", Ty::String, true, "A capability string, e.g. wai.image.avif.")], + input: &[ + pos("capability", Ty::String, true, "A capability string, e.g. wai.image.avif."), + flag("spec_root", "--spec-root", Ty::Path, false, "The wai/ directory whose text is read, on the command line (wai mcp has a --spec-root of its own)."), + ], output: Output::Json(S_ANY), + needs: Needs::All(&["mcp"]), ..OP }, Op { id: "docs.search", summary: "Lines of the specification and its extensions holding every word of a query, each with its file, line and section", - usage: "(wai mcp: docs_search)", + cli: Some("docs search"), + usage: "docs search ... [--limit N] [--spec-root ] the lines of the specification and its extensions that hold + every word, with each one's file, line and section, offline; the text is --spec-root's, $WAI_SPEC_ROOT's, or the + tree this binary was built from", mcp: true, input: &[ - member("query", Ty::String, true, "Words that must all appear on a line."), - member("limit", Ty::Integer, false, "At most this many hits (20)."), + pos("query", Ty::String, true, "Words that must all appear on a line."), + flag("limit", "--limit", Ty::Integer, false, "At most this many hits (20)."), + flag("spec_root", "--spec-root", Ty::Path, false, "The wai/ directory whose text is searched, on the command line (wai mcp has a --spec-root of its own)."), ], output: Output::Json(S_ANY), + needs: Needs::All(&["mcp"]), ..OP }, + // ─── served by wai mcp alone ─── Op { id: "dev.stop", summary: "The development relay and room service a wai mcp server started, stopped", @@ -1719,14 +1742,14 @@ http vectors diff|fuzz --seed S --count N --out F", id: "contract.create", summary: "A channel's share contract from its terms, signed with the operator key; each owner countersigns it", cli: Some("contract create"), - usage: "contract create --key [--out F] [--yes] the share contract (WRC1) of the terms, signed + usage: "contract create --key [--out F] [--yes] the share contract (WRC1) of the terms, signed with the operator key, every countersignature still to come, written to F (.wrc); each owner adds its own with `wai contract accept`; replacing a file waits for --yes (--confirm, deprecated, is the same)", effect: Effect::WritesProject, stability: Stability::Preview, input: &[ pos("terms", Ty::Path, true, "The terms: start, end, currency, channel, channel_signer, rule, between, carriers, owners, splits and assets."), - flag("key", "--key", Ty::Path, true, "The operator key's file: 32 bytes, or 64 hexadecimal digits."), + flag("key", "--key", Ty::String, true, "The operator key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."), flag("out", "--out", Ty::Path, false, "The contract's file (.wrc)."), ], output: Output::Json(S_SHARE_CONTRACT), @@ -1739,14 +1762,14 @@ http vectors diff|fuzz --seed S --count N --out F", id: "contract.update", summary: "An owner's countersignature added to a share contract, after its rules and the operator's signature are checked", cli: Some("contract accept"), - usage: "contract accept --key [--out F] [--yes] the owner's countersignature added to the contract, + usage: "contract accept --key [--out F] [--yes] the owner's countersignature added to the contract, written to F (the contract's own file when F is not given); replacing a file waits for --yes (--confirm, deprecated, is the same)", effect: Effect::WritesProject, stability: Stability::Preview, input: &[ pos("contract", Ty::Path, true, "The contract's file (WRC1)."), - flag("key", "--key", Ty::Path, true, "The owner key's file: 32 bytes, or 64 hexadecimal digits."), + flag("key", "--key", Ty::String, true, "The owner key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."), flag("out", "--out", Ty::Path, false, "Where the countersigned contract goes (the contract's own file)."), ], output: Output::Json(S_SHARE_CONTRACT), @@ -1759,7 +1782,7 @@ http vectors diff|fuzz --seed S --count N --out F", id: "statement.create", summary: "A party's revenue-share statement for a period, recomputed from the ledger's invoices and the channel's program-as-run, signed with the operator key", cli: Some("statement create"), - usage: "statement create --party --start µs --end µs --key [--out F] [--yes] the statement + usage: "statement create --party --start µs --end µs --key [--out F] [--yes] the statement (WRS1) of the party for [start, end), computed from a verification document without its statement (the contract, other contracts, runs and ledger documents), signed with the operator key and written with its document to F (.wai-share.json) for `wai statement verify`; replacing a file waits for --yes", @@ -1770,7 +1793,7 @@ http vectors diff|fuzz --seed S --count N --out F", flag("party", "--party", Ty::String, true, "The party's key, 64 hexadecimal digits: the operator or an owner."), flag("start", "--start", Ty::Integer, true, "The period's start, microseconds since 1970."), flag("end", "--end", Ty::Integer, true, "The period's end, after its start."), - flag("key", "--key", Ty::Path, true, "The operator key's file: 32 bytes, or 64 hexadecimal digits."), + flag("key", "--key", Ty::String, true, "The operator key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."), flag("out", "--out", Ty::Path, false, "The verification document's file (.wai-share.json)."), ], output: Output::Json(S_SHARE_STATEMENT), @@ -1796,7 +1819,7 @@ http vectors diff|fuzz --seed S --count N --out F", id: "opportunity.create", summary: "A party's opportunity report for a period: the ad breaks a channel's signed cues and program-as-run prove in the programmes it shares in, signed with the issuer key", cli: Some("opportunity create"), - usage: "opportunity create --party --start µs --end µs --key [--out F] [--yes] the report + usage: "opportunity create --party --start µs --end µs --key [--out F] [--yes] the report (WOR1) of the party for [start, end), computed from a verification document without its report (the contract, other contracts, runs and rooms), signed with the issuer key and written with its document to F (.wai-opportunity.json) for `wai opportunity verify`; replacing a file waits for --yes", @@ -1807,7 +1830,7 @@ http vectors diff|fuzz --seed S --count N --out F", flag("party", "--party", Ty::String, true, "The party's key, 64 hexadecimal digits: the operator or an owner."), flag("start", "--start", Ty::Integer, true, "The period's start, microseconds since 1970."), flag("end", "--end", Ty::Integer, true, "The period's end, after its start."), - flag("key", "--key", Ty::Path, true, "The issuer key's file: 32 bytes, or 64 hexadecimal digits."), + flag("key", "--key", Ty::String, true, "The issuer key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."), flag("out", "--out", Ty::Path, false, "The verification document's file (.wai-opportunity.json)."), ], output: Output::Json(S_OPPORTUNITY), @@ -1832,7 +1855,7 @@ http vectors diff|fuzz --seed S --count N --out F", id: "statement.check", summary: "A platform's reported statement checked against an opportunity report and, when given, the operator's signed statement, signed with the issuer key", cli: Some("statement check"), - usage: "statement check [--statement ] [--floor ppm] [--ceiling ppm] --key [--out F] [--yes] + usage: "statement check [--statement ] [--floor ppm] [--ceiling ppm] --key [--out F] [--yes] the check (WCK1) of a reported statement (JSON, or CSV for a file named .csv) beside a report's verification document and a signed statement's, at the bounds given (500000 and 2000000 when not), signed with the issuer key and written with its document to F (.wai-check.json); replacing a file waits for --yes", @@ -1844,7 +1867,7 @@ http vectors diff|fuzz --seed S --count N --out F", flag("statement", "--statement", Ty::Path, false, "A signed revenue-share statement's verification document of the same party and period."), flag("floor", "--floor", Ty::Integer, false, "The lower bound, in millionths of the period's rate (500000)."), flag("ceiling", "--ceiling", Ty::Integer, false, "The upper bound, in millionths of the period's rate (2000000)."), - flag("key", "--key", Ty::Path, true, "The issuer key's file: 32 bytes, or 64 hexadecimal digits."), + flag("key", "--key", Ty::String, true, "The issuer key: its name in the keystore (wai key list), or its file: 32 bytes, or 64 hexadecimal digits."), flag("out", "--out", Ty::Path, false, "The verification document's file (.wai-check.json)."), ], output: Output::Json(S_STATEMENT_CHECK), @@ -1853,6 +1876,149 @@ http vectors diff|fuzz --seed S --count N --out F", confirm: Some(Confirm::AnyFile), ..OP }, + // ─── a developer's signing keys, in one keystore (WAI toolchain v1, step 4) ─── + Op { + id: "key.create", + summary: "A new Ed25519 signing key kept under a name, for one role, its secret in macOS's keychain or in a seed file; every signing operation's --key takes the name", + cli: Some("key create"), + usage: "key create --for [--store keychain|file] [--keystore DIR] a new signing key named + (1 to 48 of a-z, 0-9, - and _), for one role: origin, signer, room, audit, ledger, release, remote-host, + quantum or dev. Its secret is kept in the login keychain (--store keychain, the default on macOS) or in a + file of the keystore (--store file, the default elsewhere: mode 0600 on Unix; on Windows an access list for + this account alone, inheritance off), and is never printed; a seed file another account may read is refused. + The keystore is DIR, $WAI_KEYSTORE, or ~/.config/wai/keys (%APPDATA%\\wai\\keys on Windows). --key signs + with it", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + pos("name", Ty::String, true, "The key's name: 1 to 48 of a-z, 0-9, - and _, beginning with a letter or a digit."), + flag("role", "--for", Ty::String, true, "The key's role: origin, signer, room, audit, ledger, release, remote-host, quantum or dev."), + flag("store", "--store", Ty::String, false, "Where the secret is kept: keychain (the default on macOS) or file (the default elsewhere: mode 0600 on Unix; on Windows, an access list for this account alone)."), + flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."), + ], + output: Output::Json(S_KEY), + since: V5, + needs: Needs::All(&["quickstart"]), + confirm: Some(Confirm::Never), + ..OP + }, + Op { + id: "key.list", + summary: "Every key of the keystore: its name, role, key id, public key, status and where its secret is kept", + cli: Some("key list"), + usage: "key list [--for ] [--keystore DIR] every key of the keystore, its newest version, and where its secret is kept", + stability: Stability::Preview, + input: &[ + flag("role", "--for", Ty::String, false, "Only the keys of this role."), + flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."), + ], + output: Output::Json(S_KEYS), + since: V5, + needs: Needs::All(&["quickstart"]), + ..OP + }, + Op { + id: "key.show", + summary: "One key: its public key and did:key, its role, every version with its status as a key log states it, and the version --key signs with", + cli: Some("key show"), + usage: "key show [--keystore DIR] one key's versions, each active, retired (until) or revoked (compromised since), as + jwp-receipts §7.2's key log states them, and the one --key signs with", + stability: Stability::Preview, + input: &[ + pos("name", Ty::String, true, "The key's name."), + flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."), + ], + output: Output::Json(S_KEY_SHOW), + since: V5, + needs: Needs::All(&["quickstart"]), + ..OP + }, + Op { + id: "key.rotate", + summary: "A key's successor made under the same name and a new key id, the old version retired now and its secret destroyed", + cli: Some("key rotate"), + usage: "key rotate [--store keychain|file] [--keystore DIR] [--yes] a successor under a new key id (.), + which --key signs with from now; the old version is retired now and its secret destroyed (jwp-receipts + §7.6), so it waits for --yes", + effect: Effect::Destructive, + stability: Stability::Preview, + input: &[ + pos("name", Ty::String, true, "The key's name."), + flag("store", "--store", Ty::String, false, "Where the successor's secret is kept (where the old one's was)."), + flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."), + ], + output: Output::Json(S_KEY_ROTATE), + since: V5, + needs: Needs::All(&["quickstart"]), + ..OP + }, + Op { + id: "key.revoke", + summary: "A key's version revoked as compromised from a time, its secret destroyed, so --key signs nothing with it", + cli: Some("key revoke"), + usage: "key revoke [--id ] [--compromised-since ] [--keystore DIR] [--yes] the key's newest + version (or --id's) revoked, compromised since the time given (now; YYYY-MM-DDTHH:MM:SSZ), and its secret + destroyed (jwp-receipts §7.6); it waits for --yes", + effect: Effect::Destructive, + stability: Stability::Preview, + input: &[ + pos("name", Ty::String, true, "The key's name."), + flag("id", "--id", Ty::String, false, "The version to revoke, by its key id (the newest)."), + flag("compromised_since", "--compromised-since", Ty::String, false, "From when another party may have held it: a date-time in UTC, YYYY-MM-DDTHH:MM:SSZ (now)."), + flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."), + ], + output: Output::Json(S_KEY_REVOKE), + since: V5, + needs: Needs::All(&["quickstart"]), + ..OP + }, + Op { + id: "key.import", + summary: "A key file's seed kept under a name, as wai key create keeps a new one; a published test seed is refused unless named one", + cli: Some("key import"), + usage: "key import --for --from [--store keychain|file] [--published-test-seed] [--keystore DIR] + the seed of a key file (64 hexadecimal digits or the 32 bytes, as every --key file holds) kept as wai key create + keeps one; a seed of one byte repeated 32 times, the form of every seed WAI publishes, is refused unless + --published-test-seed names it a test key", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + pos("name", Ty::String, true, "The key's name."), + flag("role", "--for", Ty::String, true, "The key's role: origin, signer, room, audit, ledger, release, remote-host, quantum or dev."), + flag("from", "--from", Ty::Path, true, "The key file: 64 hexadecimal digits, or the 32 bytes."), + flag("store", "--store", Ty::String, false, "Where the secret is kept: keychain (the default on macOS) or file."), + switch("published_test_seed", "--published-test-seed", "Keep a published test seed, marked as one."), + flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."), + ], + output: Output::Json(S_KEY), + since: V5, + needs: Needs::All(&["quickstart"]), + confirm: Some(Confirm::Never), + ..OP + }, + Op { + id: "key.export", + summary: "A key's public half, never its secret: in hexadecimal, as a did:key, as key-log entries, or as the line wai-keys lists it by", + cli: Some("key export"), + usage: "key export [--format hex|did-key|entry|wai-keys] [--may-sign ,...] [--out F] [--keystore DIR] + the public half alone: hex (the default), did-key, entry (each version as a key-log entry, jwp-receipts §7.2) or + wai-keys (the newest as wai-keys init --key and append --add take it); entry and wai-keys name the authorities + an origin gives it (--may-sign); to stdout, or to F (replacing a file waits for --yes)", + effect: Effect::WritesProject, + stability: Stability::Preview, + input: &[ + pos("name", Ty::String, true, "The key's name."), + flag("format", "--format", Ty::String, false, "hex (the default), did-key, entry or wai-keys."), + flag("may_sign", "--may-sign", Ty::String, false, "With entry and wai-keys: the authorities an origin gives the key (jwp-receipts §7.5), comma-separated."), + flag("out", "--out", Ty::Path, false, "Write it to this file."), + flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."), + ], + output: Output::Json(S_KEY_EXPORT), + since: V5, + needs: Needs::All(&["quickstart"]), + confirm: Some(Confirm::AnyFile), + ..OP + }, // ─── the catalogue itself ─── Op { id: "ops.list", @@ -1894,6 +2060,20 @@ http vectors diff|fuzz --seed S --count N --out F", output: Output::Json(S_OPS_EXPORT), ..OP }, + // ─── the toolchain itself ─── + Op { + id: "toolchain.show", + summary: "This wai's version, the digests of its catalogue and its registry, the release of the standard it implements, and the build features it holds", + cli: Some("--version"), + aliases: &["version"], + usage: "--version [--json] this binary's version, the SHA-256 of its catalogue (wai/catalogue/ops.json) and of its + registry (wai/registry/capabilities.json), the release of the standard it implements with the envelope formats + it reads, and the build features it holds (also wai version; wai/VERSIONING.md says what each promises)", + stability: Stability::Preview, + output: Output::Json(S_TOOLCHAIN), + since: V5, + ..OP + }, ]; /// The operation `id`. diff --git a/wai/wai-rs/tests/cli_key.rs b/wai/wai-rs/tests/cli_key.rs new file mode 100644 index 000000000..27b2cf762 --- /dev/null +++ b/wai/wai-rs/tests/cli_key.rs @@ -0,0 +1,722 @@ +//! `wai key` through the `wai` binary, as a person and an agent meet it (`--features quickstart +//! --test cli_key`; with `key_discovery` too, what it exports is read by `wai-keys`): +//! +//! - `wai key create|import` keep a key under a name, its secret in a file of mode 0600 in a +//! keystore of mode 0700, and no command prints it or writes it anywhere else; +//! - `wai key list|show|export` give the public halves, with each version's status as a key log +//! states it (jwp-receipts §7.2); +//! - `--key ` signs with the key's newest version on the operations that sign; +//! - `wai key rotate|revoke` wait for `--yes` (exit 4, nothing changed), retire or revoke a version +//! and destroy its secret; a revoked key signs nothing; +//! - a published test seed, a seed file others may read, a name taken, a key held twice and a +//! record that does not read are refused, each with its exit code and its stable code. +//! +//! Every test keeps its keys in a keystore of its own scratch directory, in files: none touches +//! the person's keystore or keychain, and the seeds here are made for these tests. +#![cfg(feature = "quickstart")] + +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +fn scratch(name: &str) -> PathBuf { + let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_key").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// `wai` in `d`, its keystore `d/ks` (`$WAI_KEYSTORE`), no input and no terminal. +fn wai(d: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(d).args(args).env("WAI_KEYSTORE", d.join("ks")).stdin(Stdio::null()).output().expect("wai runs") +} + +fn text(o: &Output) -> String { + format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) +} + +/// The JSON envelope of a command that succeeds, and its `data`. +fn ok(d: &Path, args: &[&str]) -> Value { + let mut a = args.to_vec(); + a.push("--json"); + let o = wai(d, &a); + assert!(o.status.success(), "{args:?}: {}", text(&o)); + let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: not one envelope ({e}): {}", text(&o))); + assert_eq!(v["wai"], "envelope/1", "{v}"); + assert_eq!(v["ok"], true, "{v}"); + v["data"].clone() +} + +/// The exit status and stable code of a command that fails. +fn refused(d: &Path, args: &[&str]) -> (i32, String) { + let mut a = args.to_vec(); + a.push("--json"); + let o = wai(d, &a); + let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: not one envelope ({e}): {}", text(&o))); + assert_eq!(v["ok"], false, "{args:?}: {v}"); + (o.status.code().unwrap(), v["error"]["code"].as_str().unwrap().to_string()) +} + +fn create(d: &Path, name: &str) -> Value { + ok(d, &["key", "create", name, "--for", "signer", "--store", "file"]) +} + +/// The hexadecimal seed kept for key id `id`. +fn seed_of(d: &Path, id: &str) -> String { + std::fs::read_to_string(d.join("ks").join(format!("{id}.seed"))).unwrap().trim().to_string() +} + +const NODE: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + +/// The organisation key of an enrolment `--key arg` signs, so a test sees which key signed. +fn signer_of_enrolment(d: &Path, arg: &str) -> String { + let e = ok(d, &["enrolment", "create", NODE, "--role", "relay", "--key", arg, "--out", "n.wfe", "--yes"]); + e["organisation"].as_str().unwrap().to_string() +} + +#[test] +fn a_key_is_made_kept_private_and_never_printed() { + let d = scratch("create"); + let mut all = String::new(); + let run = |args: &[&str], all: &mut String| { + let o = wai(&d, args); + assert!(o.status.success(), "{args:?}: {}", text(&o)); + all.push_str(&text(&o)); + String::from_utf8_lossy(&o.stdout).into_owned() + }; + let t = run(&["key", "create", "signer", "--for", "signer", "--store", "file"], &mut all); + assert!(t.contains("never printed") && t.contains("--key signer"), "{t}"); + // It says what protects the seed here, and claims no mode where there is none. + assert_eq!(t.contains("mode 0600"), cfg!(unix), "{t}"); + assert_eq!(t.contains("an access list for this account alone"), cfg!(windows), "{t}"); + let c = ok(&d, &["key", "show", "signer"]); + let v = &c["versions"][0]; + let (id, key) = (v["id"].as_str().unwrap(), v["key"].as_str().unwrap()); + assert_eq!((id, v["status"].as_str(), v["store"].as_str(), c["signs_with"].as_str(), c["role"].as_str()), ("signer", Some("active"), Some("file"), Some("signer"), Some("signer"))); + assert_eq!(key.len(), 64); + assert_eq!(v["did_key"].as_str().unwrap(), wai::signer_id::did_key(&<[u8; 32]>::try_from(unhex(key)).unwrap())); + + // The secret is the key's, and in the store alone. + let seed = seed_of(&d, "signer"); + let sk = ed25519_dalek::SigningKey::from_bytes(&<[u8; 32]>::try_from(unhex(&seed)).unwrap()); + assert_eq!(hex(&sk.verifying_key().to_bytes()), key); + assert!(!seed.bytes().all(|b| b == seed.as_bytes()[0]), "a seed of one byte repeated"); + let record = std::fs::read_to_string(d.join("ks/signer.json")).unwrap(); + assert!(!record.contains(&seed), "the record holds no secret"); + let r: Value = serde_json::from_str(&record).unwrap(); + assert_eq!(r["format"], "wai.key/1"); + + // No command prints it, as text or in an envelope. + for args in [&["key", "list"][..], &["key", "show", "signer"], &["key", "export", "signer"], &["key", "export", "signer", "--format", "did-key"], &["key", "export", "signer", "--format", "entry", "--may-sign", "delivery"], &["key", "export", "signer", "--format", "wai-keys", "--may-sign", "delivery"]] { + run(args, &mut all); + all.push_str(&ok(&d, args).to_string()); + } + assert!(!all.contains(&seed), "a command printed the secret: {all}"); + + // A second key has a seed of its own; the two are listed. + create(&d, "other"); + assert_ne!(seed_of(&d, "other"), seed); + let l = ok(&d, &["key", "list"]); + let names: Vec<&str> = l["keys"].as_array().unwrap().iter().map(|k| k["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["other", "signer"]); + assert_eq!(l["keys"][1]["key"].as_str().unwrap(), key); +} + +#[cfg(unix)] +#[test] +fn the_keystore_and_its_files_are_the_owners_alone() { + use std::os::unix::fs::PermissionsExt; + let d = scratch("modes"); + create(&d, "k"); + ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"]); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&d.join("ks")), 0o700); + assert_eq!(mode(&d.join("ks/k.json")), 0o600); + assert_eq!(mode(&d.join("ks/k.2.seed")), 0o600); + + // A seed file another user may read does not sign: exit 3, with what to do. + std::fs::set_permissions(d.join("ks/k.2.seed"), std::fs::Permissions::from_mode(0o640)).unwrap(); + let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]); + assert_eq!(o.status.code(), Some(3), "{}", text(&o)); + assert!(text(&o).contains("chmod 600"), "{}", text(&o)); + assert_eq!(refused(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]).1, "key-file-exposed"); + assert!(!d.join("n.wfe").exists()); +} + +#[test] +fn the_keystore_is_found_by_flag_then_environment_then_the_users_configuration() { + let d = scratch("where"); + let home = d.join("home"); + let list = |envs: &[(&str, &Path)], args: &[&str]| -> String { + let mut c = Command::new(env!("CARGO_BIN_EXE_wai")); + c.current_dir(&d).args(["key", "list", "--json"]).args(args).stdin(Stdio::null()).env_remove("WAI_KEYSTORE").env_remove("XDG_CONFIG_HOME").env_remove("APPDATA").env("HOME", &home); + for (k, v) in envs { + c.env(k, v); + } + let o = c.output().unwrap(); + assert!(o.status.success(), "{}", text(&o)); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!(v["data"]["keys"].as_array().unwrap().len(), 0, "{v}"); + v["data"]["keystore"].as_str().unwrap().to_string() + }; + let under = |x: &Path, first: &str| x.join(first).join("wai").join("keys").display().to_string(); + #[cfg(windows)] + assert_eq!(list(&[("APPDATA", &d.join("roaming"))], &[]), d.join("roaming").join("wai").join("keys").display().to_string()); + assert_eq!(list(&[], &[]), under(&home, ".config")); + assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg"))], &[]), d.join("xdg").join("wai").join("keys").display().to_string()); + assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg")), ("WAI_KEYSTORE", &d.join("env"))], &[]), d.join("env").display().to_string()); + assert_eq!(list(&[("WAI_KEYSTORE", &d.join("env"))], &["--keystore", "flag"]), "flag"); + // Listing a keystore that is not there makes nothing. + assert!(!home.exists()); +} + +#[test] +fn every_signing_operation_of_this_build_takes_a_keys_name() { + let d = scratch("sign"); + let key = create(&d, "org")["key"].as_str().unwrap().to_string(); + // An enrolment: a name, and a file as before. + assert_eq!(signer_of_enrolment(&d, "org"), key); + let seed = seed_of(&d, "org"); + std::fs::write(d.join("org.hex"), &seed).unwrap(); + assert_eq!(signer_of_enrolment(&d, "org.hex"), key); + std::fs::write(d.join("org.raw"), unhex(&seed)).unwrap(); + assert_eq!(signer_of_enrolment(&d, "org.raw"), key); + // A file of that name comes first: what ran before still runs the same. + let other: String = (1..=32u8).map(|b| format!("{b:02x}")).collect(); + std::fs::write(d.join("org"), &other).unwrap(); + assert_ne!(signer_of_enrolment(&d, "org"), key); + std::fs::remove_file(d.join("org")).unwrap(); + + // Not a file and not a key: exit 5, saying where it looked. + for missing in ["absent", "absent.key"] { + let a = ["enrolment", "create", NODE, "--role", "relay", "--key", missing, "--out", "n.wfe", "--yes"]; + assert_eq!(refused(&d, &a), (5, "key-not-found".to_string()), "{missing}"); + } + + // A conformance record, through its own reader of --key. + let wai_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + // A command line of the system's shell: single quotes quote nothing to cmd. + let vectors = env!("CARGO_BIN_EXE_wai_jwp_vectors"); + let imp = if cfg!(windows) { format!("\"{vectors}\" verdicts {{corpus}}") } else { format!("'{vectors}' verdicts {{corpus}}") }; + ok(&d, &["conform", "verifier", "--suite", "jwp", "--impl", &imp, "--root", wai_dir.to_str().unwrap(), "--key", "org", "--out", "jwp.wai-conform.json"]); + let rec: Value = serde_json::from_slice(&std::fs::read(d.join("jwp.wai-conform.json")).unwrap()).unwrap(); + assert_eq!(rec["signer_pubkey"].as_str().unwrap(), key, "{rec}"); + + // A channel's receipt (the sample channel of extensions/channel-lineup.md §11). + let c = wai_dir.join("live-conformance/channels"); + let f = |n: &str| c.join(n).to_str().unwrap().to_string(); + let (x, a, b, p, art) = (f("sample.xmltv.xml"), f("sample-a.mrss.xml"), f("sample-b.mrss.xml"), f("sample.m3u8"), f("")); + ok(&d, &["channel", "import", "--channel", "sample-1", "--xmltv", &x, "--mrss", &a, "--mrss-owner", "owner-a", "--mrss", &b, "--mrss-owner", "owner-b", "--owner", "owner-c", "--playlist", &p, "--artwork", &art, "--key", "org", "--out-dir", "channel"]); + let receipt = std::fs::read_to_string(d.join("channel/channel.json")).unwrap(); + assert!(receipt.contains(&key), "the receipt names the key that signed it: {receipt}"); +} + +#[cfg(feature = "video_manifest")] +#[test] +fn a_playlist_is_sealed_with_a_keys_name_or_a_seed_given_as_before() { + let d = scratch("seal"); + let key = create(&d, "pub")["key"].as_str().unwrap().to_string(); + let wai_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let obj = |id: &str| -> String { + let c = wai_dir.join("http-conformance"); + let listing: Value = serde_json::from_slice(&std::fs::read(c.join("objects.json")).unwrap()).unwrap(); + c.join(listing[id]["path"].as_str().unwrap()).to_str().unwrap().to_string() + }; + let objs: Vec = ["O9a", "O9b", "O9c"].iter().map(|i| obj(i)).collect(); + std::fs::write(d.join("classical.m3u8"), "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:1\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:0.640,\nc0.m4s\n#EXTINF:0.640,\nc1.m4s\n#EXTINF:0.640,\nc2.m4s\n#EXT-X-ENDLIST\n").unwrap(); + let list = serde_json::json!({ + "uri_prefix": "objects/", + "variants": [{"uri": "wai-180.m3u8", "classical": "classical.m3u8", "resolution": [320, 180], "frame_rate_milli": 25000, + "segments": objs.iter().map(|o| serde_json::json!({"object": o})).collect::>()}], + }); + std::fs::write(d.join("list.json"), list.to_string()).unwrap(); + assert!(wai(&d, &["http", "hls", "--objects", "list.json", "--out", "."]).status.success()); + let sealed_by = |k: &str, out: &str| -> String { + let o = wai(&d, &["http", "seal-playlist", "wai-180.m3u8", "--key", k, "--out", out]); + assert!(o.status.success(), "{}", text(&o)); + let mut found = String::new(); + for e in std::fs::read_dir(d.join(out).join("mr")).unwrap() { + found.push_str(&std::fs::read_to_string(e.unwrap().path()).unwrap()); + } + found + }; + assert!(sealed_by("pub", "a").contains(&key)); + let seed = seed_of(&d, "pub"); + assert!(sealed_by(&seed, "b").contains(&key), "64 hexadecimal digits, as this command took before"); +} + +/// A share contract, through the revenue share's and the opportunity report's reader of `--key`. +#[cfg(feature = "revenue_share")] +#[test] +fn a_contract_is_signed_with_a_keys_name() { + let d = scratch("contract"); + let key = create(&d, "operator")["key"].as_str().unwrap().to_string(); + let owner = "33".repeat(32); + let terms = serde_json::json!({ + "start": 0, "end": 10, "currency": "EUR", "channel": "fast-7", "channel_signer": "22".repeat(32), "rule": "preceding", "between": 0, + "carriers": [{"room": "11".repeat(32)}], "owners": [owner], + "splits": [{"operator": 500_000, "owners": {owner.clone(): 500_000}}], "assets": {"a/ep-1": 0}, + }); + std::fs::write(d.join("terms.json"), terms.to_string()).unwrap(); + let c = ok(&d, &["contract", "create", "terms.json", "--key", "operator", "--out", "c.wrc"]); + assert_eq!(c["operator"].as_str().unwrap(), key, "{c}"); + assert_eq!(refused(&d, &["contract", "create", "terms.json", "--key", "absent", "--out", "c2.wrc"]), (5, "key-not-found".to_string())); +} + +/// An upscale's signed record, through the derivations' reader of `--key`, which takes the seed +/// itself as well, as it did. +#[cfg(feature = "exact_upscale")] +#[test] +fn an_upscale_record_is_signed_with_a_keys_name() { + let d = scratch("upscale"); + let key = create(&d, "edge")["key"].as_str().unwrap().to_string(); + let prior = Path::new(env!("CARGO_MANIFEST_DIR")).join("../live-conformance/upscale/priors/generic.wsr"); + std::fs::write(d.join("in.png"), wai::png_still::encode(&[9u8; 3 * 8 * 6], 8, 6)).unwrap(); + let signer = |k: &str, rec: &str| -> String { + let o = wai(&d, &["upscale", "run", "in.png", "--prior", prior.to_str().unwrap(), "-o", "out.png", "--record", rec, "--envelope", "in.wai", "--key", k, "--yes"]); + assert!(o.status.success(), "{}", text(&o)); + std::fs::read_to_string(d.join(rec)).unwrap() + }; + assert!(signer("edge", "a.json").contains(&key)); + assert!(signer(&seed_of(&d, "edge"), "b.json").contains(&key)); +} + +#[test] +fn a_rotation_waits_for_yes_then_retires_the_old_key_and_destroys_its_secret() { + let d = scratch("rotate"); + let first = create(&d, "k")["key"].as_str().unwrap().to_string(); + let before = std::fs::read(d.join("ks/k.json")).unwrap(); + let old_seed = seed_of(&d, "k"); + + // Off a terminal, without --yes: exit 4, the proposal and the command, nothing changed. + let o = wai(&d, &["key", "rotate", "k", "--store", "file", "--json"]); + assert_eq!(o.status.code(), Some(4), "{}", text(&o)); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!(v["error"]["code"], "confirmation-required", "{v}"); + assert_eq!(v["proposed"][0]["new"], "k.2", "{v}"); + assert_eq!(v["proposed"][0]["retire"], "k", "{v}"); + assert_eq!(v["proposed"][0]["destroy_secret"], "k", "{v}"); + assert!(v["confirm"].as_str().unwrap().ends_with("--yes"), "{v}"); + // --no-input never asks either. + assert_eq!(wai(&d, &["key", "rotate", "k", "--store", "file", "--no-input"]).status.code(), Some(4)); + assert_eq!(std::fs::read(d.join("ks/k.json")).unwrap(), before); + assert!(d.join("ks/k.seed").is_file() && !d.join("ks/k.2.seed").exists()); + + let r = ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"]); + assert_eq!((r["previous"].as_str(), r["id"].as_str(), r["secret_destroyed"].as_bool()), (Some("k"), Some("k.2"), Some(true)), "{r}"); + let until = r["until"].as_str().unwrap(); + let second = r["key"].as_str().unwrap(); + assert_ne!(second, first); + assert!(!d.join("ks/k.seed").exists(), "the old secret is gone"); + let new_seed = seed_of(&d, "k.2"); + assert_ne!(new_seed, old_seed); + + // The record states both versions as a key log does: the old retired with until, the new active. + let s = ok(&d, &["key", "show", "k"]); + assert_eq!(s["signs_with"], "k.2"); + let (v0, v1) = (&s["versions"][0], &s["versions"][1]); + assert_eq!((v0["id"].as_str(), v0["key"].as_str(), v0["status"].as_str(), v0["until"].as_str(), v0["compromised_since"].as_str(), v0["store"].as_str()), (Some("k"), Some(first.as_str()), Some("retired"), Some(until), None, None)); + assert_eq!((v1["id"].as_str(), v1["key"].as_str(), v1["status"].as_str(), v1["until"].as_str(), v1["store"].as_str()), (Some("k.2"), Some(second), Some("active"), None, Some("file"))); + + // --key signs with the successor; a further rotation numbers on. + assert_eq!(signer_of_enrolment(&d, "k"), second); + assert_eq!(ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"])["id"], "k.3"); + + // The export names every version; the line for a log names the newest. + let e = ok(&d, &["key", "export", "k", "--format", "entry", "--may-sign", "delivery,energy/Estimated"]); + let ids: Vec<&str> = e["entries"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["k", "k.2", "k.3"]); + assert_eq!(e["entries"][0]["until"].as_str(), Some(until)); + assert_eq!(e["entries"][0]["may_sign"], serde_json::json!(["delivery", "energy/Estimated"])); + let l = ok(&d, &["key", "export", "k", "--format", "wai-keys", "--may-sign", "delivery,energy/Estimated"]); + assert!(l["text"].as_str().unwrap().starts_with("id=k.3,key="), "{l}"); + assert!(l["text"].as_str().unwrap().trim_end().ends_with(",may_sign=delivery|energy/Estimated"), "{l}"); +} + +#[test] +fn a_revoked_key_signs_nothing_and_its_time_never_changes() { + let d = scratch("revoke"); + let first = create(&d, "k")["key"].as_str().unwrap().to_string(); + let args = ["key", "revoke", "k", "--compromised-since", "2026-11-01T00:00:00Z"]; + assert_eq!(refused(&d, &args), (4, "confirmation-required".to_string())); + assert!(d.join("ks/k.seed").is_file(), "nothing changed"); + assert_eq!(refused(&d, &["key", "revoke", "k", "--compromised-since", "yesterday", "--yes"]).0, 2); + assert_eq!(refused(&d, &["key", "revoke", "k", "--id", "k.9", "--yes"]), (5, "key-not-found".to_string())); + + let mut yes = args.to_vec(); + yes.push("--yes"); + let r = ok(&d, &yes); + assert_eq!((r["id"].as_str(), r["compromised_since"].as_str(), r["secret_destroyed"].as_bool()), (Some("k"), Some("2026-11-01T00:00:00Z"), Some(true)), "{r}"); + assert!(!d.join("ks/k.seed").exists()); + + // It signs nothing, and says so; the log's line refuses it; the record stays. + let a = ["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]; + assert_eq!(refused(&d, &a), (2, "key-revoked".to_string())); + assert!(!d.join("n.wfe").exists()); + assert_eq!(refused(&d, &["key", "export", "k", "--format", "wai-keys", "--may-sign", "delivery"]).1, "key-revoked"); + let s = ok(&d, &["key", "show", "k"]); + assert_eq!((s["signs_with"].as_str(), s["versions"][0]["status"].as_str(), s["versions"][0]["key"].as_str(), s["versions"][0]["store"].as_str()), (None, Some("revoked"), Some(first.as_str()), None)); + + // Its time, once set, does not change (jwp-receipts §7.2 rule 6). + assert_eq!(refused(&d, &["key", "revoke", "k", "--yes"]), (2, "key-already-revoked".to_string())); + + // A successor signs again; the revoked version stays revoked, with no until. + let n = ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"]); + assert_eq!((n["id"].as_str(), n["secret_destroyed"].as_bool()), (Some("k.2"), Some(false))); + assert_eq!(signer_of_enrolment(&d, "k"), n["key"].as_str().unwrap()); + let s = ok(&d, &["key", "show", "k"]); + assert_eq!((s["versions"][0]["status"].as_str(), s["versions"][0]["until"].as_str()), (Some("revoked"), None)); + + // An earlier version revoked keeps the time it was retired at; the newest still signs. + let d = scratch("revoke-old"); + create(&d, "k"); + let until = ok(&d, &["key", "rotate", "k", "--store", "file", "--yes"])["until"].as_str().unwrap().to_string(); + let newest = ok(&d, &["key", "show", "k"])["versions"][1]["key"].as_str().unwrap().to_string(); + let r = ok(&d, &["key", "revoke", "k", "--id", "k", "--yes"]); + assert_eq!(r["secret_destroyed"], false, "a retired version's secret is gone already"); + let s = ok(&d, &["key", "show", "k"]); + assert_eq!((s["versions"][0]["status"].as_str(), s["versions"][0]["until"].as_str()), (Some("revoked"), Some(until.as_str()))); + assert_eq!(signer_of_enrolment(&d, "k"), newest); +} + +#[test] +fn a_seed_file_is_imported_and_a_published_one_refused() { + let d = scratch("import"); + let seed: [u8; 32] = core::array::from_fn(|i| 100 + i as u8); + let want = hex(&ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes()); + std::fs::write(d.join("seed.hex"), format!("{}\n", hex(&seed))).unwrap(); + let args = ["key", "import", "old", "--for", "audit", "--from", "seed.hex", "--store", "file"]; + let r = ok(&d, &args); + assert_eq!((r["key"].as_str(), r["role"].as_str(), r["published_test_seed"].as_bool()), (Some(want.as_str()), Some("audit"), Some(false)), "{r}"); + assert_eq!(seed_of(&d, "old"), hex(&seed)); + assert!(text(&wai(&d, &["key", "show", "old"])).contains("old")); + assert!(d.join("seed.hex").is_file(), "the file it came from is the person's to remove"); + assert_eq!(signer_of_enrolment(&d, "old"), want); + + // The name is taken; the key is held already, under any name; each exits 2 with its code. + assert_eq!(refused(&d, &args), (2, "key-exists".to_string())); + assert_eq!(refused(&d, &["key", "create", "old", "--for", "dev", "--store", "file"]), (2, "key-exists".to_string())); + assert_eq!(refused(&d, &["key", "import", "again", "--for", "dev", "--from", "seed.hex", "--store", "file"]), (2, "key-duplicate".to_string())); + assert!(!d.join("ks/again.json").exists() && !d.join("ks/again.seed").exists()); + // 32 raw bytes read as well. + let raw: [u8; 32] = core::array::from_fn(|i| 200 - i as u8); + std::fs::write(d.join("seed.raw"), raw).unwrap(); + assert_eq!(ok(&d, &["key", "import", "raw", "--for", "dev", "--from", "seed.raw", "--store", "file"])["key"].as_str().unwrap(), hex(&ed25519_dalek::SigningKey::from_bytes(&raw).verifying_key().to_bytes())); + // A file that holds neither, or is not there. + std::fs::write(d.join("bad.hex"), "not a seed").unwrap(); + assert_eq!(refused(&d, &["key", "import", "bad", "--for", "dev", "--from", "bad.hex", "--store", "file"]), (2, "key-file-malformed".to_string())); + assert_eq!(refused(&d, &["key", "import", "bad", "--for", "dev", "--from", "absent.hex", "--store", "file"]).0, 5); + + // A published test seed (one byte repeated) is refused unless named one, and the record says so. + std::fs::write(d.join("published.hex"), "07".repeat(32)).unwrap(); + assert_eq!(refused(&d, &["key", "import", "t", "--for", "dev", "--from", "published.hex", "--store", "file"]), (2, "published-test-seed".to_string())); + assert!(!d.join("ks/t.json").exists()); + let r = ok(&d, &["key", "import", "t", "--for", "dev", "--from", "published.hex", "--store", "file", "--published-test-seed"]); + assert_eq!(r["published_test_seed"], true); + assert_eq!(ok(&d, &["key", "show", "t"])["versions"][0]["published_test_seed"], true); + assert!(text(&wai(&d, &["key", "show", "t"])).contains("a published test seed")); +} + +#[test] +fn a_dry_run_makes_no_key() { + let d = scratch("dry"); + let r = ok(&d, &["key", "create", "k", "--for", "dev", "--store", "file", "--dry-run"]); + assert!(r["key"].is_null(), "{r}"); + assert!(!d.join("ks").exists()); + let o = wai(&d, &["key", "create", "k", "--for", "dev", "--store", "file", "--dry-run", "--json"]); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + let plan = v["plan"].to_string(); + assert!(plan.contains("k.json") && plan.contains("k.seed"), "{plan}"); + create(&d, "k"); + let before = std::fs::read(d.join("ks/k.json")).unwrap(); + let r = ok(&d, &["key", "rotate", "k", "--store", "file", "--dry-run"]); + assert_eq!((r["id"].as_str(), r["key"].is_null()), (Some("k.2"), true), "{r}"); + let o = wai(&d, &["key", "revoke", "k", "--dry-run", "--json"]); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + assert!(v["plan"].to_string().contains("k.seed"), "{v}"); + assert_eq!(std::fs::read(d.join("ks/k.json")).unwrap(), before); + assert!(d.join("ks/k.seed").is_file()); +} + +#[test] +fn what_the_command_line_gets_wrong_is_refused_by_exit_code_and_stable_code() { + let d = scratch("usage"); + for args in [ + &["key", "create"][..], + &["key", "create", "k"], + &["key", "create", "K", "--for", "dev"], + &["key", "create", "a.b", "--for", "dev"], + &["key", "create", "k", "--for", "boss"], + &["key", "create", "k", "--for", "dev", "--store", "cloud"], + &["key", "create", "k", "--for", "dev", "--nope"], + &["key", "import", "k", "--for", "dev"], + &["key", "list", "--for", "boss"], + &["key", "show"], + &["key", "export"], + ] { + assert_eq!(wai(&d, args).status.code(), Some(2), "{args:?}"); + } + assert!(!d.join("ks").exists(), "nothing was made"); + assert_eq!(refused(&d, &["key", "show", "absent"]), (5, "key-not-found".to_string())); + assert_eq!(refused(&d, &["key", "rotate", "absent", "--yes"]), (5, "key-not-found".to_string())); + assert_eq!(refused(&d, &["key", "revoke", "absent", "--yes"]), (5, "key-not-found".to_string())); + assert_eq!(refused(&d, &["key", "export", "absent"]), (5, "key-not-found".to_string())); + + create(&d, "k"); + for a in [&["key", "export", "k", "--format", "entry"][..], &["key", "export", "k", "--format", "wai-keys"], &["key", "export", "k", "--may-sign", "delivery"], &["key", "export", "k", "--format", "pem"], &["key", "export", "k", "--format", "entry", "--may-sign", "a b"]] { + assert_eq!(refused(&d, a).0, 2, "{a:?}"); + } + // Replacing a file the export would write waits for --yes. + std::fs::write(d.join("k.pub"), "there").unwrap(); + assert_eq!(refused(&d, &["key", "export", "k", "--out", "k.pub"]), (4, "confirmation-required".to_string())); + assert_eq!(std::fs::read(d.join("k.pub")).unwrap(), b"there"); + let key = ok(&d, &["key", "export", "k", "--out", "k.pub", "--yes"])["text"].as_str().unwrap().to_string(); + assert_eq!(std::fs::read_to_string(d.join("k.pub")).unwrap(), key); + + #[cfg(not(target_os = "macos"))] + assert_eq!(refused(&d, &["key", "create", "c", "--for", "dev", "--store", "keychain"]), (2, "keychain-unavailable".to_string())); +} + +#[test] +fn a_record_that_does_not_read_is_refused_and_listed_as_such() { + let d = scratch("malformed"); + create(&d, "good"); + create(&d, "broken"); + let p = d.join("ks/broken.json"); + let t = std::fs::read_to_string(&p).unwrap(); + std::fs::write(&p, t.replace("\"role\": \"signer\"", "\"role\": \"signer\",\n \"extra\": 1")).unwrap(); + assert_eq!(refused(&d, &["key", "show", "broken"]), (2, "key-record-malformed".to_string())); + let l = ok(&d, &["key", "list"]); + assert_eq!(l["keys"][0]["error"]["code"], "key-record-malformed", "{l}"); + assert_eq!(l["keys"][1]["name"], "good", "{l}"); + let a = ["enrolment", "create", NODE, "--role", "relay", "--key", "broken", "--out", "n.wfe"]; + assert_eq!(refused(&d, &a), (2, "key-record-malformed".to_string())); + + // A record and a secret changed apart do not sign. + let other: String = (1..=32u8).map(|b| format!("{b:02x}")).collect(); + let seed = d.join("ks/good.seed"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&seed, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + std::fs::write(&seed, other).unwrap(); + let a = ["enrolment", "create", NODE, "--role", "relay", "--key", "good", "--out", "n.wfe"]; + assert_eq!(refused(&d, &a), (2, "key-mismatch".to_string())); +} + +/// An origin's key log lists what `wai key` exports, as it is: a rotation and a revocation carried +/// over by `wai-keys`, and the log verified (jwp-receipts §7.2, §7.3). +#[cfg(feature = "key_discovery")] +#[test] +fn wai_keys_reads_what_wai_key_exports() { + let d = scratch("log"); + let wk = |args: &[&str]| -> Output { Command::new(env!("CARGO_BIN_EXE_wai-keys")).current_dir(&d).args(args).output().expect("wai-keys runs") }; + create(&d, "origin-root"); + create(&d, "next-root"); + create(&d, "delivery"); + let root_file = d.join("ks/origin-root.seed"); + let root_file = root_file.to_str().unwrap(); + let next_key = ok(&d, &["key", "export", "next-root"])["text"].as_str().unwrap().trim().to_string(); + let line = ok(&d, &["key", "export", "delivery", "--format", "wai-keys", "--may-sign", "delivery"])["text"].as_str().unwrap().trim().to_string(); + let o = wk(&["init", "--origin", "https://example.test", "--root-seed-file", root_file, "--next-root-key", &next_key, "--issued-at", "2026-11-05T00:00:00Z", "--key", &line]); + assert!(o.status.success(), "{}", text(&o)); + std::fs::write(d.join("log.json"), &o.stdout).unwrap(); + + // A rotation: the successor's line and the old id's until, as `wai key rotate` states them. + let r = ok(&d, &["key", "rotate", "delivery", "--store", "file", "--yes"]); + let retire = format!("{}@{}", r["previous"].as_str().unwrap(), r["until"].as_str().unwrap()); + let line = ok(&d, &["key", "export", "delivery", "--format", "wai-keys", "--may-sign", "delivery"])["text"].as_str().unwrap().trim().to_string(); + let until = r["until"].as_str().unwrap(); + let o = wk(&["append", "log.json", "--root-seed-file", root_file, "--issued-at", until, "--add", &line, "--retire", &retire]); + assert!(o.status.success(), "{}", text(&o)); + std::fs::write(d.join("log.json"), &o.stdout).unwrap(); + + // A revocation, at the time `wai key revoke` states. + let v = ok(&d, &["key", "revoke", "delivery", "--id", "delivery", "--compromised-since", "2026-11-05T00:00:00Z", "--yes"]); + let revoke = format!("{}@{}", v["id"].as_str().unwrap(), v["compromised_since"].as_str().unwrap()); + let o = wk(&["append", "log.json", "--root-seed-file", root_file, "--issued-at", until, "--revoke", &revoke]); + assert!(o.status.success(), "{}", text(&o)); + std::fs::write(d.join("log.json"), &o.stdout).unwrap(); + + let o = wk(&["verify", "log.json", "--origin", "https://example.test"]); + assert!(o.status.success(), "{}", text(&o)); + assert!(String::from_utf8_lossy(&o.stdout).starts_with("anchored"), "{}", text(&o)); + + // The log states what the keystore states, entry for entry. + let log: Value = serde_json::from_slice(&std::fs::read(d.join("log.json")).unwrap()).unwrap(); + let head = log["revisions"].as_array().unwrap().last().unwrap(); + let e = ok(&d, &["key", "export", "delivery", "--format", "entry", "--may-sign", "delivery"]); + assert_eq!(head["keys"], e["entries"], "the head's entries are the export's"); +} + +/// Windows' access lists, read here as the system's own descriptor language, which names accounts +/// by SID in every locale, and widened by SID. +#[cfg(windows)] +mod access_lists { + use super::*; + + fn powershell(command: &str) -> String { + // Without PSModulePath: a parent PowerShell 7's would keep this one from loading Get-Acl. + let o = Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", command]).env_remove("PSModulePath").stdin(Stdio::null()).output().expect("powershell runs"); + assert!(o.status.success(), "{command}: {}", text(&o)); + String::from_utf8_lossy(&o.stdout).trim().to_string() + } + + fn me() -> String { + powershell("[Security.Principal.WindowsIdentity]::GetCurrent().User.Value") + } + + fn descriptor(p: &Path) -> String { + powershell(&format!("(Get-Acl -LiteralPath '{}').Sddl", p.display())) + } + + /// `p`'s owner is `me`, its inheritance is off, and its one entry is `me`'s, `entry` being that + /// entry's flags. + fn holds_only_me(p: &Path, me: &str, flags: &str) { + let s = descriptor(p); + assert!(s.starts_with(&format!("O:{me}G:")), "{}: its owner: {s}", p.display()); + let (_, dacl) = s.split_once("D:").unwrap_or_else(|| panic!("{}: no list: {s}", p.display())); + let (control, entries) = dacl.split_once('(').unwrap_or_else(|| panic!("{}: no entry: {s}", p.display())); + assert!(control.contains('P'), "{}: inheritance is on: {s}", p.display()); + assert_eq!(entries, format!("A;{flags};FA;;;{me})"), "{}: its entries: {s}", p.display()); + } + + fn icacls(p: &Path, args: &[&str]) { + let o = Command::new("icacls").arg(p).args(args).output().expect("icacls runs"); + assert!(o.status.success(), "icacls {args:?}: {}", text(&o)); + } + + /// A directory in the folder every user of the machine shares, removed in every case. + struct Shared(PathBuf); + + impl Shared { + fn new(name: &str) -> Shared { + let d = PathBuf::from(std::env::var_os("PUBLIC").expect("%PUBLIC%")).join(format!("wai-key-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + Shared(d) + } + } + + impl Drop for Shared { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn wai_in(cwd: &Path, keystore: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(cwd).args(args).env("WAI_KEYSTORE", keystore).stdin(Stdio::null()).output().expect("wai runs") + } + + #[test] + fn a_keystore_in_a_folder_every_user_shares_gets_what_one_in_a_profile_gets() { + let me = me(); + let d = scratch("win-shared"); + std::fs::write(d.join("seed.hex"), (100..132u8).map(|b| format!("{b:02x}")).collect::()).unwrap(); + // One the toolchain makes; and one that exists already, under the folder's wide list, which + // stays as it is while what wai makes inside does not take it. + let (made, there) = (Shared::new("made"), Shared::new("there")); + std::fs::create_dir_all(&there.0).unwrap(); + for ks in [&made.0, &there.0] { + let store = ks.to_str().unwrap(); + for args in [&["key", "create", "k", "--for", "signer", "--keystore", store][..], &["key", "import", "held", "--for", "signer", "--from", "seed.hex", "--keystore", store]] { + let o = wai(&d, args); + assert!(o.status.success(), "{args:?}: {}", text(&o)); + } + for f in ["k.seed", "k.json", "held.seed", "held.json"] { + holds_only_me(&ks.join(f), &me, ""); + } + // A successor's seed, and the record replaced beside it. + let o = wai(&d, &["key", "rotate", "k", "--keystore", store, "--yes"]); + assert!(o.status.success(), "{}", text(&o)); + holds_only_me(&ks.join("k.2.seed"), &me, ""); + holds_only_me(&ks.join("k.json"), &me, ""); + // It signs: this account's list is enough for this account. + let o = wai_in(&d, ks, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe", "--yes"]); + assert!(o.status.success(), "{}", text(&o)); + } + holds_only_me(&made.0, &me, "OICI"); + assert!(!descriptor(&there.0).contains(&format!("D:P(A;OICI;FA;;;{me})")), "a directory that was there stays as it was"); + } + + #[test] + fn a_seed_another_account_may_read_signs_nothing_until_the_fix_it_names() { + let d = scratch("win-widened"); + let ks = d.join("ks"); + create(&d, "k"); + let seed = ks.join("k.seed"); + let sign = ["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe", "--yes"]; + let good = signer_of_enrolment(&d, "k"); + std::fs::remove_file(d.join("n.wfe")).unwrap(); + // Everyone, SYSTEM and Administrators alike, each by its SID. + for sid in ["S-1-1-0", "S-1-5-18", "S-1-5-32-544"] { + icacls(&seed, &["/grant", &format!("*{sid}:R")]); + let o = wai(&d, &["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe", "--yes", "--json"]); + assert_eq!(o.status.code(), Some(3), "{sid}: {}", text(&o)); + assert!(!d.join("n.wfe").exists(), "{sid}: it signed"); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!(v["error"]["code"], "key-file-exposed", "{v}"); + let message = v["error"]["message"].as_str().unwrap(); + assert!(message.contains(seed.to_str().unwrap()) && message.contains(sid), "{message}"); + // The command it names is the whole fix. + let hint = v["error"]["hint"].as_str().unwrap(); + assert!(hint.contains(&format!("*{sid}")), "{hint}"); + use std::os::windows::process::CommandExt; + let fixed = Command::new("cmd").raw_arg(format!("/S /C \"{hint}\"")).stdin(Stdio::null()).output().unwrap(); + assert!(fixed.status.success(), "{hint}: {}", text(&fixed)); + assert_eq!(signer_of_enrolment(&d, "k"), good, "{sid}: fixed, it signs again"); + std::fs::remove_file(d.join("n.wfe")).unwrap(); + } + // A list wider than any one grant: inheritance on again, under the wide list of this folder. + std::fs::rename(ks.join("k.seed"), d.join("k.seed")).unwrap(); + std::fs::rename(ks.join("k.json"), d.join("k.json")).unwrap(); + let o = wai_in(&d, &d, &sign); + assert!(o.status.success(), "moved with its list, it is still its owner's: {}", text(&o)); + std::fs::remove_file(d.join("n.wfe")).unwrap(); + icacls(&d.join("k.seed"), &["/inheritance:e"]); + let o = wai_in(&d, &d, &sign); + assert_eq!(o.status.code(), Some(3), "inheritance on: {}", text(&o)); + // Another owner may change the list, where this account may hand a file over. + let handed = Command::new("icacls").arg(d.join("k.seed")).args(["/setowner", "*S-1-5-32-544"]).output().unwrap(); + if handed.status.success() { + icacls(&d.join("k.seed"), &["/inheritance:r", "/remove", "*S-1-5-32-544", "*S-1-5-18"]); + let o = wai_in(&d, &d, &sign); + assert_eq!(o.status.code(), Some(3), "another owner: {}", text(&o)); + } + } + + #[test] + fn the_default_keystore_under_appdata_works_end_to_end() { + let me = me(); + let d = scratch("win-appdata"); + let run = |args: &[&str]| -> Output { + Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(&d).args(args).env_remove("WAI_KEYSTORE").env_remove("XDG_CONFIG_HOME").env("APPDATA", d.join("roaming")).stdin(Stdio::null()).output().unwrap() + }; + let ok = |args: &[&str]| { + let o = run(args); + assert!(o.status.success(), "{args:?}: {}", text(&o)); + String::from_utf8_lossy(&o.stdout).into_owned() + }; + let ks = d.join("roaming").join("wai").join("keys"); + assert!(ok(&["key", "create", "k", "--for", "signer"]).contains("an access list for this account alone")); + holds_only_me(&ks, &me, "OICI"); + holds_only_me(&ks.join("k.seed"), &me, ""); + holds_only_me(&ks.join("k.json"), &me, ""); + assert!(ok(&["key", "list"]).contains("k")); + ok(&["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n.wfe"]); + ok(&["key", "rotate", "k", "--yes"]); + assert!(!ks.join("k.seed").exists() && ks.join("k.2.seed").is_file()); + ok(&["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n2.wfe"]); + ok(&["key", "revoke", "k", "--yes"]); + assert!(!ks.join("k.2.seed").exists()); + assert_eq!(run(&["enrolment", "create", NODE, "--role", "relay", "--key", "k", "--out", "n3.wfe"]).status.code(), Some(2)); + } +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn unhex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} diff --git a/wai/wai-rs/tests/cli_ops.rs b/wai/wai-rs/tests/cli_ops.rs index b84e2044f..22608b4d5 100644 --- a/wai/wai-rs/tests/cli_ops.rs +++ b/wai/wai-rs/tests/cli_ops.rs @@ -541,6 +541,18 @@ fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec, bool)> more.push(("stage.wrap", s(&["stage", "pack", "b0.wai", "l0.wai", "-o", "x.wai"]), true)); more.push(("stage.unwrap", s(&["stage", "explode", "x0.wai", "0", "-o", "e.wai"]), true)); } + // A developer's keys, in a keystore of this directory and its files: the person's keystore and + // keychain are never touched here. + for k in ["turning", "spent"] { + let o = wai_in(d, &["key", "create", k, "--for", "signer", "--keystore", "ks", "--store", "file"]); + assert!(o.status.success(), "{}", text(&o)); + } + std::fs::write(d.join("seed.hex"), (100..132u8).map(|b| format!("{b:02x}")).collect::()).unwrap(); + more.push(("key.create", s(&["key", "create", "made", "--for", "dev", "--keystore", "ks", "--store", "file"]), true)); + more.push(("key.import", s(&["key", "import", "held", "--for", "signer", "--from", "seed.hex", "--keystore", "ks", "--store", "file"]), true)); + more.push(("key.rotate", s(&["key", "rotate", "turning", "--keystore", "ks", "--store", "file", "--yes"]), true)); + more.push(("key.revoke", s(&["key", "revoke", "spent", "--keystore", "ks", "--yes"]), true)); + more.push(("key.export", s(&["key", "export", "turning", "--keystore", "ks", "--out", "turning.pub"]), true)); let mut v = vec![ ("toolchain.test", s(&["hello", "hello-out"]), true), ("project.create", s(&["create", "proj", "--template", "video-room"]), true), diff --git a/wai/wai-rs/tests/cli_toolchain.rs b/wai/wai-rs/tests/cli_toolchain.rs new file mode 100644 index 000000000..76d1779d3 --- /dev/null +++ b/wai/wai-rs/tests/cli_toolchain.rs @@ -0,0 +1,254 @@ +//! The toolchain's own surface through the `wai` binary (`--features quickstart --test +//! cli_toolchain`): +//! +//! - `wai --version` and `--json` name one version, the digests of the catalogue and the registry +//! as they are committed, the release of the standard and the build's features; the crate, the +//! export and the changelog's newest release say the same version; +//! - `wai docs search` and `wai registry show` give what `wai mcp`'s tools give, with a refusal's +//! exit code and stable code; +//! - a deprecated flag still works and says so in one line, and `wai/DEPRECATIONS.md` is held to +//! the catalogue, both ways. +#![cfg(feature = "quickstart")] + +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use wai::catalogue::{self, OPS}; + +fn wai_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("..").canonicalize().unwrap() +} + +fn scratch(name: &str) -> PathBuf { + let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_toolchain").join(name); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// `wai`, with no specification root from its environment. +fn wai(d: &Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(d).args(args).env_remove("WAI_SPEC_ROOT").stdin(Stdio::null()).output().expect("wai runs") +} + +fn text(o: &Output) -> String { + format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) +} + +/// The envelope of a `--json` run. +fn envelope(d: &Path, args: &[&str]) -> (Option, Value) { + let mut a = args.to_vec(); + a.push("--json"); + let o = wai(d, &a); + let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{args:?}: not one envelope ({e}): {}", text(&o))); + assert_eq!(v["wai"], catalogue::ENVELOPE, "{v}"); + (o.status.code(), v) +} + +fn sha256(b: &[u8]) -> String { + Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect() +} + +#[test] +fn version_names_the_toolchain_its_catalogue_its_registry_and_the_standard() { + let d = scratch("version"); + let (code, v) = envelope(&d, &["--version"]); + assert_eq!(code, Some(0), "{v}"); + assert_eq!((v["ok"].as_bool(), v["op"].as_str()), (Some(true), Some("toolchain.show")), "{v}"); + let data = &v["data"]; + let version = env!("CARGO_PKG_VERSION"); + assert_eq!(data["toolchain"], version); + assert_eq!(data["specification"]["version"], version); + assert_eq!(data["specification"]["formats"], json!(["WAI1", "WAI2"])); + assert_eq!(data["envelope"], catalogue::ENVELOPE); + assert_eq!(data["catalogue"]["format"], catalogue::FORMAT); + assert_eq!(data["catalogue"]["operations"], OPS.len()); + // The digests are of the committed files, byte for byte. + let (ops, reg) = (std::fs::read(wai_dir().join("catalogue/ops.json")).unwrap(), std::fs::read(wai_dir().join("registry/capabilities.json")).unwrap()); + assert_eq!(data["catalogue"]["sha256"], sha256(&ops)); + assert_eq!(data["registry"]["sha256"], sha256(®)); + let caps: Value = serde_json::from_slice(®).unwrap(); + assert_eq!(data["registry"]["capabilities"], caps["capabilities"].as_array().unwrap().len()); + // The features named are this build's: each answers on, and quickstart is among them here. + let features: Vec<&str> = data["features"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert!(features.contains(&"quickstart"), "{features:?}"); + assert!(features.iter().all(|f| catalogue::feature_on(f) == Some(true)), "{features:?}"); + + // Text, for a person; the other spellings run the same operation. + let o = wai(&d, &["--version"]); + assert!(o.status.success(), "{}", text(&o)); + let t = String::from_utf8_lossy(&o.stdout).into_owned(); + assert_eq!(t.lines().next(), Some(format!("wai {version}").as_str()), "{t}"); + assert!(t.contains(&sha256(&ops)) && t.contains(&sha256(®)), "{t}"); + for spelling in [&["version"][..], &["toolchain", "show"]] { + assert_eq!(wai(&d, spelling).stdout, o.stdout, "{spelling:?}"); + } + assert_eq!(wai(&d, &["--version", "extra"]).status.code(), Some(2)); + let h = wai(&d, &["--version", "--help"]); + assert!(h.status.success() && String::from_utf8_lossy(&h.stdout).contains("toolchain.show"), "{}", text(&h)); +} + +/// One version source: what the binary says is the crate's, the export's and the newest release of +/// the changelog (WAI toolchain v1, U1). +#[test] +fn the_crate_the_export_and_the_changelog_name_one_version() { + let version = env!("CARGO_PKG_VERSION"); + let ops: Value = serde_json::from_slice(&std::fs::read(wai_dir().join("catalogue/ops.json")).unwrap()).unwrap(); + assert_eq!(ops["toolchain"], version, "wai/catalogue/ops.json"); + let log = std::fs::read_to_string(wai_dir().join("CHANGELOG.md")).unwrap(); + let newest = log.lines().find_map(|l| l.strip_prefix("## v")).expect("a release heading"); + let newest = newest.split_whitespace().next().unwrap(); + assert_eq!(newest, version, "wai/CHANGELOG.md's newest release is not this crate's version"); +} + +/// A `wai mcp` server's answer to one tool, as `structuredContent`. +fn tool(args: Value, name: &str, project: &Path) -> Value { + let mut c = Command::new(env!("CARGO_BIN_EXE_wai")) + .args(["mcp", "--spec-root", wai_dir().to_str().unwrap(), "--project", project.to_str().unwrap()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("wai mcp starts"); + let mut stdin = c.stdin.take().unwrap(); + let mut out = BufReader::new(c.stdout.take().unwrap()); + let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}}}); + let call = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": name, "arguments": args}}); + writeln!(stdin, "{init}\n{}\n{call}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).unwrap(); + let mut line = String::new(); + out.read_line(&mut line).unwrap(); + line.clear(); + out.read_line(&mut line).unwrap(); + drop(stdin); + let _ = c.wait(); + let r: Value = serde_json::from_str(&line).unwrap(); + r["result"]["structuredContent"].clone() +} + +#[test] +fn docs_search_is_the_tool_wai_mcp_serves() { + let d = scratch("docs"); + let root = wai_dir(); + let root = root.to_str().unwrap(); + let args = ["docs", "search", "signer_id", "origin-bound", "--spec-root", root]; + let (code, v) = envelope(&d, &args); + assert_eq!(code, Some(0), "{v}"); + assert_eq!(v["op"], "docs.search"); + let hits = v["data"]["hits"].as_array().unwrap(); + assert!(!hits.is_empty(), "{v}"); + for h in hits { + let t = h["text"].as_str().unwrap().to_ascii_lowercase(); + assert!(t.contains("signer_id") && t.contains("origin-bound"), "{h}"); + assert!(h["file"].is_string() && h["line"].is_u64() && h["section"].is_string(), "{h}"); + } + // The door gives what the tool gives. + assert_eq!(v["data"], tool(json!({"query": "signer_id origin-bound"}), "docs_search", &d)); + + // Text names each hit's file and line. + let o = wai(&d, &args); + let t = String::from_utf8_lossy(&o.stdout).into_owned(); + assert!(t.contains(&format!("{}:{}", hits[0]["file"].as_str().unwrap(), hits[0]["line"])), "{t}"); + + let (_, l) = envelope(&d, &["docs", "search", "capability", "--limit", "2", "--spec-root", root]); + assert_eq!((l["data"]["count"].as_u64(), l["data"]["truncated"].as_bool()), (Some(2), Some(true)), "{l}"); + let (code, none) = envelope(&d, &["docs", "search", "zzzqqqzzz", "--spec-root", root]); + assert_eq!((code, none["data"]["count"].as_u64()), (Some(0), Some(0)), "no line holds it, and that is an answer"); + + // The refusals. + assert_eq!(wai(&d, &["docs", "search"]).status.code(), Some(2)); + assert_eq!(wai(&d, &["docs", "search", "x", "--nope"]).status.code(), Some(2)); + assert_eq!(wai(&d, &["docs", "search", "x", "--limit", "0"]).status.code(), Some(2)); + let (code, e) = envelope(&d, &["docs", "search", "x", "--spec-root", d.to_str().unwrap()]); + assert_eq!((code, e["error"]["code"].as_str()), (Some(5), Some("spec-not-found")), "{e}"); + assert!(e["error"]["hint"].as_str().unwrap().contains("--spec-root"), "{e}"); +} + +#[test] +fn registry_show_is_the_tool_wai_mcp_serves() { + let d = scratch("registry"); + let root = wai_dir(); + let root = root.to_str().unwrap(); + let args = ["registry", "show", "wai.image.png", "--spec-root", root]; + let (code, v) = envelope(&d, &args); + assert_eq!(code, Some(0), "{v}"); + assert_eq!(v["op"], "registry.show"); + assert_eq!(v["data"]["registry"]["capability"], "wai.image.png", "{v}"); + assert_eq!(v["data"]["decoded_natively_here"], true, "the floor, in this build: {v}"); + assert!(!v["data"]["spec_lines"].as_array().unwrap().is_empty(), "{v}"); + assert_eq!(v["data"], tool(json!({"capability": "wai.image.png"}), "registry_show", &d)); + + let t = String::from_utf8_lossy(&wai(&d, &args).stdout).into_owned(); + assert!(t.starts_with("wai.image.png\n") && t.contains("decoded natively by this build: yes") && t.contains("in the specification:"), "{t}"); + + // Without the specification's text it still answers from the registry, and says so. + let (_, no_text) = envelope(&d, &["registry", "show", "wai.image.png", "--spec-root", d.to_str().unwrap()]); + assert!(no_text["data"]["spec_lines"].is_null(), "{no_text}"); + assert_eq!(no_text["data"]["registry"]["capability"], "wai.image.png"); + + let (code, e) = envelope(&d, &["registry", "show", "wai.image.none", "--spec-root", root]); + assert_eq!((code, e["error"]["code"].as_str()), (Some(5), Some("capability-not-found")), "{e}"); + assert_eq!(wai(&d, &["registry", "show"]).status.code(), Some(2)); + assert_eq!(wai(&d, &["registry", "show", "a", "b"]).status.code(), Some(2)); + // The operations beside it still answer as they did. + assert!(wai(&d, &["registry", "list"]).status.success()); +} + +#[test] +fn a_deprecated_flag_works_and_says_so_in_one_line() { + let d = scratch("deprecated"); + std::fs::write(d.join("p.bin"), b"hello").unwrap(); + let notice = "wai: deprecated: --confirm, use --yes instead"; + let o = wai(&d, &["wrap", "wai.text.zstd", "p.bin", "--confirm"]); + assert!(o.status.success(), "{}", text(&o)); + assert_eq!(String::from_utf8_lossy(&o.stderr).matches(notice).count(), 1, "{}", text(&o)); + assert!(!String::from_utf8_lossy(&o.stdout).contains("deprecated"), "standard error alone"); + // With --json the envelope stays one document, and the notice is said once. + let o = wai(&d, &["wrap", "wai.text.zstd", "p.bin", "--confirm", "--json"]); + assert!(o.status.success(), "{}", text(&o)); + assert_eq!(String::from_utf8_lossy(&o.stderr).matches(notice).count(), 1, "{}", text(&o)); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!(v["ok"], true, "{v}"); + let o = wai(&d, &["wrap", "wai.text.zstd", "p.bin", "--yes"]); + assert!(o.status.success() && !text(&o).contains("deprecated"), "{}", text(&o)); +} + +/// `wai/DEPRECATIONS.md` lists what the catalogue marks deprecated, and nothing it does not, unless +/// the row names its removal; `wai/VERSIONING.md` states the window and links the list. +#[test] +fn the_deprecations_page_is_the_catalogues_list() { + let page = std::fs::read_to_string(wai_dir().join("DEPRECATIONS.md")).unwrap(); + // (kind, item, deprecated in, replacement, removed in) + let rows: Vec> = page + .lines() + .filter(|l| l.starts_with('|')) + .skip(2) + .map(|l| l.trim().trim_matches('|').split('|').map(|c| c.trim().trim_matches('`').to_string()).collect()) + .collect(); + assert!(rows.iter().all(|r| r.len() == 5), "{rows:?}"); + let mut marked: Vec<(String, String, String)> = Vec::new(); + for f in catalogue::AGENT_FLAGS { + if let Some((alias, since)) = f.deprecated_alias { + marked.push((alias.to_string(), since.to_string(), f.flag.to_string())); + } + } + for o in OPS { + if let Some(d) = o.deprecated { + marked.push((o.id.to_string(), d.since.to_string(), d.use_instead.to_string())); + } + } + for (item, since, instead) in &marked { + let r = rows.iter().find(|r| r[1] == *item).unwrap_or_else(|| panic!("the catalogue marks {item} deprecated, and wai/DEPRECATIONS.md does not list it")); + assert_eq!((&r[2], &r[3]), (since, instead), "{item}"); + } + for r in &rows { + assert!(marked.iter().any(|(i, _, _)| *i == r[1]) || !r[4].is_empty(), "wai/DEPRECATIONS.md lists {}, which the catalogue does not mark deprecated and no release removed", r[1]); + } + + let v = std::fs::read_to_string(wai_dir().join("VERSIONING.md")).unwrap().split_whitespace().collect::>().join(" "); + for needle in ["(DEPRECATIONS.md)", "at least two minor releases and six months", "wai --version --json", "wai: deprecated: --confirm, use --yes instead"] { + assert!(v.contains(needle), "wai/VERSIONING.md does not state {needle:?}"); + } + assert!(page.contains("(VERSIONING.md)"), "wai/DEPRECATIONS.md links the policy"); +} diff --git a/wai/wai-rs/tests/fixtures/dev-stack/fake-stack.py b/wai/wai-rs/tests/fixtures/dev-stack/fake-stack.py index cce944f03..f8c7a71e4 100755 --- a/wai/wai-rs/tests/fixtures/dev-stack/fake-stack.py +++ b/wai/wai-rs/tests/fixtures/dev-stack/fake-stack.py @@ -15,6 +15,7 @@ import json import os import re import socket +import socketserver import sys import threading import time @@ -55,6 +56,14 @@ class Room(http.server.BaseHTTPRequestHandler): pass +class Server(http.server.ThreadingHTTPServer): + """HTTPServer looks up a name for its address before it listens, and nothing here reads the + name. On some hosts that lookup takes 35 s, past what `wai dev` waits for a service.""" + + def server_bind(self): + socketserver.TCPServer.server_bind(self) + + def main(argv): if argv[:1] in (["keygen"], ["relay-lines"]): open(argv[1], "a").close() @@ -73,7 +82,7 @@ def main(argv): while True: c, _ = s.accept() c.close() - http.server.ThreadingHTTPServer(listen_addr(config, "listen"), Room).serve_forever() + Server(listen_addr(config, "listen"), Room).serve_forever() return 0 diff --git a/wai/wai-spz-gen/Cargo.lock b/wai/wai-spz-gen/Cargo.lock index 0c64dcd13..5a3b4fe18 100644 --- a/wai/wai-spz-gen/Cargo.lock +++ b/wai/wai-spz-gen/Cargo.lock @@ -1016,6 +1016,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "windows-sys", ] [[package]] diff --git a/wai/wai-studio/core/Cargo.lock b/wai/wai-studio/core/Cargo.lock index cc977268b..a4db90200 100644 --- a/wai/wai-studio/core/Cargo.lock +++ b/wai/wai-studio/core/Cargo.lock @@ -816,6 +816,7 @@ dependencies = [ "serde_json", "sha2", "wai-ops", + "windows-sys", "zstd", ] diff --git a/wai/wai-studio/src/actions.generated.js b/wai/wai-studio/src/actions.generated.js index 1631dc11f..2b3316295 100644 --- a/wai/wai-studio/src/actions.generated.js +++ b/wai/wai-studio/src/actions.generated.js @@ -1,7 +1,7 @@ // GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the // catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run // `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs. -export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"bbb0d3f85cfb9d2c1e6a0a2a69fff6f9b8fc6762a1a9560834d8136a0b7c47bd"}; +export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"6691889d5b136d736264a2d607c33e58fbdf1021a911da8676f54a0373ebae09"}; export const ACTIONS = [ {"id":"toolchain.test","noun":"toolchain","verb":"test","menu":"Toolchain","item":"Test","palette":"toolchain test","summary":"WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai hello","mcp":null,"route":null,"call":"toolchain_test"}, {"id":"project.create","noun":"project","verb":"create","menu":"Project","item":"Create","palette":"project create","summary":"A new project from one of the examples, written into a new or empty directory; --list names the templates","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai create","mcp":null,"route":null,"call":"project_create"}, @@ -60,8 +60,8 @@ export const ACTIONS = [ {"id":"app.check","noun":"app","verb":"check","menu":"App","item":"Check","palette":"app check","summary":"An app program checked against the WAI registry and WAI's rules, every error with its line and reason code; in a project, its room and runtime too","placement":"local","effect":"reads","stability":"preview","toolset":"app","cli":"wai app check","mcp":"app_check","route":null,"call":"app_check"}, {"id":"app.export","noun":"app","verb":"export","menu":"App","item":"Export","palette":"app export","summary":"An app made with Create written as a project: the program, wai.json naming its room, the page and its runtime, a README, AGENTS.md and the licence","placement":"local","effect":"writes-project","stability":"preview","toolset":"app","cli":"wai app export","mcp":"app_export","route":null,"call":"app_export"}, {"id":"app.stream","noun":"app","verb":"stream","menu":"App","item":"Stream","palette":"app stream","summary":"A program streamed into the person's open Create page through the bridge wai mcp runs on loopback","placement":"local","effect":"runs","stability":"preview","toolset":"app","cli":null,"mcp":"app_stream","route":null,"call":"app_stream"}, - {"id":"registry.show","noun":"registry","verb":"show","menu":"Registry","item":"Show","palette":"registry show","summary":"One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":null,"mcp":"registry_show","route":null,"call":"registry_show"}, - {"id":"docs.search","noun":"docs","verb":"search","menu":"Docs","item":"Search","palette":"docs search","summary":"Lines of the specification and its extensions holding every word of a query, each with its file, line and section","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":null,"mcp":"docs_search","route":null,"call":"docs_search"}, + {"id":"registry.show","noun":"registry","verb":"show","menu":"Registry","item":"Show","palette":"registry show","summary":"One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai registry show","mcp":"registry_show","route":null,"call":"registry_show"}, + {"id":"docs.search","noun":"docs","verb":"search","menu":"Docs","item":"Search","palette":"docs search","summary":"Lines of the specification and its extensions holding every word of a query, each with its file, line and section","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai docs search","mcp":"docs_search","route":null,"call":"docs_search"}, {"id":"dev.stop","noun":"dev","verb":"stop","menu":"Dev","item":"Stop","palette":"dev stop","summary":"The development relay and room service a wai mcp server started, stopped","placement":"local","effect":"runs","stability":"stable","toolset":"dev","cli":null,"mcp":"dev_stop","route":null,"call":"dev_stop"}, {"id":"room.join","noun":"room","verb":"join","menu":"Room","item":"Join","palette":"room join","summary":"A test principal enters the development room with its grant, publishes its tracks, and is seen joined by the room's clock","placement":"client","effect":"writes-target","stability":"stable","toolset":"dev","cli":null,"mcp":"room_join","route":null,"call":"room_join"}, {"id":"room.leave","noun":"room","verb":"leave","menu":"Room","item":"Leave","palette":"room leave","summary":"A test principal's session in the development room closed, and its leave seen by the room's clock","placement":"client","effect":"writes-target","stability":"stable","toolset":"dev","cli":null,"mcp":"room_leave","route":null,"call":"room_leave"}, @@ -113,8 +113,16 @@ export const ACTIONS = [ {"id":"opportunity.create","noun":"opportunity","verb":"create","menu":"Opportunity","item":"Create","palette":"opportunity create","summary":"A party's opportunity report for a period: the ad breaks a channel's signed cues and program-as-run prove in the programmes it shares in, signed with the issuer key","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai opportunity create","mcp":null,"route":null,"call":"opportunity_create"}, {"id":"opportunity.verify","noun":"opportunity","verb":"verify","menu":"Opportunity","item":"Verify","palette":"opportunity verify","summary":"An opportunity report's or a statement check's verification document checked: every break, line and flag recomputed from the records, or the reason it is refused","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai opportunity verify","mcp":null,"route":null,"call":"opportunity_verify"}, {"id":"statement.check","noun":"statement","verb":"check","menu":"Statement","item":"Check","palette":"statement check","summary":"A platform's reported statement checked against an opportunity report and, when given, the operator's signed statement, signed with the issuer key","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai statement check","mcp":null,"route":null,"call":"statement_check"}, + {"id":"key.create","noun":"key","verb":"create","menu":"Key","item":"Create","palette":"key create","summary":"A new Ed25519 signing key kept under a name, for one role, its secret in macOS's keychain or in a seed file; every signing operation's --key takes the name","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai key create","mcp":null,"route":null,"call":"key_create"}, + {"id":"key.list","noun":"key","verb":"list","menu":"Key","item":"List","palette":"key list","summary":"Every key of the keystore: its name, role, key id, public key, status and where its secret is kept","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai key list","mcp":null,"route":null,"call":"key_list"}, + {"id":"key.show","noun":"key","verb":"show","menu":"Key","item":"Show","palette":"key show","summary":"One key: its public key and did:key, its role, every version with its status as a key log states it, and the version --key signs with","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai key show","mcp":null,"route":null,"call":"key_show"}, + {"id":"key.rotate","noun":"key","verb":"rotate","menu":"Key","item":"Rotate","palette":"key rotate","summary":"A key's successor made under the same name and a new key id, the old version retired now and its secret destroyed","placement":"local","effect":"destructive","stability":"preview","toolset":"core","cli":"wai key rotate","mcp":null,"route":null,"call":"key_rotate"}, + {"id":"key.revoke","noun":"key","verb":"revoke","menu":"Key","item":"Revoke","palette":"key revoke","summary":"A key's version revoked as compromised from a time, its secret destroyed, so --key signs nothing with it","placement":"local","effect":"destructive","stability":"preview","toolset":"core","cli":"wai key revoke","mcp":null,"route":null,"call":"key_revoke"}, + {"id":"key.import","noun":"key","verb":"import","menu":"Key","item":"Import","palette":"key import","summary":"A key file's seed kept under a name, as wai key create keeps a new one; a published test seed is refused unless named one","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai key import","mcp":null,"route":null,"call":"key_import"}, + {"id":"key.export","noun":"key","verb":"export","menu":"Key","item":"Export","palette":"key export","summary":"A key's public half, never its secret: in hexadecimal, as a did:key, as key-log entries, or as the line wai-keys lists it by","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai key export","mcp":null,"route":null,"call":"key_export"}, {"id":"ops.list","noun":"ops","verb":"list","menu":"Ops","item":"List","palette":"ops list","summary":"Every operation of this catalogue: its id, command line, effect and whether this build holds it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops list","mcp":null,"route":null,"call":"ops_list"}, {"id":"ops.search","noun":"ops","verb":"search","menu":"Ops","item":"Search","palette":"ops search","summary":"The operations that match a task, ranked, offline: up to five","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops search","mcp":null,"route":null,"call":"ops_search"}, {"id":"ops.show","noun":"ops","verb":"show","menu":"Ops","item":"Show","palette":"ops show","summary":"One operation's entry: its input and output schemas, effect, placement and command line","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops show","mcp":null,"route":null,"call":"ops_show"}, {"id":"ops.export","noun":"ops","verb":"export","menu":"Ops","item":"Export","palette":"ops export","summary":"The catalogue as JSON (wai.ops/1), what other tools and CI read; --check compares a file with it","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai ops export","mcp":null,"route":null,"call":"ops_export"}, + {"id":"toolchain.show","noun":"toolchain","verb":"show","menu":"Toolchain","item":"Show","palette":"toolchain show","summary":"This wai's version, the digests of its catalogue and its registry, the release of the standard it implements, and the build features it holds","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai --version","mcp":null,"route":null,"call":"toolchain_show"}, ]; diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index efd1a6cda..ac009e9fd 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -1233,6 +1233,7 @@ dependencies = [ "sha2", "wai-ops", "wai-quantum", + "windows-sys", ] [[package]]