From d5d5245497fdac6890eaa2cf63feffddd8c1fe7c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 11:04:49 -0500 Subject: [PATCH 1/9] wai key: a developer's signing keys in one keystore, and --key on the operations that sign key.create, key.list, key.show, key.rotate, key.revoke, key.import and key.export join the catalogue. One keystore: --keystore, $WAI_KEYSTORE, or ~/.config/wai/keys, mode 0700. Each key is a record of public halves (wai.key/1), its secret in the login keychain on macOS, through /usr/bin/security on standard input, or in a seed file of mode 0600 elsewhere. No operation prints a secret. A version's status is the key log's (jwp-receipts 7.2), rotation follows 7.6, a published test seed is refused on import unless named one (7.8), and wai key export gives what wai-keys takes. --key takes a key's name on enrolment create, channel import, contract create and accept, statement create and check, opportunity create, upscale run, tonemap run, encode avc and hevc, http seal-playlist and conform. A path that exists is still read as a key file first. Not folded: package build, which hands --key to wai-pack, and the other tools' own key verbs. The keychain path ran against a stand-in for its tool, not against macOS's: the gates run on Linux. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-catalogue.yml | 15 +- wai/catalogue/ops.json | 650 ++++++++- ...11-05-wai-key-signing-keys-one-keystore.md | 38 + wai/wai-rs/Cargo.toml | 7 + wai/wai-rs/src/bin/wai_cli/derive.rs | 8 +- wai/wai-rs/src/bin/wai_cli/dx/channel.rs | 10 +- .../src/bin/wai_cli/dx/conform/record.rs | 20 +- wai/wai-rs/src/bin/wai_cli/dx/fleet.rs | 9 +- wai/wai-rs/src/bin/wai_cli/dx/key.rs | 1224 +++++++++++++++++ wai/wai-rs/src/bin/wai_cli/dx/mod.rs | 14 + wai/wai-rs/src/bin/wai_cli/dx/share.rs | 19 +- wai/wai-rs/src/bin/wai_cli/encode.rs | 15 +- wai/wai-rs/src/bin/wai_cli/http_playlists.rs | 11 +- wai/wai-rs/src/bin/wai_cli/out.rs | 68 +- wai/wai-rs/src/catalogue.rs | 190 ++- wai/wai-rs/tests/cli_key.rs | 552 ++++++++ wai/wai-rs/tests/cli_ops.rs | 12 + 17 files changed, 2734 insertions(+), 128 deletions(-) create mode 100644 wai/changes/2026-11-05-wai-key-signing-keys-one-keystore.md create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/key.rs create mode 100644 wai/wai-rs/tests/cli_key.rs diff --git a/.github/workflows/wai-catalogue.yml b/.github/workflows/wai-catalogue.yml index f7c5a56b8..6ba1dcb08 100644 --- a/.github/workflows/wai-catalogue.yml +++ b/.github/workflows/wai-catalogue.yml @@ -33,6 +33,9 @@ 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/**' - 'wai/REGISTRY.md' - 'wai/LICENSE' - 'wai/wai-quantum/**' @@ -58,6 +61,9 @@ 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/**' - 'wai/REGISTRY.md' - 'wai/LICENSE' - 'wai/wai-quantum/**' @@ -132,7 +138,10 @@ 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. - name: The catalogue's rules, and the command line through it shell: bash working-directory: wai/wai-rs @@ -141,8 +150,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 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/wai/catalogue/ops.json b/wai/catalogue/ops.json index f70d733c8..163b9c56a 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" } @@ -7211,7 +7208,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 +7237,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 +7293,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 +7322,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 +7378,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 +7429,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 +7552,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 +7603,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 +7720,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 +7780,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 +7827,608 @@ "sdk": "wai.statement.check" } }, + { + "id": "key.create", + "summary": "A new Ed25519 signing key kept under a name, for one role, its secret in the OS keychain or a file only you may read; 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 of mode 0600 (--store file, the default elsewhere), and is never printed. The keystore", + " is DIR, $WAI_KEYSTORE, or ~/.config/wai/keys. --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 (mode 0600, the default elsewhere).", + "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", 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..d2d789f8a --- /dev/null +++ b/wai/changes/2026-11-05-wai-key-signing-keys-one-keystore.md @@ -0,0 +1,38 @@ +### `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` made with mode 0600. A seed file + another user may read is refused (exit 3, `key-file-exposed`). 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. +- **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/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 87a945c99..23a52bef7 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -1680,6 +1680,13 @@ 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"] + [[test]] name = "jpegai_decode_symbols" path = "tests/jpegai_decode_symbols.rs" 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/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/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..0bd8b3f0c --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/dx/key.rs @@ -0,0 +1,1224 @@ +//! `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. 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, made +//! with mode 0600. A seed file whose mode lets another user read it is refused (exit 3). +//! +//! 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). + #[cfg(test)] + 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` reports a command that failed on its standard error; what the keychain + // holds is read back in any case. + 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 user may read or write (mode 0600 or narrower), where the system has modes. +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(not(unix))] + 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", + "--store file keeps the secret in the keystore, in a file only you may read (mode 0600)", + )); + } + 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(not(unix))] + { + 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) +} + +fn where_kept(store: Store) -> &'static str { + match store { + Store::Keychain => "the login keychain", + Store::File => "a file of the keystore only you may read", + } +} + +fn where_kept_at(store: Store, dir: &Path, id: &str) -> String { + match store { + Store::Keychain => "the login keychain".into(), + Store::File => format!("{} (mode 0600)", seed_path(dir, id).display()), + } +} + +/// `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; printf '%s' "$pw" > "$d/$svc.$acct" + done + exit 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 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); + } +} 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..81544891b 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,10 @@ //! `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`). //! //! [`envelope`] holds the agent rules every command keeps: the exit codes, the JSON envelope, //! `--dry-run`, `--yes`. @@ -58,6 +62,8 @@ 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; #[cfg(feature = "mcp")] #[path = "../mcp/mod.rs"] @@ -101,6 +107,14 @@ 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 }, ]; /// 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/out.rs b/wai/wai-rs/src/bin/wai_cli/out.rs index b68c74951..f98fe6d39 100644 --- a/wai/wai-rs/src/bin/wai_cli/out.rs +++ b/wai/wai-rs/src/bin/wai_cli/out.rs @@ -27,7 +27,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 +79,68 @@ pub fn write_new(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result< f.write_all(b) } +/// Options that make a file only its owner may read or write (mode 0600), where the system has +/// modes: the mode is the file's from its creation, never widened by the umask in between. +fn private_options() -> std::fs::OpenOptions { + let mut o = std::fs::OpenOptions::new(); + o.write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + o +} + +/// 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 = private_options().create_new(true).open(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 = private_options().create_new(true).open(&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,6 +190,9 @@ 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() } diff --git a/wai/wai-rs/src/catalogue.rs b/wai/wai-rs/src/catalogue.rs index 467281870..20e9ee6bb 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,13 @@ 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"}"#; +// 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 +481,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 +541,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 +551,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 +579,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 +588,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 +867,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 +989,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 +999,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 +1094,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)."), @@ -1719,14 +1728,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 +1748,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 +1768,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 +1779,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 +1805,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 +1816,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 +1841,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 +1853,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 +1862,147 @@ 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 the OS keychain or a file only you may read; 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 of mode 0600 (--store file, the default elsewhere), and is never printed. The keystore + is DIR, $WAI_KEYSTORE, or ~/.config/wai/keys. --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 (mode 0600, the default elsewhere)."), + 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", diff --git a/wai/wai-rs/tests/cli_key.rs b/wai/wai-rs/tests/cli_key.rs new file mode 100644 index 000000000..015d6c653 --- /dev/null +++ b/wai/wai-rs/tests/cli_key.rs @@ -0,0 +1,552 @@ +//! `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}"); + 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 p = |x: PathBuf| x.display().to_string(); + assert_eq!(list(&[], &[]), p(home.join(".config/wai/keys"))); + assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg"))], &[]), p(d.join("xdg/wai/keys"))); + assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg")), ("WAI_KEYSTORE", &d.join("env"))], &[]), p(d.join("env"))); + 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(".."); + let imp = format!("'{}' verdicts {{corpus}}", env!("CARGO_BIN_EXE_wai_jwp_vectors")); + 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"); +} + +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), -- 2.54.0 (Apple Git-157) From 8d663d6c11ef4410baf45ea62e68cfbd4e4d4ed8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 11:06:18 -0500 Subject: [PATCH 2/9] wai: the help banner names the .WAI mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wai --help opens with "wai — the .WAI™ toolchain." The command name, the crate names and identifiers such as .wai stay ASCII with no mark. No test or snapshot pins the line. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/bin/wai_cli/door.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wai/wai-rs/src/bin/wai_cli/door.rs b/wai/wai-rs/src/bin/wai_cli/door.rs index d39c8a726..09438140a 100644 --- a/wai/wai-rs/src/bin/wai_cli/door.rs +++ b/wai/wai-rs/src/bin/wai_cli/door.rs @@ -265,7 +265,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(); -- 2.54.0 (Apple Git-157) From 71f096406ebdcc53603a5a3f87c7d72453ea1d49 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 11:14:37 -0500 Subject: [PATCH 3/9] wai --version, wai docs search and wai registry show, and what a version of the toolchain promises toolchain.show is wai --version: this binary's version, the SHA-256 of the catalogue's export and of the registry, the release of the standard with the envelope formats it reads, and the build features it holds. A test holds the crate, the export and the changelog's newest release to one version. docs.search and registry.show gain a command line, each running the tool wai mcp serves, and a test holds the two doors' answers equal. wai/VERSIONING.md and wai/DEPRECATIONS.md state what is public, what holds it in CI and how a change is announced, and a test holds the list to the catalogue. --confirm now says it is deprecated, in one line on standard error. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-catalogue.yml | 18 +- wai/DEPRECATIONS.md | 10 + wai/VERSIONING.md | 84 ++++++ wai/catalogue/ops.json | 122 +++++++-- ...05-wai-version-docs-registry-versioning.md | 23 ++ wai/wai-rs/Cargo.toml | 7 + wai/wai-rs/src/bin/wai_cli/door.rs | 65 +++++ wai/wai-rs/src/bin/wai_cli/dx/lookup.rs | 111 ++++++++ wai/wai-rs/src/bin/wai_cli/dx/mod.rs | 6 + wai/wai-rs/src/bin/wai_cli/mcp/mod.rs | 37 +++ wai/wai-rs/src/catalogue.rs | 40 ++- wai/wai-rs/tests/cli_toolchain.rs | 254 ++++++++++++++++++ 12 files changed, 754 insertions(+), 23 deletions(-) create mode 100644 wai/DEPRECATIONS.md create mode 100644 wai/VERSIONING.md create mode 100644 wai/changes/2026-11-05-wai-version-docs-registry-versioning.md create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/lookup.rs create mode 100644 wai/wai-rs/tests/cli_toolchain.rs diff --git a/.github/workflows/wai-catalogue.yml b/.github/workflows/wai-catalogue.yml index 6ba1dcb08..4c09f89e4 100644 --- a/.github/workflows/wai-catalogue.yml +++ b/.github/workflows/wai-catalogue.yml @@ -36,6 +36,12 @@ on: # 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/**' @@ -64,6 +70,12 @@ on: # 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/**' @@ -141,7 +153,9 @@ jobs: # 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. + # 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 @@ -150,7 +164,7 @@ 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 --test cli_key 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" 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 163b9c56a..eb34d78af 100644 --- a/wai/catalogue/ops.json +++ b/wai/catalogue/ops.json @@ -4864,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 }, @@ -4886,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": [ @@ -4901,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", @@ -4913,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 }, @@ -4935,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": [ @@ -4954,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", @@ -8694,6 +8731,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-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/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 23a52bef7..4e8210740 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -1687,6 +1687,13 @@ 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/src/bin/wai_cli/door.rs b/wai/wai-rs/src/bin/wai_cli/door.rs index 09438140a..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(); @@ -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/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 81544891b..f4c7cf15f 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs @@ -38,6 +38,8 @@ //! `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`. @@ -64,6 +66,7 @@ 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"] @@ -115,6 +118,9 @@ pub const VERBS: &[Impl] = &[ 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/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/catalogue.rs b/wai/wai-rs/src/catalogue.rs index 20e9ee6bb..ae1e09f28 100644 --- a/wai/wai-rs/src/catalogue.rs +++ b/wai/wai-rs/src/catalogue.rs @@ -321,6 +321,7 @@ 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"}"#; @@ -1249,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", @@ -2044,6 +2058,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_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"); +} -- 2.54.0 (Apple Git-157) From 4ec3d5fa6f706677960c5cef5ab2b28df4c8ca98 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 11:35:25 -0500 Subject: [PATCH 4/9] wai key: the keychain path against the real security tool, in a scratch keychain A macOS test drives /usr/bin/security through Keychain::with_tool and a wrapper that names a scratch keychain on every command it passes on: an item kept, read back, replaced through -U and removed, no secret on any process argument, exit 44 read as none. It removes the scratch keychain in every case, a failure included, and holds the search list and the default keychain to what they were. It never names the login keychain, and needs no dialog. What the real tool does that the stand-in did not: security -i exits with the status of a command that failed in it, where the stand-in always exited 0. add now reports that status and what the tool said, with any secret removed from it, rather than reading it as "holds another secret". The stand-in does the same now. wai-key-keychain.yml runs this test alone on the macOS runner, for a pull request whose own diff changes dx/key.rs or the workflow. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-key-keychain.yml | 59 +++++++++ ...11-05-wai-key-signing-keys-one-keystore.md | 5 + wai/wai-rs/src/bin/wai_cli/dx/key.rs | 116 +++++++++++++++++- 3 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/wai-key-keychain.yml 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/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 index d2d789f8a..08f89cc6c 100644 --- 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 @@ -14,6 +14,11 @@ another user may read is refused (exit 3, `key-file-exposed`). 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. +- **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 diff --git a/wai/wai-rs/src/bin/wai_cli/dx/key.rs b/wai/wai-rs/src/bin/wai_cli/dx/key.rs index 0bd8b3f0c..8d4c8f37e 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/key.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/key.rs @@ -354,8 +354,12 @@ impl Keychain { 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` reports a command that failed on its standard error; what the keychain - // holds is read back in any case. + // `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; @@ -1191,9 +1195,11 @@ item() { svc=; acct=; while [ $# -gt 0 ]; do case "$1" in -s) svc=$2; shift 2;; if [ "$1" = "-i" ]; then while read -r cmd rest; do [ "$cmd" = add-generic-password ] || { echo "unknown command $cmd" >&2; continue; } - pw=; item $rest; printf '%s' "$pw" > "$d/$svc.$acct" + 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 0 + exit ${status:-0} fi cmd=$1; shift; item "$@"; f="$d/$svc.$acct" case "$cmd" in @@ -1216,9 +1222,111 @@ esac 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"); + } } -- 2.54.0 (Apple Git-157) From c1b44f5b990f678f5497eac4865aef9832968ef8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 11:42:43 -0500 Subject: [PATCH 5/9] wai key: say what protects a seed file on Windows, and hold the tests to that system On Windows wai printed "mode 0600" beside a seed file that has no mode. A seed there is a plain file: wai sets no access list, checks none and encrypts nothing, so only what the file inherits from its directory protects it. The command's output, its help, the catalogue and the changelog entry now say so, and a test holds the output to the system it runs on. Two tests assumed Unix: the keystore's path written with forward slashes, and a command line quoted with single quotes, which quote nothing to cmd. The path test also covers %APPDATA%\wai\keys on Windows. Co-Authored-By: Claude Opus 5.5 --- wai/catalogue/ops.json | 9 +++--- ...11-05-wai-key-signing-keys-one-keystore.md | 9 ++++-- wai/wai-rs/src/bin/wai_cli/dx/key.rs | 29 +++++++++++++------ wai/wai-rs/src/catalogue.rs | 9 +++--- wai/wai-rs/tests/cli_key.rs | 17 +++++++---- 5 files changed, 49 insertions(+), 24 deletions(-) diff --git a/wai/catalogue/ops.json b/wai/catalogue/ops.json index eb34d78af..fabbeb74b 100644 --- a/wai/catalogue/ops.json +++ b/wai/catalogue/ops.json @@ -7866,15 +7866,16 @@ }, { "id": "key.create", - "summary": "A new Ed25519 signing key kept under a name, for one role, its secret in the OS keychain or a file only you may read; every signing operation's --key takes the name", + "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 of mode 0600 (--store file, the default elsewhere), and is never printed. The keystore", - " is DIR, $WAI_KEYSTORE, or ~/.config/wai/keys. --key signs with it" + " file of the keystore (--store file, the default elsewhere: mode 0600 on Unix; on Windows a plain file, which", + " only its directory's access list protects), and is never printed. The keystore is DIR, $WAI_KEYSTORE, or", + " ~/.config/wai/keys (%APPDATA%\\wai\\keys on Windows). --key signs with it" ], "placement": "local", "effect": "writes-project", @@ -7907,7 +7908,7 @@ }, "store": { "type": "string", - "description": "Where the secret is kept: keychain (the default on macOS) or file (mode 0600, the default elsewhere).", + "description": "Where the secret is kept: keychain (the default on macOS) or file (the default elsewhere: mode 0600 on Unix; on Windows, only its directory's access list protects it).", "x-cli": { "flag": "--store" } 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 index 08f89cc6c..dba6baf05 100644 --- 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 @@ -10,10 +10,15 @@ 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` made with mode 0600. A seed file - another user may read is refused (exit 3, `key-file-exposed`). No operation prints a secret. + 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, + and one another user may read is refused (exit 3, `key-file-exposed`). On Windows, nothing of + `wai`'s: it is a plain file of hexadecimal digits, with no access list set or checked and nothing + encrypted, so only the access list it inherits protects it. Under `%APPDATA%` that admits the + profile's users, Administrators and SYSTEM; a keystore named in a directory other users may read + has a seed they may read, and `wai` still signs with it. - **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 diff --git a/wai/wai-rs/src/bin/wai_cli/dx/key.rs b/wai/wai-rs/src/bin/wai_cli/dx/key.rs index 8d4c8f37e..4b2ba587b 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/key.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/key.rs @@ -5,15 +5,20 @@ //! //! **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. 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: +//! with mode 0700 on Unix. 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, made -//! with mode 0600. A seed file whose mode lets another user read it is refused (exit 3). +//! - `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 a plain file, and `wai` protects it with nothing:** it sets no access list, +//! checks none, and encrypts nothing. Only the access list the file inherits from its directory +//! protects it. Under `%APPDATA%` that is the profile's (its users, Administrators and SYSTEM); a +//! keystore named in a directory other users may read has a seed they may read, and `wai` still +//! uses it. //! //! No operation prints a secret, writes one anywhere but its store, or takes one on its command //! line. @@ -571,7 +576,7 @@ fn store_arg(args: &[String], default: Store, usage: &str) -> Result) -> R { done(op, true, data, &text) } -fn where_kept(store: Store) -> &'static str { +/// What protects a seed file here, as a person is told it: a mode on Unix, and on Windows only what +/// its directory's access list gives it. +fn seed_file_protection() -> &'static str { + if cfg!(unix) { "mode 0600" } else { "a plain file: only its directory's access list protects it, and wai neither sets nor checks one" } +} + +fn where_kept(store: Store) -> String { match store { - Store::Keychain => "the login keychain", - Store::File => "a file of the keystore only you may read", + 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!("{} (mode 0600)", seed_path(dir, id).display()), + Store::File => format!("{} ({})", seed_path(dir, id).display(), seed_file_protection()), } } diff --git a/wai/wai-rs/src/catalogue.rs b/wai/wai-rs/src/catalogue.rs index ae1e09f28..3e8f78ee1 100644 --- a/wai/wai-rs/src/catalogue.rs +++ b/wai/wai-rs/src/catalogue.rs @@ -1879,19 +1879,20 @@ http vectors diff|fuzz --seed S --count N --out F", // ─── 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 the OS keychain or a file only you may read; every signing operation's --key takes the name", + 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 of mode 0600 (--store file, the default elsewhere), and is never printed. The keystore - is DIR, $WAI_KEYSTORE, or ~/.config/wai/keys. --key signs with it", + file of the keystore (--store file, the default elsewhere: mode 0600 on Unix; on Windows a plain file, which + only its directory's access list protects), and is never printed. 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 (mode 0600, the default elsewhere)."), + 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, only its directory's access list protects it)."), flag("keystore", "--keystore", Ty::Path, false, "The keystore's directory ($WAI_KEYSTORE, or ~/.config/wai/keys)."), ], output: Output::Json(S_KEY), diff --git a/wai/wai-rs/tests/cli_key.rs b/wai/wai-rs/tests/cli_key.rs index 015d6c653..c4addeefb 100644 --- a/wai/wai-rs/tests/cli_key.rs +++ b/wai/wai-rs/tests/cli_key.rs @@ -86,6 +86,9 @@ fn a_key_is_made_kept_private_and_never_printed() { }; 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("only its directory's access list protects it"), !cfg!(unix), "{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()); @@ -156,10 +159,12 @@ fn the_keystore_is_found_by_flag_then_environment_then_the_users_configuration() assert_eq!(v["data"]["keys"].as_array().unwrap().len(), 0, "{v}"); v["data"]["keystore"].as_str().unwrap().to_string() }; - let p = |x: PathBuf| x.display().to_string(); - assert_eq!(list(&[], &[]), p(home.join(".config/wai/keys"))); - assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg"))], &[]), p(d.join("xdg/wai/keys"))); - assert_eq!(list(&[("XDG_CONFIG_HOME", &d.join("xdg")), ("WAI_KEYSTORE", &d.join("env"))], &[]), p(d.join("env"))); + 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()); @@ -190,7 +195,9 @@ fn every_signing_operation_of_this_build_takes_a_keys_name() { // A conformance record, through its own reader of --key. let wai_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); - let imp = format!("'{}' verdicts {{corpus}}", env!("CARGO_BIN_EXE_wai_jwp_vectors")); + // 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}"); -- 2.54.0 (Apple Git-157) From 4cd06f06d67ad5b81b5e9c8f042a36256c27929a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 11:46:37 -0500 Subject: [PATCH 6/9] wai conform: run a command line on Windows as it is written The command adapter gave cmd its line through Command::arg, which escapes the quotes quote() writes, so each placeholder's path reached the implementation with quotes in it and every case failed. It now goes with raw_arg under /S /C, so a quoted program first works too. Found running cli_key on Windows; a test there holds both forms. Also: with_tool is only compiled for the tests that use it, on Unix. Co-Authored-By: Claude Opus 5.5 --- ...-05-wai-conform-command-line-on-windows.md | 7 +++++ .../src/bin/wai_cli/dx/conform/adapter.rs | 29 +++++++++++++++++-- wai/wai-rs/src/bin/wai_cli/dx/key.rs | 4 +-- wai/wai-rs/tests/cli_key.rs | 2 +- 4 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 wai/changes/2026-11-05-wai-conform-command-line-on-windows.md 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/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/key.rs b/wai/wai-rs/src/bin/wai_cli/dx/key.rs index 4b2ba587b..2b959ec44 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/key.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/key.rs @@ -326,8 +326,8 @@ impl Keychain { Keychain { tool: PathBuf::from(SECURITY) } } - /// The keychain this one's `tool` keeps (the tests' stand-in). - #[cfg(test)] + /// 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() } } diff --git a/wai/wai-rs/tests/cli_key.rs b/wai/wai-rs/tests/cli_key.rs index c4addeefb..c03c4cefa 100644 --- a/wai/wai-rs/tests/cli_key.rs +++ b/wai/wai-rs/tests/cli_key.rs @@ -197,7 +197,7 @@ fn every_signing_operation_of_this_build_takes_a_keys_name() { 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}}") }; + 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}"); -- 2.54.0 (Apple Git-157) From 562f424446e373b8f815e4548362d30fbca420b4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 12:09:37 -0500 Subject: [PATCH 7/9] wai key on Windows: a seed for this account alone, and refused when another may read it A seed in a shared folder got the folder's list, and wai signed with it. Now the seed, the record and a keystore wai makes have this account as owner and as their one entry, with inheritance off, wherever they are. The call that makes the file carries the list (CreateFileW with its security descriptor), so the secret never exists on disk under anything wider, and no handle can be held open from a moment when it did. Unix was already so: open with O_CREAT|O_EXCL and mode 0600. A test now holds that too. A seed whose list grants any other account anything, or that another account owns, is refused with the same exit 3 and key-file-exposed as a wide mode on Unix, naming the file, the accounts and the icacls command that fixes it. Everything is by SID, read through the system's calls and never from icacls' output, which is localised. SYSTEM and Administrators are not listed: a backup or a scanner reads through its privileges rather than the list, an administrator can still take ownership, and the account itself reads the file elevated or not. All the unsafe code is in win_acl.rs, behind cfg(windows). What decides is ordinary code over plain values, so its tests run on every system. windows-sys 0.61 was already in all sixteen lockfiles that hold this crate; each gains one line. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-remote/Cargo.lock | 1 + .../crates/joule-moq-room/Cargo.lock | 1 + wai/catalogue/ops.json | 9 +- ...11-05-wai-key-signing-keys-one-keystore.md | 14 +- wai/tools/ops-gpu-bench/Cargo.lock | 1 + wai/wai-audit/Cargo.lock | 1 + wai/wai-fleet/Cargo.lock | 1 + wai/wai-key-proxy/Cargo.lock | 1 + wai/wai-ledger/Cargo.lock | 1 + wai/wai-live-web/Cargo.lock | 1 + wai/wai-pack/Cargo.lock | 1 + wai/wai-quantum-serve/Cargo.lock | 1 + wai/wai-relay/Cargo.lock | 1 + wai/wai-rs/Cargo.lock | 1 + wai/wai-rs/Cargo.toml | 6 + wai/wai-rs/fuzz/Cargo.lock | 1 + wai/wai-rs/src/bin/wai_cli/dx/key.rs | 52 +- wai/wai-rs/src/bin/wai_cli/out.rs | 57 ++- wai/wai-rs/src/bin/wai_cli/win_acl.rs | 460 ++++++++++++++++++ wai/wai-rs/src/catalogue.rs | 9 +- wai/wai-rs/tests/cli_key.rs | 165 ++++++- wai/wai-spz-gen/Cargo.lock | 1 + wai/wai-studio/core/Cargo.lock | 1 + wai/wai-web/Cargo.lock | 1 + 24 files changed, 748 insertions(+), 40 deletions(-) create mode 100644 wai/wai-rs/src/bin/wai_cli/win_acl.rs 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/catalogue/ops.json b/wai/catalogue/ops.json index fabbeb74b..0d2225687 100644 --- a/wai/catalogue/ops.json +++ b/wai/catalogue/ops.json @@ -7873,9 +7873,10 @@ "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 a plain file, which", - " only its directory's access list protects), and is never printed. The keystore is DIR, $WAI_KEYSTORE, or", - " ~/.config/wai/keys (%APPDATA%\\wai\\keys on Windows). --key signs with it" + " 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", @@ -7908,7 +7909,7 @@ }, "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, only its directory's access list protects it).", + "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" } 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 index dba6baf05..bb667bfb7 100644 --- 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 @@ -13,12 +13,14 @@ 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, - and one another user may read is refused (exit 3, `key-file-exposed`). On Windows, nothing of - `wai`'s: it is a plain file of hexadecimal digits, with no access list set or checked and nothing - encrypted, so only the access list it inherits protects it. Under `%APPDATA%` that admits the - profile's users, Administrators and SYSTEM; a keystore named in a directory other users may read - has a seed they may read, and `wai` still signs with it. +- **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 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 4e8210740..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 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/dx/key.rs b/wai/wai-rs/src/bin/wai_cli/dx/key.rs index 2b959ec44..543850932 100644 --- a/wai/wai-rs/src/bin/wai_cli/dx/key.rs +++ b/wai/wai-rs/src/bin/wai_cli/dx/key.rs @@ -5,20 +5,20 @@ //! //! **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. 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: +//! 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 a plain file, and `wai` protects it with nothing:** it sets no access list, -//! checks none, and encrypts nothing. Only the access list the file inherits from its directory -//! protects it. Under `%APPDATA%` that is the profile's (its users, Administrators and SYSTEM); a -//! keystore named in a directory other users may read has a seed they may read, and `wai` still -//! uses it. +//! 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. @@ -456,7 +456,8 @@ fn load_seed(dir: &Path, v: &Version, kc: &Keychain) -> Result<[u8; 32], OpError Ok(seed) } -/// A seed file no other user may read or write (mode 0600 or narrower), where the system has modes. +/// 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)] { @@ -472,7 +473,21 @@ fn private_enough(p: &Path) -> Result<(), OpError> { )); } } - #[cfg(not(unix))] + #[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(()) } @@ -592,7 +607,11 @@ fn make_keystore(dir: &Path) -> Result<(), OpError> { use std::os::unix::fs::DirBuilderExt; std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir).map_err(|e| io_fail(dir, &e)) } - #[cfg(not(unix))] + #[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)) } @@ -759,10 +778,15 @@ fn add_key(k: NewKey, seed: Option<[u8; 32]>) -> R { done(op, true, data, &text) } -/// What protects a seed file here, as a person is told it: a mode on Unix, and on Windows only what -/// its directory's access list gives it. +/// What protects a seed file here, as a person is told it. fn seed_file_protection() -> &'static str { - if cfg!(unix) { "mode 0600" } else { "a plain file: only its directory's access list protects it, and wai neither sets nor checks one" } + 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 { diff --git a/wai/wai-rs/src/bin/wai_cli/out.rs b/wai/wai-rs/src/bin/wai_cli/out.rs index f98fe6d39..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; @@ -79,17 +84,26 @@ pub fn write_new(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::Result< f.write_all(b) } -/// Options that make a file only its owner may read or write (mode 0600), where the system has -/// modes: the mode is the file's from its creation, never widened by the umask in between. -fn private_options() -> std::fs::OpenOptions { - let mut o = std::fs::OpenOptions::new(); - o.write(true); - #[cfg(unix)] +/// 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)] { - use std::os::unix::fs::OpenOptionsExt; - o.mode(0o600); + 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) } - o } /// A new file only its owner may read or write (a key's secret, a key's record), which must not @@ -104,7 +118,7 @@ pub fn write_new_private(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io: note(json!({"write": shown(p), "bytes": b.len(), "replaces": false})); return Ok(()); } - let mut f = private_options().create_new(true).open(p)?; + let mut f = create_private(p)?; f.write_all(b)?; f.sync_all() } @@ -122,7 +136,7 @@ pub fn replace_private(path: impl AsRef, bytes: impl AsRef<[u8]>) -> io::R name.push(format!(".{}.new", std::process::id())); let tmp = p.with_file_name(name); let _ = std::fs::remove_file(&tmp); - let written = private_options().create_new(true).open(&tmp).and_then(|mut f| f.write_all(b).and_then(|_| f.sync_all())); + 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) => { @@ -199,3 +213,24 @@ pub fn plan_lines() -> Vec { }) .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 3e8f78ee1..aa4fdc785 100644 --- a/wai/wai-rs/src/catalogue.rs +++ b/wai/wai-rs/src/catalogue.rs @@ -1884,15 +1884,16 @@ http vectors diff|fuzz --seed S --count N --out F", 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 a plain file, which - only its directory's access list protects), and is never printed. The keystore is DIR, $WAI_KEYSTORE, or - ~/.config/wai/keys (%APPDATA%\\wai\\keys on Windows). --key signs with it", + 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, only its directory's access list protects it)."), + 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), diff --git a/wai/wai-rs/tests/cli_key.rs b/wai/wai-rs/tests/cli_key.rs index c03c4cefa..27b2cf762 100644 --- a/wai/wai-rs/tests/cli_key.rs +++ b/wai/wai-rs/tests/cli_key.rs @@ -88,7 +88,7 @@ fn a_key_is_made_kept_private_and_never_printed() { 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("only its directory's access list protects it"), !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()); @@ -550,6 +550,169 @@ fn wai_keys_reads_what_wai_key_exports() { 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() } 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-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]] -- 2.54.0 (Apple Git-157) From 571eb3dd8c1ab1f715f34fbb4e145c1e693e2985 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 18:25:21 -0500 Subject: [PATCH 8/9] WAI Studio's actions are generated again from the catalogue with wai key in it The catalogue gained the key, toolchain and docs operations, and Studio's src/actions.generated.js has to be regenerated whenever it changes: node scripts/actions.mjs in wai/wai-studio. CI's check and the Studio unit test both read it, and both failed. Changelog: none Co-Authored-By: Claude Opus 5.5 --- wai/wai-studio/src/actions.generated.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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"}, ]; -- 2.54.0 (Apple Git-157) From 6467f3f42e6a149e4b18009c4e5d968cc62cd1af Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 5 Nov 2026 18:24:54 -0500 Subject: [PATCH 9/9] wai dev's test: the stand-in room service listens without a reverse lookup tests/cli_studio.rs failed on every run on one macOS runner and passed on every other runner, 3 of 3 against 19 of 19 on 5 November, with "the room service's clock on the relay: not within 30 s". The stand-in room service starts an HTTPServer, whose server_bind calls socket.getfqdn before the server listens. On that host, under the runner's Python 3.15.2, getfqdn("127.0.0.1") takes 35 s, past the 30 s wai dev waits, and a stack dump of the stalled stand-in shows it there. The stand-in relay opens a plain socket and answers in 0.1 s. Nothing here reads the name, so the fixture no longer asks for it. On that host and Python the room stand-in took 35.5 s and now takes 0.12 s. A socket.getfqdn planted to stall for 40 s fails the test without this change, with the same message, and passes with it. Changelog: none Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/fixtures/dev-stack/fake-stack.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 -- 2.54.0 (Apple Git-157)