From 4e307c9cb6b9085c6b15d12229688b1b92f7ed51 Mon Sep 17 00:00:00 2001 From: trqlmao Date: Mon, 8 Jun 2026 10:40:36 -0700 Subject: [PATCH 1/4] feat: add OpenAPI, error spec, vector index, and a one-command runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the gap between the turnkey crypto path and the wire/transport layer, where most "get it right first try" friction lives for both humans and LLMs implementing AVP. - openapi.yaml: OpenAPI 3.1 description of the HTTP/JSON profile — every path, method, status code, and error response — referencing the JSON Schema shapes so the route surface is machine-readable and stub-generatable. The gRPC profile had proto/avp.proto; HTTP had only a prose route table. - schema + SPEC §6: define the error body { error, code?, detail? } and a status→code table, and state that every non-2xx is terminal while a stale-version push is a 200 with conflict:true. The five reference servers already emit { error, detail? }; `code` is additive, so they stay conformant. - SPEC §11: reword conformance to map each requirement to the vector that actually exists (Ed25519 anchors the challenge signature; the payload-AEAD epoch-tamper case anchors rotation correctness) instead of implying standalone challenge/token and rotation vector files that do not exist. Dedicated vectors for those paths are noted as welcome additions. - vectors/index.json: machine-readable index of the vectors (file, kind, spec section, RFC anchors) so a harness can enumerate them without hardcoding filenames. - Taskfile.yml: `task test` runs the conformance suite and every language's example tests in one command, mirroring the CI jobs. Cross-linked from README, IMPLEMENTING.md, llms.txt, examples/README.md, and vectors/README.md; recorded under CHANGELOG [Unreleased]. --- CHANGELOG.md | 14 ++ IMPLEMENTING.md | 10 +- README.md | 12 +- SPEC.md | 48 +++++- Taskfile.yml | 78 ++++++++++ examples/README.md | 4 +- llms.txt | 4 +- openapi.yaml | 323 +++++++++++++++++++++++++++++++++++++++++ schema/avp.schema.json | 22 +++ vectors/README.md | 4 + vectors/index.json | 77 ++++++++++ 11 files changed, 587 insertions(+), 9 deletions(-) create mode 100644 Taskfile.yml create mode 100644 openapi.yaml create mode 100644 vectors/index.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a96fbfa..58fab1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,23 @@ implementation's version. - Clarified that an issuer MAY require out-of-band authentication and embed deployment-specific claims on the `token` grant, and that a server MAY return implementation-defined resource or policy errors such as quota limits; both non-normative (SPEC sections 3 and 6). +- Defined the HTTP/JSON error body `{ error, code?, detail? }` and a status→code table, and stated that + every non-2xx is terminal while a stale-version `push` is a `200` with `conflict: true` (SPEC section + 6); added `$defs/Error` to the JSON Schema. +- Reworded the conformance section (SPEC section 11) to map each requirement to the vector that actually + exists: the Ed25519 primitive anchors the challenge signature and the payload-AEAD epoch-tamper case + anchors rotation correctness; dedicated challenge/token and rotation vectors are noted as welcome + additions rather than implied to already exist. ### Repository +- Added `openapi.yaml`, an OpenAPI 3.1 description of the HTTP/JSON profile (paths, status codes, error + responses) that references the JSON Schema shapes, so the route surface is machine-readable and + stub-generatable. +- Added `vectors/index.json`, a machine-readable index of the conformance vectors (file, kind, spec + section, RFC anchors) so a harness can enumerate them without hardcoding filenames. +- Added a root `Taskfile.yml` (go-task): `task test` runs the conformance suite and every language's + example tests in one command, mirroring the CI jobs. - Added a Go reference client and server, and upgraded the TypeScript, Python, Java, and Rust reference clients to implement the real envelope and key-wrap crypto (previously opaque placeholders). Every example is verified byte-for-byte against the conformance vectors. diff --git a/IMPLEMENTING.md b/IMPLEMENTING.md index b1b59dd..d4de86b 100644 --- a/IMPLEMENTING.md +++ b/IMPLEMENTING.md @@ -150,7 +150,9 @@ data key to that member entry. ### Routes (HTTP/JSON profile) All bodies are JSON with proto field names in `camelCase`. Routes other than the two auth routes -require `Authorization: Bearer `. +require `Authorization: Bearer `. The machine-readable route description, with every status code +and the error body, is [`openapi.yaml`](openapi.yaml) (OpenAPI 3.1); generate a client or server stub +from it if your stack supports it. | Method + path | Request -> Response | |---|---| @@ -177,6 +179,12 @@ Note: `memberId` in the path is the base64 Ed25519 public key. Because it contai | `404` | Repo or member not found | | `409` | Duplicate `repoId` on create | +Every non-2xx response body is an error object: `{ "error": "", "code": "", +"detail": "" }`. `error` is human-readable (do not parse it); `code` is an optional stable +token (`unauthorized`, `forbidden`, `not_found`, `duplicate_repo`, `quota_exceeded`, `policy_denied`, +`bad_request`) you may switch on. Treat all of them as **terminal** — see the concurrency note below for +the one outcome that is *not* an error. + Optimistic concurrency: `push` returns `{ "accepted": false, "conflict": true }` with the current version when `expectedPayloadVersion` does not match. A client must pull, re-apply, and retry; this is not a `4xx` status code. diff --git a/README.md b/README.md index 538da14..828f4f7 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ stack: - **gRPC profile.** [`proto/avp.proto`](proto/avp.proto) is canonical. Field numbers and names are stable. - **HTTP/JSON profile.** One HTTP path per operation, JSON bodies whose field names are the proto field - names in `camelCase`, and a Bearer token in `Authorization`. See [`schema/avp.schema.json`](schema/avp.schema.json). + names in `camelCase`, and a Bearer token in `Authorization`. Message shapes are + [`schema/avp.schema.json`](schema/avp.schema.json); the full route surface (paths, status codes, error + bodies) is [`openapi.yaml`](openapi.yaml). A browser or Node client can implement AVP with no gRPC toolchain at all. @@ -51,9 +53,11 @@ A browser or Node client can implement AVP with no gRPC toolchain at all. |------|----------| | [`SPEC.md`](SPEC.md) | The normative protocol specification. | | [`proto/avp.proto`](proto/avp.proto) | Canonical Protocol Buffers schema (gRPC profile). | -| [`schema/avp.schema.json`](schema/avp.schema.json) | JSON Schema (HTTP/JSON profile). | -| [`vectors/`](vectors/) | Conformance test vectors. | -| [`examples/`](examples/) | A worked end-to-end flow, representative message bodies, and a runnable reference server. | +| [`schema/avp.schema.json`](schema/avp.schema.json) | JSON Schema message shapes (HTTP/JSON profile). | +| [`openapi.yaml`](openapi.yaml) | OpenAPI 3.1 route description for the HTTP/JSON profile. | +| [`vectors/`](vectors/) | Conformance test vectors (indexed by [`vectors/index.json`](vectors/index.json)). | +| [`examples/`](examples/) | A worked end-to-end flow, representative message bodies, and runnable reference implementations. | +| [`Taskfile.yml`](Taskfile.yml) | One-command local runner: `task test` runs the conformance suite and every example. | | [`CONTRIBUTING.md`](CONTRIBUTING.md) | How to help. | | [`SECURITY.md`](SECURITY.md) | How to report a vulnerability. | | [`llms.txt`](llms.txt) | Structured entry point for LLMs and AI agents. | diff --git a/SPEC.md b/SPEC.md index 639546e..38fff25 100644 --- a/SPEC.md +++ b/SPEC.md @@ -183,6 +183,36 @@ failures and MUST NOT retry them as if they were a stale-version conflict. Recom HTTP `429 Too Many Requests` / `403 Forbidden` for the JSON profile; gRPC `RESOURCE_EXHAUSTED` / `PERMISSION_DENIED` for the gRPC profile. +### Errors + +In the HTTP/JSON profile, any non-`2xx` response body is an **error object**: + +```json +{ "error": "", "code": "", "detail": "" } +``` + +`error` is a human-readable message and is always present; clients MUST NOT parse it. `code` is an +OPTIONAL stable machine-readable token (see the table) a client MAY switch on; when absent, the client +falls back to the HTTP status. `detail` is OPTIONAL extra context. The schema is +[`schema/avp.schema.json`](schema/avp.schema.json) `#/$defs/Error`; the full route surface, including +which status each operation can return, is [`openapi.yaml`](openapi.yaml). + +| Status | `code` | Meaning | +|---|---|---| +| `400` | `bad_request` | Malformed body or parameters | +| `401` | `unauthorized` | Missing/invalid token, or an expired/reused challenge nonce | +| `403` | `forbidden` | Authenticated but not a member, or an operation disallowed by policy (`policy_denied`) | +| `404` | `not_found` | Repo or member does not exist | +| `409` | `duplicate_repo` | `createRepo` with an id that already exists | +| `429` | `quota_exceeded` | A deployment resource limit was hit | + +A client MUST treat every error here as **terminal** and MUST NOT retry it as if it were a +stale-version conflict. The one retryable outcome — optimistic-concurrency conflict — is **not** an +error: `push` returns HTTP `200` with `PushResponse { accepted: false, conflict: true }` and the +current version (§10). The gRPC profile carries the same outcomes as status codes +(`INVALID_ARGUMENT`, `UNAUTHENTICATED`, `PERMISSION_DENIED`, `NOT_FOUND`, `ALREADY_EXISTS`, +`RESOURCE_EXHAUSTED`). + ### Profiles - **gRPC**, [`proto/avp.proto`](proto/avp.proto) is canonical. Field numbers and names are stable. @@ -267,8 +297,22 @@ A conformant server MUST uphold: ## 11. Conformance An implementation is conformant if it satisfies every MUST above and reproduces the vectors in -[`vectors/`](vectors/) (canonical AAD/binding-message construction, challenge/sign/token, key wrap/unwrap, -and rotation). See [`vectors/README.md`](vectors/README.md). +[`vectors/`](vectors/), indexed by [`vectors/index.json`](vectors/index.json): + +- the **deterministic constructions** — the AAD layout (`aad.json`) and the canonical key-binding + message (`key-binding-message.json`); +- the **RFC-anchored primitives** — HKDF-SHA256 (`hkdf.json`), X25519 (`x25519.json`), and Ed25519 + sign/verify (`ed25519.json`). The Ed25519 vector anchors the signature used by the challenge→token + flow (§3): signing the raw nonce bytes is exactly this primitive; +- the **envelope compositions** — payload AEAD (`payload-aead.json`) and the key wrap/unwrap + (`key-wrap.json`). The payload-AEAD case includes the epoch-tamper assertion (decryption fails when + the AAD's `keyEpoch` changes), which is the cryptographic core of rotation correctness (§10): a + stale-epoch envelope cannot authenticate. + +See [`vectors/README.md`](vectors/README.md). Dedicated end-to-end vectors for the challenge→token +exchange and the multi-step `removeMember` rotation are a welcome addition (see +[`CONTRIBUTING.md`](CONTRIBUTING.md)); today those paths are covered by the primitives above plus the +cross-language wire interop in [`examples/`](examples/). ## 12. Security considerations and open items diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..4579d44 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,78 @@ +# One-command local runner for the AVP reference examples and conformance suite. +# +# Uses go-task (https://taskfile.dev): `task --list`. Each target mirrors the matching +# .github/workflows/examples-*.yml job, so "green locally" means "green in CI". Each language +# is independent — run only the toolchains you have installed. +# +# Requirements per target: conformance/typescript -> Bun; go -> Go; python -> Python 3; +# rust -> Cargo; java -> a JDK. `task test` runs every target and needs them all. +version: "3" + +tasks: + default: + desc: List available tasks. + cmds: + - task --list + silent: true + + test: + desc: Run the conformance suite and every language's example tests. + cmds: + - task: conformance + - task: go + - task: typescript + - task: python + - task: rust + - task: java + + conformance: + desc: Reproduce every vector in vectors/ with the Node conformance runner. + dir: examples/conformance + cmds: + - bun install --frozen-lockfile + - bun run typecheck + - bun run test + + go: + desc: Vet, test, and build the Go client and server. + dir: examples/go + cmds: + - go vet ./... + - go test ./... + - go build ./... + + typescript: + desc: Typecheck and test the TypeScript client and server. + cmds: + - for: { var: PROJECTS } + cmd: cd examples/typescript/{{.ITEM}} && bun install --frozen-lockfile && bun run typecheck && (grep -q '"test"' package.json && bun run test || echo "no test script in {{.ITEM}}") + vars: + PROJECTS: client server + + python: + desc: Byte-compile and unit-test the Python client and server. + cmds: + - cd examples/python/server && pip install -r requirements.txt && python -m py_compile server.py test_server.py && python -m unittest test_server.py + - cd examples/python/client && pip install -r requirements.txt && python -m py_compile client.py crypto.py && python -m unittest test_crypto.py + + rust: + desc: Build and test the Rust client and server. + cmds: + - cd examples/rust/server && cargo test + - cd examples/rust/client && cargo test + + java: + desc: Compile and run the Java server smoke test and client crypto vectors. + cmds: + - cd examples/java/server && javac Server.java SmokeTest.java && java SmokeTest + - cd examples/java/client && javac Crypto.java CryptoVectors.java Client.java && java CryptoVectors + + interop: + desc: Cross-language wire interop (informational — runs in CI). + cmds: + - | + echo "The cross-language interop matrix (each lang's client -> Go server, Go client ->" + echo "each lang's server) runs in .github/workflows/examples-interop.yml. Reproducing the" + echo "full matrix locally needs all five toolchains plus port-readiness polling; the" + echo "workflow is the reference. Per-language correctness is covered by 'task test'." + silent: true diff --git a/examples/README.md b/examples/README.md index fbea5fc..1676c6c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,7 +31,9 @@ server, and the Go reference client runs against each language's server. Go and are the fullest worked references for the cryptographic core. Conformance tooling lives in [`conformance/`](conformance/) (it checks the repo's -[`../vectors/`](../vectors/)). More languages are welcome; see +[`../vectors/`](../vectors/)). From the repo root, [`task test`](../Taskfile.yml) (go-task) runs the +conformance suite and every language's example tests in one command; per-language targets +(`task go`, `task rust`, …) run just one toolchain. More languages are welcome; see [`../CONTRIBUTING.md`](../CONTRIBUTING.md). ## The cast diff --git a/llms.txt b/llms.txt index 51d24af..1eca878 100644 --- a/llms.txt +++ b/llms.txt @@ -14,7 +14,8 @@ Notes for implementers: field names are normative; do not rename or renumber the - [Specification (SPEC.md)](https://raw.githubusercontent.com/trqlmao/avp/main/SPEC.md): The normative protocol: identity and authentication, the cryptographic envelope, payload and provenance, the transport surface, federation, anti-MITM key binding, invariants, conformance, and security considerations. - [Protobuf schema (avp.proto)](https://raw.githubusercontent.com/trqlmao/avp/main/proto/avp.proto): Canonical gRPC schema with stable field numbers and names. -- [JSON Schema (avp.schema.json)](https://raw.githubusercontent.com/trqlmao/avp/main/schema/avp.schema.json): Message shapes for the HTTP/JSON transport profile. +- [JSON Schema (avp.schema.json)](https://raw.githubusercontent.com/trqlmao/avp/main/schema/avp.schema.json): Message shapes for the HTTP/JSON transport profile, including the error body (`$defs/Error`). +- [OpenAPI (openapi.yaml)](https://raw.githubusercontent.com/trqlmao/avp/main/openapi.yaml): OpenAPI 3.1 route description of the HTTP/JSON profile — paths, methods, status codes, and error responses, referencing the JSON Schema shapes. ## Examples @@ -23,6 +24,7 @@ Notes for implementers: field names are normative; do not rename or renumber the ## Conformance vectors +- [Vectors index (index.json)](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/index.json): Machine-readable index of every vector file — kind, spec section, and RFC anchors — for a harness to enumerate. - [Vectors overview](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/README.md): How the vectors are organized and which are deterministic versus generated. - [AAD vectors](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/aad.json): Deterministic additional-authenticated-data construction (SPEC section 4). - [Key-binding message vectors](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/key-binding-message.json): Deterministic canonical key-binding message (SPEC section 9). diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..dbeaac2 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,323 @@ +# OpenAPI description of the AVP HTTP/JSON transport profile. +# +# This is the route-level companion to schema/avp.schema.json (message shapes) and +# proto/avp.proto (the canonical gRPC profile). It maps every operation in SPEC §6 to a +# path, method, status set, and body, so an HTTP stack can scaffold a client or server and +# render interactive docs. Message schemas are referenced from schema/avp.schema.json — that +# file remains the single source of truth for shapes; do not duplicate them here. +# +# OpenAPI 3.1 (JSON Schema 2020-12). SPDX-License-Identifier: MIT +openapi: 3.1.0 +info: + title: Alt Vault Protocol — HTTP/JSON profile + version: "0.2" + summary: Zero-knowledge, federated sharing of Minecraft alt accounts. + description: | + The HTTP/JSON transport profile for AVP. One path per operation, JSON bodies whose field + names are the proto field names in `camelCase`, and a bearer token in `Authorization` for + every route except the two auth routes. See [SPEC.md](SPEC.md) for normative semantics and + [IMPLEMENTING.md](IMPLEMENTING.md) for an implementer guide. + + **Two services, possibly two hosts.** The auth routes (`/api/auth/keypair/*`) are served by + the identity provider (IdP); the `/v1/repos/*` routes are the vault server. They may be the + same origin or different ones. Tokens are server-local (SPEC §3): cache them keyed by host. + + **Conflict is not an error.** A stale-version `push` returns `200` with + `PushResponse.conflict = true`, not a 4xx. Every non-2xx body is an + [`Error`](schema/avp.schema.json) and is terminal — never retried as a conflict (SPEC §6). + license: + name: MIT + url: https://github.com/trqlmao/avp/blob/main/LICENSE +externalDocs: + description: Specification + url: https://github.com/trqlmao/avp/blob/main/SPEC.md + +servers: + - url: https://{host} + description: Any conformant AVP host (IdP and/or vault). + variables: + host: + default: vault.example + +tags: + - name: auth + description: Challenge→token identity flow (IdP). No bearer token required. + - name: vault + description: Repository operations (vault server). Bearer token required. + - name: discovery + description: Optional unauthenticated server metadata. + +security: + - bearerAuth: [] + +paths: + /api/auth/keypair/challenge: + post: + tags: [auth] + operationId: challenge + summary: Request a single-use challenge nonce for an Ed25519 identity. + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/ChallengeRequest" } + responses: + "200": + description: A fresh single-use nonce with a short TTL. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/AuthChallenge" } + "400": { $ref: "#/components/responses/BadRequest" } + + /api/auth/keypair/token: + post: + tags: [auth] + operationId: token + summary: Exchange a signed nonce for a bearer token. + description: | + Sign the **raw nonce bytes** (base64-decode `nonce` first) with the Ed25519 private key. + A common mistake is signing the base64 string; the server rejects it. + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/TokenRequest" } + responses: + "200": + description: A bearer token scoped to this host and the caller's memberships. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/AuthToken" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /v1/repos: + post: + tags: [vault] + operationId: createRepo + summary: Create a repository from a client-built manifest and initial payload. + description: The manifest's sole member MUST be the caller; a duplicate `repoId` is rejected. + requestBody: + required: true + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/CreateRepoRequest" } + responses: + "200": + description: The stored manifest. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/VaultManifest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + /v1/repos/{repoId}/pull: + post: + tags: [vault] + operationId: pull + summary: Fetch the current manifest and (if changed) the payload envelope. + parameters: [{ $ref: "#/components/parameters/RepoId" }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/PullRequest" } + responses: + "200": + description: | + The manifest plus `unchanged`. When `knownPayloadVersion` equals the current version, + `unchanged` is `true` and `envelope` is omitted; otherwise `envelope` is present. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/PullResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /v1/repos/{repoId}/push: + post: + tags: [vault] + operationId: push + summary: Write a new payload using optimistic concurrency. + description: | + Applies only if `expectedPayloadVersion` equals the current version. A stale write is **not** + an error: the response is `200` with `accepted = false`, `conflict = true`, and the current + version — pull, re-apply, and retry. Present `rotatedMembers` to replace the roster atomically + with the write. + parameters: [{ $ref: "#/components/parameters/RepoId" }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/PushRequest" } + responses: + "200": + description: | + Write outcome. `accepted = true` on success; `accepted = false, conflict = true` on a + stale-version conflict (retryable). Both carry the authoritative `payloadVersion`/`keyEpoch`. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/PushResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "429": { $ref: "#/components/responses/QuotaExceeded" } + + /v1/repos/{repoId}/add-member: + post: + tags: [vault] + operationId: addMember + summary: Record a member whose wrapped key the client computed. + parameters: [{ $ref: "#/components/parameters/RepoId" }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/MemberAddRequest" } + responses: + "200": + description: The updated manifest. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/VaultManifest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /v1/repos/{repoId}/remove-member: + post: + tags: [vault] + operationId: removeMember + summary: Remove a member, applying the accompanying key rotation atomically. + description: | + In one atomic step: drop `removedMemberId`, replace the roster with `rewrappedMembers`, store + `rotatedEnvelope`, and set the epoch to `newKeyEpoch`. The removed member's old wrapped key + cannot derive the new epoch's data key. + parameters: [{ $ref: "#/components/parameters/RepoId" }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/MemberRemoveRequest" } + responses: + "200": + description: The updated manifest at the new epoch. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/VaultManifest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /v1/repos/{repoId}/member/{memberId}: + get: + tags: [vault] + operationId: fetchMemberKey + summary: Look up a single member's public-key entry. + description: | + `memberId` is the base64 Ed25519 public key. Because it contains `+`, `/`, and `=`, it MUST be + percent-encoded, and the server MUST route on the *escaped* path (do not unescape before matching + the path separator). + parameters: + - { $ref: "#/components/parameters/RepoId" } + - { $ref: "#/components/parameters/MemberId" } + responses: + "200": + description: The member entry (public keys and any key-binding signature). + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/MemberEntry" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /.well-known/avp: + get: + tags: [discovery] + operationId: discovery + summary: Optional server metadata for resolving an avp:// address (SPEC §8.2). + security: [] + responses: + "200": + description: Which transport profile(s) the server speaks and which IdP to trust. + content: + application/json: + schema: { $ref: "#/components/schemas/Discovery" } + "404": + description: The server publishes no discovery document. + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: The token from `/api/auth/keypair/token`, server-local (SPEC §3). + + parameters: + RepoId: + name: repoId + in: path + required: true + description: The opaque repository id, percent-encoded. + schema: { type: string } + MemberId: + name: memberId + in: path + required: true + description: The base64 Ed25519 member id, percent-encoded (contains `+` `/` `=`). + schema: { type: string } + + responses: + BadRequest: + description: Malformed body or parameters. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/Error" } + example: { error: "invalid JSON body", code: bad_request } + Unauthorized: + description: Missing/invalid token, or an expired or reused challenge nonce. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/Error" } + example: { error: "missing or unknown bearer token", code: unauthorized } + Forbidden: + description: Authenticated but not a member, or disallowed by deployment policy. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/Error" } + example: { error: "caller is not a member", code: forbidden } + NotFound: + description: Repo or member not found. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/Error" } + example: { error: "repo not found", code: not_found } + Conflict: + description: A repository with this id already exists. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/Error" } + example: { error: "repo already exists", code: duplicate_repo } + QuotaExceeded: + description: A deployment resource limit was hit. Terminal; do not retry as a conflict. + content: + application/json: + schema: { $ref: "./schema/avp.schema.json#/$defs/Error" } + example: { error: "repository quota exceeded", code: quota_exceeded } + + schemas: + Discovery: + type: object + description: Body of GET /.well-known/avp (SPEC §8.2). + required: [profiles] + properties: + profiles: + type: array + items: { type: string, enum: [grpc, http-json] } + description: Transport profile(s) the server speaks. + issuerJwksUrl: + type: string + description: The IdP key set whose member key bindings to trust (§9). diff --git a/schema/avp.schema.json b/schema/avp.schema.json index 9d957bd..5881513 100644 --- a/schema/avp.schema.json +++ b/schema/avp.schema.json @@ -221,6 +221,28 @@ "keyEpoch": { "type": ["integer", "null"] }, "issuerJwksUrl": { "type": ["string", "null"] } } + }, + "Error": { + "type": "object", + "description": "Error response body for the HTTP/JSON profile (SPEC §6). Returned with any non-2xx status. A stale-version push is NOT an error: it is a 200 PushResponse with conflict=true.", + "required": ["error"], + "properties": { + "error": { "type": "string", "description": "human-readable message; not machine-parsed" }, + "code": { + "type": "string", + "description": "stable machine-readable code; absence means unspecified", + "enum": [ + "unauthorized", + "forbidden", + "not_found", + "duplicate_repo", + "quota_exceeded", + "policy_denied", + "bad_request" + ] + }, + "detail": { "type": "string", "description": "optional extra context, e.g. a parse error" } + } } } } diff --git a/vectors/README.md b/vectors/README.md index b78dcf0..3998dec 100644 --- a/vectors/README.md +++ b/vectors/README.md @@ -4,6 +4,10 @@ Test vectors an implementation can check itself against. Each file is JSON with or more `cases`. The conformance runner in [`../examples/conformance`](../examples/conformance) loads and checks every file here with Node's `crypto`. +[`index.json`](index.json) is a machine-readable index of these files — each entry gives the file, its +`kind` (deterministic / rfc-primitive / composition), the spec section, and the published RFC anchors — +so a harness can enumerate the vectors instead of hardcoding filenames. + ## Deterministic construction vectors These depend only on byte/string construction rules, not on key material, so they are fully specified in diff --git a/vectors/index.json b/vectors/index.json new file mode 100644 index 0000000..c18786a --- /dev/null +++ b/vectors/index.json @@ -0,0 +1,77 @@ +{ + "description": "Machine-readable index of AVP conformance vectors. A harness can enumerate this instead of hardcoding filenames. Prose explanation: vectors/README.md. Each entry: file (relative to vectors/), kind, specSection, and the published anchors a conformant implementation must reproduce.", + "schemeId": "X25519-HKDF-SHA256-AESGCM-v1", + "kinds": { + "deterministic": "Pure byte/string construction with no key material; must match exactly.", + "rfc-primitive": "A cryptographic primitive pinned to a published RFC test vector.", + "composition": "Full AVP envelope composition with fixed keys/IVs; reproduce and round-trip (decrypt/unwrap), including the documented failure assertion." + }, + "vectors": [ + { + "id": "aad", + "file": "aad.json", + "kind": "deterministic", + "specSection": "4", + "construction": "UTF8(repoId) || 0x1F || int64BE(payloadVersion) || int64BE(keyEpoch)", + "expects": "AAD bytes as lowercase hex", + "anchors": [] + }, + { + "id": "key-binding-message", + "file": "key-binding-message.json", + "kind": "deterministic", + "specSection": "9", + "construction": "utf8(ed25519PublicKey + \"|\" + x25519PublicKey)", + "expects": "the canonical message as a UTF-8 string", + "anchors": [] + }, + { + "id": "hkdf", + "file": "hkdf.json", + "kind": "rfc-primitive", + "primitive": "HKDF-SHA256", + "specSection": "4", + "expects": "prkHex (extract) and okmHex (expand)", + "anchors": ["RFC 5869 Appendix A.1", "RFC 5869 Appendix A.3 (zero-length salt)"] + }, + { + "id": "x25519", + "file": "x25519.json", + "kind": "rfc-primitive", + "primitive": "X25519", + "specSection": "4", + "expects": "raw 32-byte little-endian shared secret, unhashed", + "anchors": ["RFC 7748 §5.2", "RFC 7748 §6.1 (reused by key-wrap)"] + }, + { + "id": "ed25519", + "file": "ed25519.json", + "kind": "rfc-primitive", + "primitive": "Ed25519", + "specSection": "3", + "expects": "public key derived from seed; deterministic signature reproduced and verified", + "anchors": ["RFC 8032 §7.1 TEST 2", "RFC 8032 §7.1 TEST 3"], + "note": "Anchors the challenge -> token signature: signing the raw nonce bytes is this primitive." + }, + { + "id": "payload-aead", + "file": "payload-aead.json", + "kind": "composition", + "specSection": "4", + "construction": "AES-256-GCM, 12-byte IV, 16-byte tag appended; AAD = aad construction", + "expects": "re-encrypt equals committed ciphertext; decrypt recovers plaintext", + "failureAssertion": "decryption MUST fail when the AAD keyEpoch is changed (rollback/replay protection; anchors rotation correctness, SPEC §10)", + "anchors": ["RFC 5116 AES-256-GCM"] + }, + { + "id": "key-wrap", + "file": "key-wrap.json", + "kind": "composition", + "specSection": "4", + "construction": "X25519-HKDF-SHA256-AESGCM-v1: KEK = HKDF-SHA256(ikm=X25519(eph,recip), salt=ephPubRaw, info=UTF8(\"avp/rdk-wrap/v1\")); AES-256-GCM(KEK, iv, aad=UTF8(\"avp/rdk-wrap/v1\"), dataKey)", + "expects": "recompute shared secret + KEK; re-wrap equals committed ciphertext; unwrap recovers the data key", + "note": "recipientPrivateKeyB64, sharedSecretHex, kekHex are checker aids, NOT part of the wire WrappedKey", + "anchors": ["RFC 7748 §6.1 (recipient = Alice, ephemeral = Bob)"] + } + ] +} From bb598009298b7651fc6efd7b1ab57395a86e4f97 Mon Sep 17 00:00:00 2001 From: trqlmao Date: Mon, 8 Jun 2026 11:41:56 -0700 Subject: [PATCH 2/4] feat: add a black-box conformance harness and conformance CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make "conformant" executable rather than self-attested, and stop the machine-readable artifacts from silently drifting apart. harness/ — a black-box conformance suite. Point it at any running AVP server (`bun harness/conformance.ts --server URL`) and it drives the full HTTP/JSON wire contract, asserting the normative MUSTs instead of just printing a transcript: - §3 auth: nonce shape, valid-token, single-use-nonce rejection, the classic "signed the base64 string not the raw nonce" rejection, 401 without a token. - §6 vault: createRepo sole-member/duplicate rules, pull unchanged vs envelope, push accept + version bump, a stale push as a 200 conflict (never a 4xx), non-member 403, addMember/fetchMemberKey, removed-member 403. - §4/§10 crypto + invariants: envelope round-trips to our plaintext; a second member unwraps and decrypts; zero-knowledge (the plaintext never appears in the server's JSON or stored ciphertext); removeMember bumps the epoch and a departed member's old key cannot decrypt the rotated envelope; a remaining member can. The only AVP-specific code is the client crypto, reused from the vector-tested examples/typescript/client, so the harness stays a thin wire driver. Verified locally: 24/24 against the TypeScript reference server. .github/workflows/conformance.yml — runs the vector runner (which had no CI until now), lints openapi.yaml with Spectral, checks schema/example/ index/openapi consistency, and runs the harness against the Go and TypeScript reference servers (SHA-pinned actions, the interop workflow's server-start/poll pattern). examples/conformance — a new test asserts every examples/*.json body validates against its schema $def, that vectors/index.json lists exactly the vector files present, and that every openapi.yaml $ref resolves to a schema $def (adds ajv + yaml dev-deps). Cross-linked from README, IMPLEMENTING.md (§6 checklist), SPEC.md (§11), llms.txt, and Taskfile.yml (`task harness`, `task openapi-lint`); recorded under CHANGELOG [Unreleased]. --- .github/workflows/conformance.yml | 116 +++++++ .spectral.yaml | 8 + CHANGELOG.md | 8 + IMPLEMENTING.md | 6 + README.md | 16 + SPEC.md | 11 +- Taskfile.yml | 15 +- examples/conformance/bun.lock | 14 + examples/conformance/package.json | 6 +- examples/conformance/test/schema.test.ts | 68 ++++ harness/README.md | 35 ++ harness/bun.lock | 20 ++ harness/conformance.ts | 395 +++++++++++++++++++++++ harness/package.json | 16 + harness/tsconfig.json | 15 + llms.txt | 1 + 16 files changed, 743 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/conformance.yml create mode 100644 .spectral.yaml create mode 100644 examples/conformance/test/schema.test.ts create mode 100644 harness/README.md create mode 100644 harness/bun.lock create mode 100644 harness/conformance.ts create mode 100644 harness/package.json create mode 100644 harness/tsconfig.json diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..d988aaf --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,116 @@ +name: conformance + +# Executable conformance: +# - vectors: reproduce vectors/ and check schema/example/index/openapi consistency (examples/conformance) +# - openapi: lint openapi.yaml with Spectral +# - black-box: drive the full wire contract against the Go and TypeScript reference servers (harness/) + +on: + push: + paths: + - "examples/**" + - "harness/**" + - "schema/**" + - "vectors/**" + - "openapi.yaml" + - ".spectral.yaml" + - ".github/workflows/conformance.yml" + pull_request: + paths: + - "examples/**" + - "harness/**" + - "schema/**" + - "vectors/**" + - "openapi.yaml" + - ".spectral.yaml" + - ".github/workflows/conformance.yml" + +permissions: + contents: read + +jobs: + vectors: + name: vectors + schema consistency + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.10" + - name: Install + run: bun install --frozen-lockfile --cwd examples/conformance + - name: Typecheck + run: bun run --cwd examples/conformance typecheck + - name: Reproduce vectors and check artifact consistency + run: bun run --cwd examples/conformance test + + openapi: + name: openapi lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.10" + - name: Spectral lint + run: bunx @stoplight/spectral-cli@6 lint openapi.yaml --ruleset .spectral.yaml --fail-severity error + + black-box: + name: black-box (${{ matrix.server }} server) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + server: [go, typescript] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # The harness runs under Bun (no install: only node:crypto + global fetch). + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.10" + + - uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + if: matrix.server == 'go' + with: + go-version: "1.22" + + - name: Install TypeScript server deps + if: matrix.server == 'typescript' + run: bun install --frozen-lockfile --cwd examples/typescript/server + + - name: Run the harness against the ${{ matrix.server }} reference server + env: + SERVER: ${{ matrix.server }} + run: | + set -e + PORT=19101 + + case "$SERVER" in + go) + (cd examples/go/server && PORT=$PORT go run .) & + SRV_PID=$! + ;; + typescript) + (cd examples/typescript/server && PORT=$PORT bun run start) & + SRV_PID=$! + ;; + esac + + # Poll until the server accepts connections (up to 20 s). + for i in $(seq 1 100); do + curl -fsS -X POST "http://localhost:$PORT/api/auth/keypair/challenge" \ + -H 'content-type: application/json' -d '{}' >/dev/null 2>&1 && break + sleep 0.2 + done + curl -fsS -X POST "http://localhost:$PORT/api/auth/keypair/challenge" \ + -H 'content-type: application/json' -d '{}' >/dev/null 2>&1 \ + || { echo "server did not start within 20s"; exit 1; } + + bun harness/conformance.ts --server "http://localhost:$PORT" + HARNESS_EXIT=$? + + kill $SRV_PID 2>/dev/null || true + wait $SRV_PID 2>/dev/null || true + + exit $HARNESS_EXIT diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..8a4c758 --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,8 @@ +# Spectral ruleset for linting openapi.yaml (the HTTP/JSON profile description). +# Extends the built-in OpenAPI rules; a couple of doc-style rules are relaxed because the +# normative descriptions live in SPEC.md, not in per-operation prose here. +extends: ["spectral:oas"] +rules: + oas3-unused-component: off # message schemas live in schema/avp.schema.json, referenced externally + info-contact: off # reporting channel is SECURITY.md / the issue tracker, not a contact block + operation-tag-defined: warn diff --git a/CHANGELOG.md b/CHANGELOG.md index 58fab1d..7a23d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ implementation's version. ### Repository +- Added a black-box conformance harness in `harness/`: point it at any running server and it drives the + full wire contract, asserting the normative MUSTs (auth failure modes, optimistic-concurrency + conflict, membership authorization, key rotation, and zero-knowledge — the plaintext never surfaces in + what the server stores). Reuses the vector-tested reference crypto. +- Added a `conformance` CI workflow: reproduces the vectors, lints `openapi.yaml` with Spectral, checks + that the schema / example bodies / vector index / OpenAPI `$refs` stay consistent, and runs the + black-box harness against the Go and TypeScript reference servers. The vector runner now also runs in + CI for the first time. - Added `openapi.yaml`, an OpenAPI 3.1 description of the HTTP/JSON profile (paths, status codes, error responses) that references the JSON Schema shapes, so the route surface is machine-readable and stub-generatable. diff --git a/IMPLEMENTING.md b/IMPLEMENTING.md index d4de86b..22505b7 100644 --- a/IMPLEMENTING.md +++ b/IMPLEMENTING.md @@ -218,6 +218,12 @@ operation the server performs is verifying the Ed25519 challenge signature in th [`.github/workflows/examples-interop.yml`](.github/workflows/examples-interop.yml) workflow does this for all five reference examples and is a useful model. +5. **Black-box your server.** Point the conformance harness in [`harness/`](harness/) at your running + server (`bun harness/conformance.ts --server http://localhost:PORT`). It drives the whole flow and + asserts the normative MUSTs — the auth failure modes, optimistic-concurrency `conflict`, membership + authorization, key rotation, and zero-knowledge (the plaintext never surfaces in what the server + stores). CI runs it against the Go and TypeScript reference servers. + ## 7. Reference implementations All five examples implement the real envelope and key-wrap crypto, each verified byte-for-byte diff --git a/README.md b/README.md index 828f4f7..4ff6416 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,22 @@ It reproduces the deterministic and RFC-anchored vectors exactly and round-trips (decrypting and unwrapping what a peer produced). Reproduce the same in your language and you are conformant. +### Check your server + +The vectors prove your crypto; the black-box harness in [`harness/`](harness/) proves your *server*. +Point it at any running AVP server and it drives the full wire contract, asserting the normative MUSTs — +the auth failure modes, optimistic-concurrency `conflict`, membership authorization, key rotation, and +zero-knowledge: + +```sh +bun harness/conformance.ts --server http://localhost:8787 +``` + +It prints `PASS`/`FAIL` per check and exits non-zero on any failure. CI +([`.github/workflows/conformance.yml`](.github/workflows/conformance.yml)) runs it against the Go and +TypeScript reference servers, lints [`openapi.yaml`](openapi.yaml), and checks that the schema, example +bodies, and vector index stay consistent. + ## For AI agents If you are an AI coding agent asked to implement, review, or extend AVP, start from diff --git a/SPEC.md b/SPEC.md index 38fff25..e3fc4d4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -309,10 +309,13 @@ An implementation is conformant if it satisfies every MUST above and reproduces the AAD's `keyEpoch` changes), which is the cryptographic core of rotation correctness (§10): a stale-epoch envelope cannot authenticate. -See [`vectors/README.md`](vectors/README.md). Dedicated end-to-end vectors for the challenge→token -exchange and the multi-step `removeMember` rotation are a welcome addition (see -[`CONTRIBUTING.md`](CONTRIBUTING.md)); today those paths are covered by the primitives above plus the -cross-language wire interop in [`examples/`](examples/). +See [`vectors/README.md`](vectors/README.md). A **server** additionally proves conformance by passing the +black-box harness in [`harness/`](harness/), which drives the full wire contract and asserts the MUSTs of +§3, §6, and §10 (the auth failure modes, optimistic-concurrency `conflict`, membership authorization, key +rotation, and zero-knowledge). Dedicated end-to-end vectors for the challenge→token exchange and the +multi-step `removeMember` rotation are a welcome addition (see [`CONTRIBUTING.md`](CONTRIBUTING.md)); +today those paths are covered by the primitives above, the harness, and the cross-language wire interop +in [`examples/`](examples/). ## 12. Security considerations and open items diff --git a/Taskfile.yml b/Taskfile.yml index 4579d44..3f7cc72 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -26,13 +26,26 @@ tasks: - task: java conformance: - desc: Reproduce every vector in vectors/ with the Node conformance runner. + desc: Reproduce every vector in vectors/ and check schema/example/index/openapi consistency. dir: examples/conformance cmds: - bun install --frozen-lockfile - bun run typecheck - bun run test + openapi-lint: + desc: Lint openapi.yaml with Spectral. + cmds: + - bunx @stoplight/spectral-cli@6 lint openapi.yaml --ruleset .spectral.yaml --fail-severity error + + harness: + desc: Run the black-box conformance harness against a server (AVP_SERVER_URL=http://host:port task harness). + dir: harness + cmds: + - bun conformance.ts --server "{{.AVP_SERVER_URL}}" + env: + AVP_SERVER_URL: '{{.AVP_SERVER_URL | default "http://localhost:8787"}}' + go: desc: Vet, test, and build the Go client and server. dir: examples/go diff --git a/examples/conformance/bun.lock b/examples/conformance/bun.lock index 4dc81df..56cc535 100644 --- a/examples/conformance/bun.lock +++ b/examples/conformance/bun.lock @@ -6,8 +6,10 @@ "name": "avp-conformance", "devDependencies": { "@types/node": "^22.0.0", + "ajv": "^8.17.1", "tsx": "^4.19.0", "typescript": "^5.6.0", + "yaml": "^2.6.0", }, }, }, @@ -66,14 +68,26 @@ "@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], } } diff --git a/examples/conformance/package.json b/examples/conformance/package.json index 7255225..2cdb0d2 100644 --- a/examples/conformance/package.json +++ b/examples/conformance/package.json @@ -2,7 +2,7 @@ "name": "avp-conformance", "version": "0.0.0", "private": true, - "description": "Conformance runner for the Alt Vault Protocol deterministic vectors (SPEC sections 4 and 9). Illustrative, not production.", + "description": "Conformance runner for the Alt Vault Protocol vectors (SPEC sections 4 and 9) plus schema/example/index/openapi consistency checks. Illustrative, not production.", "type": "module", "license": "MIT", "scripts": { @@ -11,7 +11,9 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "ajv": "^8.17.1", "tsx": "^4.19.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "yaml": "^2.6.0" } } diff --git a/examples/conformance/test/schema.test.ts b/examples/conformance/test/schema.test.ts new file mode 100644 index 0000000..e3c4e8b --- /dev/null +++ b/examples/conformance/test/schema.test.ts @@ -0,0 +1,68 @@ +/** + * Consistency checks across the repository's machine-readable artifacts, so the schema, the worked + * example bodies, the vector index, and the OpenAPI description cannot silently drift apart: + * + * - every example body in examples/*.json validates against its schema/avp.schema.json `$def`; + * - vectors/index.json lists exactly the vector files present in vectors/; + * - every `$ref` openapi.yaml makes into the JSON Schema resolves to a real `$def`, and the + * OpenAPI document parses as YAML. + * + * SPDX-License-Identifier: MIT + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import Ajv2020 from "ajv/dist/2020.js"; +import { parse as parseYaml } from "yaml"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, "..", "..", ".."); // examples/conformance/test -> repo root + +const schema = JSON.parse(readFileSync(join(root, "schema", "avp.schema.json"), "utf8")); + +const ajv = new Ajv2020({ strict: false, allErrors: true }); +ajv.addSchema(schema, "avp"); + +function validatorFor(def: string) { + const validate = ajv.getSchema(`avp#/$defs/${def}`); + assert.ok(validate, `schema is missing $def ${def}`); + return validate; +} + +const EXAMPLE_BODIES: ReadonlyArray = [ + ["create-repo-request.json", "CreateRepoRequest"], + ["pull-response.json", "PullResponse"], + ["push-request.json", "PushRequest"], +]; + +for (const [file, def] of EXAMPLE_BODIES) { + test(`examples/${file} validates against ${def}`, () => { + const data = JSON.parse(readFileSync(join(root, "examples", file), "utf8")); + const validate = validatorFor(def); + const ok = validate(data); + assert.ok(ok, `schema errors:\n${JSON.stringify(validate.errors, null, 2)}`); + }); +} + +test("vectors/index.json lists exactly the vector files present", () => { + const index = JSON.parse(readFileSync(join(root, "vectors", "index.json"), "utf8")); + const listed = (index.vectors as Array<{ file: string }>).map((v) => v.file).sort(); + const present = readdirSync(join(root, "vectors")) + .filter((f) => f.endsWith(".json") && f !== "index.json") + .sort(); + assert.deepEqual(listed, present, "index.json and vectors/ are out of sync"); +}); + +test("openapi.yaml parses and every schema $ref resolves to a $def", () => { + const text = readFileSync(join(root, "openapi.yaml"), "utf8"); + assert.ok(parseYaml(text), "openapi.yaml did not parse as YAML"); + const refs = [...text.matchAll(/avp\.schema\.json#\/\$defs\/(\w+)/g)].map((m) => m[1]); + const defs = new Set(Object.keys(schema.$defs)); + const missing = [...new Set(refs)].filter((r) => !defs.has(r)); + assert.deepEqual(missing, [], `OpenAPI references unknown schema $defs: ${missing.join(", ")}`); + assert.ok(refs.length > 0, "expected openapi.yaml to reference the schema"); +}); diff --git a/harness/README.md b/harness/README.md new file mode 100644 index 0000000..88e3b61 --- /dev/null +++ b/harness/README.md @@ -0,0 +1,35 @@ +# AVP conformance harness + +A black-box conformance suite for the HTTP/JSON profile. Point it at a running AVP server and it drives +the full wire contract end to end, asserting the normative MUSTs instead of just printing a transcript. +It is the executable companion to the static [`../vectors/`](../vectors/): the vectors prove your crypto +reproduces known-good bytes; this proves your *server* behaves. + +```sh +# against any conformant server +bun conformance.ts --server http://localhost:8787 +# or +AVP_SERVER_URL=http://localhost:8787 bun conformance.ts +``` + +Exit code is `0` only if every check passes; each check prints `PASS`/`FAIL` with its SPEC section. + +## What it checks + +| SPEC | Checks | +|---|---| +| §3 Auth | challenge returns a ≥32-byte nonce; a valid signed nonce yields a token; a **reused** nonce is rejected (single-use); signing the base64 string instead of the raw nonce bytes is rejected; a vault call without a token is `401`. | +| §6 Vault | `createRepo` returns a 1-member manifest; a manifest whose sole member ≠ caller is `403`; a duplicate `repoId` is `409`; `pull` at the current version is `unchanged` with no envelope; `pull` from an older version returns the envelope; `push` at the expected version is accepted and bumps the version; a **stale** `push` is a `200` with `conflict=true` (never a 4xx); a non-member's `pull` is `403`; `addMember` then `fetchMemberKey` round-trips; the removed member's `pull` becomes `403`. | +| §4 Envelope | the pulled envelope round-trips back to our plaintext; a second member unwraps the data key and decrypts. | +| §10 Invariants | the plaintext never appears in the server's JSON or inside the stored ciphertext/iv (**zero-knowledge**); `removeMember` bumps the epoch and drops the member; the departed member's old key differs from the rotated key and cannot decrypt the new-epoch envelope; a remaining member decrypts the rotated envelope. | + +## How it works + +The only AVP-specific code is the client-side crypto, imported from the vector-tested reference at +[`../examples/typescript/client/src/crypto.ts`](../examples/typescript/client/src/crypto.ts), so the +harness itself stays a thin wire driver. It runs under [Bun](https://bun.sh) with no install step (only +Node's built-in `crypto` and global `fetch`). `bun run typecheck` runs `tsc` over it. + +A pass certifies the surface this harness covers; it supplements, and does not replace, reproducing the +[`../vectors/`](../vectors/) in your implementation language. CI runs it against the Go and TypeScript +reference servers (`.github/workflows/conformance.yml`). diff --git a/harness/bun.lock b/harness/bun.lock new file mode 100644 index 0000000..6f46e1d --- /dev/null +++ b/harness/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "avp-conformance-harness", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/harness/conformance.ts b/harness/conformance.ts new file mode 100644 index 0000000..e92faa5 --- /dev/null +++ b/harness/conformance.ts @@ -0,0 +1,395 @@ +/** + * AVP black-box conformance harness (HTTP/JSON profile). + * + * Point it at any running AVP server and it exercises the full wire contract end to end, asserting the + * normative MUSTs from SPEC §3/§6/§10 rather than just printing a transcript: the challenge→token flow + * and its failure modes, optimistic concurrency (a stale push is a 200 conflict, never a 4xx), membership + * authorization, key rotation on member removal, and — the whole point — zero-knowledge: the plaintext we + * encrypt never appears in anything the server stores or echoes back. + * + * It is server-agnostic: the only AVP-specific code is the client-side crypto, reused from the + * vector-tested reference at ../examples/typescript/client/src/crypto.ts. A pass means the target server + * is conformant to the surface this harness covers; it is a strong supplement to the static vectors in + * vectors/, not a replacement for them. + * + * bun conformance.ts --server http://localhost:8787 + * AVP_SERVER_URL=http://localhost:8787 bun conformance.ts + * + * Exit code is 0 only if every check passes. SPDX-License-Identifier: MIT + */ + +import { generateKeyPairSync, randomBytes, randomUUID, sign as edSign, type KeyObject } from "node:crypto"; + +import { + decryptPayload, + encryptPayload, + generateX25519, + unwrapDataKey, + wrapDataKey, + WRAP_SCHEME_ID, + type EncryptedEnvelopeFields, + type WrappedKeyFields, +} from "../examples/typescript/client/src/crypto.ts"; + +// ─── Config ─────────────────────────────────────────────────────────────────── + +function parseBaseUrl(): string { + const flag = process.argv.indexOf("--server"); + const fromFlag = flag !== -1 ? process.argv[flag + 1] : undefined; + const url = fromFlag ?? process.env.AVP_SERVER_URL ?? "http://localhost:8787"; + return url.replace(/\/$/, ""); +} + +const BASE_URL = parseBaseUrl(); + +// ─── Tiny check framework ─────────────────────────────────────────────────────── + +interface Result { + name: string; + section: string; + ok: boolean; + detail: string; +} + +const results: Result[] = []; + +/** Record a single conformance check. `cond` true = pass; `detail` shows on failure (or info on pass). */ +function check(section: string, name: string, cond: boolean, detail = ""): void { + results.push({ section, name, ok: cond, detail }); +} + +/** Run a check whose body may throw; a throw is a failure carrying the message. */ +async function checkThrows( + section: string, + name: string, + fn: () => Promise | void, + { expectThrow = false } = {}, +): Promise { + try { + await fn(); + check(section, name, !expectThrow, expectThrow ? "expected an error but none was thrown" : ""); + } catch (err) { + check(section, name, expectThrow, expectThrow ? "" : String(err instanceof Error ? err.message : err)); + } +} + +// ─── HTTP (does not throw on non-2xx; the checks assert the status) ────────────── + +interface Resp { + status: number; + body: any; + text: string; +} + +async function req(method: string, path: string, body?: unknown, token?: string): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let parsed: any = undefined; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + parsed = undefined; + } + return { status: res.status, body: parsed, text }; +} + +const repoPath = (repoId: string, op: string) => `/v1/repos/${encodeURIComponent(repoId)}/${op}`; + +// ─── Identity (Ed25519 id + X25519 wrap key), SPEC §2/§3 ───────────────────────── + +interface Identity { + ed25519PublicKey: string; + x25519PublicKey: string; + edPrivateKey: KeyObject; + xPrivateKey: KeyObject; +} + +function generateIdentity(): Identity { + const { publicKey: edPub, privateKey: edPriv } = generateKeyPairSync("ed25519"); + const spki = edPub.export({ format: "der", type: "spki" }) as Buffer; + const edPubRaw = spki.subarray(spki.length - 32); // SPKI = 12-byte header + raw 32-byte key + const { privateKey: xPriv, publicKeyRaw: xPubRaw } = generateX25519(); + return { + ed25519PublicKey: edPubRaw.toString("base64"), + x25519PublicKey: xPubRaw.toString("base64"), + edPrivateKey: edPriv, + xPrivateKey: xPriv, + }; +} + +function memberEntry(id: Identity, dataKey: Buffer, keyEpoch: number) { + return { + ed25519PublicKey: id.ed25519PublicKey, + x25519PublicKey: id.x25519PublicKey, + wrappedDataKey: wrapDataKey(id.x25519PublicKey, dataKey) as WrappedKeyFields, + keyEpoch, + keyBindingSig: null, + }; +} + +interface Alt { + uuid: string; + username: string; + accessToken: string; + type: string; + lastUsed: number; +} + +function envelope(dataKey: Buffer, repoId: string, version: number, epoch: number, alts: Alt[]) { + const body = Buffer.from(JSON.stringify({ alts, payloadVersion: version }), "utf8"); + return encryptPayload(dataKey, repoId, version, epoch, body) as EncryptedEnvelopeFields; +} + +// ─── Auth (challenge → sign raw nonce → token), SPEC §3 ────────────────────────── + +async function challenge(id: Identity): Promise { + const res = await req("POST", "/api/auth/keypair/challenge", { ed25519PublicKey: id.ed25519PublicKey }); + if (res.status !== 200 || typeof res.body?.nonce !== "string") { + throw new Error(`challenge failed: ${res.status} ${res.text}`); + } + return res.body.nonce; +} + +function signRawNonce(id: Identity, nonceB64: string): string { + return edSign(null, Buffer.from(nonceB64, "base64"), id.edPrivateKey).toString("base64"); +} + +async function authenticate(id: Identity): Promise { + const nonce = await challenge(id); + const res = await req("POST", "/api/auth/keypair/token", { + ed25519PublicKey: id.ed25519PublicKey, + nonce, + signature: signRawNonce(id, nonce), + }); + if (res.status !== 200 || typeof res.body?.token !== "string") { + throw new Error(`token failed: ${res.status} ${res.text}`); + } + return res.body.token; +} + +const SECRET = "ZK-PROBE-SECRET-do-not-leak"; // a marker we encrypt; must never surface server-side + +// ─── The suite ────────────────────────────────────────────────────────────────── + +async function run(): Promise { + const alice = generateIdentity(); + const bob = generateIdentity(); + const carol = generateIdentity(); // never a member + + // §3 Authentication + const aliceNonce = await challenge(alice); + check("§3", "challenge returns a base64 nonce >= 32 bytes", Buffer.from(aliceNonce, "base64").length >= 32); + + // Correct signature → token. + const goodTok = await req("POST", "/api/auth/keypair/token", { + ed25519PublicKey: alice.ed25519PublicKey, + nonce: aliceNonce, + signature: signRawNonce(alice, aliceNonce), + }); + check("§3", "valid signed nonce yields a 200 token", goodTok.status === 200 && typeof goodTok.body?.token === "string"); + const aliceToken: string = goodTok.body?.token; + + // Reused nonce → rejected (single-use, MUST). + const reuse = await req("POST", "/api/auth/keypair/token", { + ed25519PublicKey: alice.ed25519PublicKey, + nonce: aliceNonce, + signature: signRawNonce(alice, aliceNonce), + }); + check("§3", "a reused nonce is rejected (single-use)", reuse.status >= 400, `got ${reuse.status}`); + + // Signing the base64 string instead of the raw nonce bytes → rejected. + const badNonce = await challenge(alice); + const wrongSig = edSign(null, Buffer.from(badNonce, "utf8"), alice.edPrivateKey).toString("base64"); + const badSig = await req("POST", "/api/auth/keypair/token", { + ed25519PublicKey: alice.ed25519PublicKey, + nonce: badNonce, + signature: wrongSig, + }); + check("§3", "signing the base64 string (not raw bytes) is rejected", badSig.status >= 400, `got ${badSig.status}`); + + // Unauthenticated vault call → 401. + const noAuth = await req("POST", "/v1/repos", { manifest: {}, initialEnvelope: {} }); + check("§3", "vault route without a token is 401", noAuth.status === 401, `got ${noAuth.status}`); + + // §6 createRepo + const dataKey = randomBytes(32); + const repoId = randomUUID(); + const altsV1: Alt[] = [{ uuid: randomUUID(), username: "main", accessToken: SECRET, type: "MICROSOFT", lastUsed: 1 }]; + const manifestV1 = { + repoId, + schemeId: WRAP_SCHEME_ID, + keyEpoch: 0, + payloadVersion: 1, + members: [memberEntry(alice, dataKey, 0)], + }; + const created = await req("POST", "/v1/repos", { + manifest: manifestV1, + initialEnvelope: envelope(dataKey, repoId, 1, 0, altsV1), + }, aliceToken); + check("§6", "createRepo returns 200 with a 1-member manifest", + created.status === 200 && created.body?.members?.length === 1, `got ${created.status}`); + + // createRepo where the sole member is not the caller → 403. + const notSelf = await req("POST", "/v1/repos", { + manifest: { ...manifestV1, repoId: randomUUID(), members: [memberEntry(bob, dataKey, 0)] }, + initialEnvelope: envelope(dataKey, repoId, 1, 0, altsV1), + }, aliceToken); + check("§6", "createRepo whose sole member != caller is 403", notSelf.status === 403, `got ${notSelf.status}`); + + // Duplicate repoId → 409. + const dup = await req("POST", "/v1/repos", { + manifest: manifestV1, + initialEnvelope: envelope(dataKey, repoId, 1, 0, altsV1), + }, aliceToken); + check("§6", "duplicate repoId on create is 409", dup.status === 409, `got ${dup.status}`); + + // pull at current version → unchanged, no envelope. + const pullKnown = await req("POST", repoPath(repoId, "pull"), { repoId, knownPayloadVersion: 1 }, aliceToken); + check("§6", "pull at the current version is unchanged with no envelope", + pullKnown.status === 200 && pullKnown.body?.unchanged === true && pullKnown.body?.envelope == null); + + // pull from 0 → envelope present and decrypts to what we stored. + const pullFresh = await req("POST", repoPath(repoId, "pull"), { repoId, knownPayloadVersion: 0 }, aliceToken); + check("§6", "pull from an older version returns the current envelope", + pullFresh.status === 200 && pullFresh.body?.unchanged === false && pullFresh.body?.envelope != null); + await checkThrows("§4", "the returned envelope round-trips back to our plaintext", () => { + const pt = decryptPayload(dataKey, pullFresh.body.envelope as EncryptedEnvelopeFields); + const got = JSON.parse(pt.toString("utf8")) as { alts: Alt[] }; + if (got.alts[0]?.accessToken !== SECRET) { + throw new Error("decrypted payload did not match what we encrypted"); + } + }); + + // §10 Zero-knowledge: the secret we encrypted must not surface anywhere the server returns. + const serverView = JSON.stringify(pullFresh.body); + let secretInCiphertext = false; + for (const field of [pullFresh.body.envelope.ciphertext, pullFresh.body.envelope.iv]) { + if (typeof field === "string" && Buffer.from(field, "base64").includes(Buffer.from(SECRET, "utf8"))) { + secretInCiphertext = true; + } + } + check("§10", "plaintext does not appear in the server's manifest/envelope JSON", !serverView.includes(SECRET)); + check("§10", "plaintext bytes do not appear inside the stored ciphertext/iv", !secretInCiphertext); + + // §6 push with optimistic concurrency + const altsV2: Alt[] = [...altsV1, { uuid: randomUUID(), username: "alt", accessToken: SECRET, type: "MICROSOFT", lastUsed: 2 }]; + const okPush = await req("POST", repoPath(repoId, "push"), { + repoId, + envelope: envelope(dataKey, repoId, 2, 0, altsV2), + expectedPayloadVersion: 1, + }, aliceToken); + check("§6", "push at the expected version is accepted and bumps the version", + okPush.status === 200 && okPush.body?.accepted === true && okPush.body?.payloadVersion === 2); + + // Stale push → 200 conflict, NOT a 4xx. + const stalePush = await req("POST", repoPath(repoId, "push"), { + repoId, + envelope: envelope(dataKey, repoId, 2, 0, altsV2), + expectedPayloadVersion: 1, // stale + }, aliceToken); + check("§6", "a stale push is a 200 with conflict=true (not a 4xx)", + stalePush.status === 200 && stalePush.body?.accepted === false && stalePush.body?.conflict === true, + `got status ${stalePush.status} accepted=${stalePush.body?.accepted} conflict=${stalePush.body?.conflict}`); + + // §6 Authorization: a non-member cannot read the repo. + const carolToken = await authenticate(carol); + const carolPull = await req("POST", repoPath(repoId, "pull"), { repoId, knownPayloadVersion: 0 }, carolToken); + check("§6", "a non-member's pull is 403", carolPull.status === 403, `got ${carolPull.status}`); + + // §6 addMember + fetchMemberKey + const added = await req("POST", repoPath(repoId, "add-member"), { repoId, member: memberEntry(bob, dataKey, 0) }, aliceToken); + check("§6", "addMember returns a manifest now listing both members", + added.status === 200 && added.body?.members?.length === 2); + + const fetched = await req("GET", `/v1/repos/${encodeURIComponent(repoId)}/member/${encodeURIComponent(bob.ed25519PublicKey)}`, + undefined, aliceToken); + check("§6", "fetchMemberKey returns bob's entry (percent-encoded member id)", + fetched.status === 200 && fetched.body?.x25519PublicKey === bob.x25519PublicKey, `got ${fetched.status}`); + + // bob (a real member) pulls, unwraps, decrypts → recovers the same plaintext. + const bobToken = await authenticate(bob); + const bobPull = await req("POST", repoPath(repoId, "pull"), { repoId, knownPayloadVersion: 0 }, bobToken); + const bobOldEntry = bobPull.body?.manifest?.members?.find((m: any) => m.ed25519PublicKey === bob.ed25519PublicKey); + await checkThrows("§4", "a second member unwraps the data key and decrypts the payload", () => { + const bobKey = unwrapDataKey(bob.xPrivateKey, bobOldEntry.wrappedDataKey as WrappedKeyFields); + const pt = decryptPayload(bobKey, bobPull.body.envelope as EncryptedEnvelopeFields); + if (!pt.toString("utf8").includes(SECRET)) { + throw new Error("bob could not recover the payload"); + } + }); + + // §10 Rotation on removeMember: remove bob, rotate to a new epoch + data key. + const newKey = randomBytes(32); + const newEpoch = 1; + const newVersion = (bobPull.body?.manifest?.payloadVersion ?? 2) + 1; + const rotatedEnvelope = envelope(newKey, repoId, newVersion, newEpoch, altsV2); + const removed = await req("POST", repoPath(repoId, "remove-member"), { + repoId, + removedMemberId: bob.ed25519PublicKey, + rotatedEnvelope, + rewrappedMembers: [memberEntry(alice, newKey, newEpoch)], + newKeyEpoch: newEpoch, + }, aliceToken); + check("§10", "removeMember bumps the epoch and drops the removed member", + removed.status === 200 && removed.body?.keyEpoch === newEpoch + && !removed.body?.members?.some((m: any) => m.ed25519PublicKey === bob.ed25519PublicKey)); + + // The removed member can no longer read the repo. + const bobAfter = await req("POST", repoPath(repoId, "pull"), { repoId, knownPayloadVersion: 0 }, bobToken); + check("§6", "the removed member's pull is now 403", bobAfter.status === 403, `got ${bobAfter.status}`); + + // bob's OLD wrapped key recovers the OLD data key, which CANNOT decrypt the new-epoch envelope. + const bobOldKey = unwrapDataKey(bob.xPrivateKey, bobOldEntry.wrappedDataKey as WrappedKeyFields); + check("§10", "the departed member's old key differs from the rotated key", !bobOldKey.equals(newKey)); + await checkThrows("§10", "a stale-epoch key cannot decrypt the rotated envelope", () => { + decryptPayload(bobOldKey, rotatedEnvelope); + }, { expectThrow: true }); + + // alice (still a member) decrypts the rotated envelope with her freshly wrapped key. + const alicePull = await req("POST", repoPath(repoId, "pull"), { repoId, knownPayloadVersion: 0 }, aliceToken); + const aliceNewEntry = alicePull.body?.manifest?.members?.find((m: any) => m.ed25519PublicKey === alice.ed25519PublicKey); + await checkThrows("§10", "a remaining member decrypts the rotated envelope", () => { + const k = unwrapDataKey(alice.xPrivateKey, aliceNewEntry.wrappedDataKey as WrappedKeyFields); + const pt = decryptPayload(k, alicePull.body.envelope as EncryptedEnvelopeFields); + if (!pt.toString("utf8").includes(SECRET)) { + throw new Error("alice could not recover the rotated payload"); + } + }); +} + +// ─── Report ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log(`AVP conformance harness → ${BASE_URL}\n`); + try { + await run(); + } catch (err) { + console.error(`\nHarness aborted (could not complete the flow): ${err instanceof Error ? err.message : err}`); + console.error("Is a conformant server running at the target URL?"); + process.exitCode = 1; + return; + } + + let passed = 0; + for (const r of results) { + const mark = r.ok ? "PASS" : "FAIL"; + const tail = r.ok ? "" : ` — ${r.detail}`; + console.log(` [${mark}] ${r.section.padEnd(4)} ${r.name}${tail}`); + if (r.ok) { + passed += 1; + } + } + const failed = results.length - passed; + console.log(`\n${passed}/${results.length} checks passed${failed ? `, ${failed} FAILED` : ""}.`); + process.exitCode = failed === 0 ? 0 : 1; +} + +void main(); diff --git a/harness/package.json b/harness/package.json new file mode 100644 index 0000000..0714f23 --- /dev/null +++ b/harness/package.json @@ -0,0 +1,16 @@ +{ + "name": "avp-conformance-harness", + "version": "0.0.0", + "private": true, + "description": "Black-box conformance harness: drives the full AVP HTTP/JSON wire contract against a running server and asserts the SPEC MUSTs (auth, optimistic concurrency, authorization, rotation, zero-knowledge).", + "type": "module", + "license": "MIT", + "scripts": { + "conformance": "bun conformance.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/harness/tsconfig.json b/harness/tsconfig.json new file mode 100644 index 0000000..78e1dae --- /dev/null +++ b/harness/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["conformance.ts", "../examples/typescript/client/src/crypto.ts"] +} diff --git a/llms.txt b/llms.txt index 1eca878..7e12229 100644 --- a/llms.txt +++ b/llms.txt @@ -21,6 +21,7 @@ Notes for implementers: field names are normative; do not rename or renumber the - [Worked flow and message bodies](https://raw.githubusercontent.com/trqlmao/avp/main/examples/README.md): An end-to-end walkthrough (create, invite, join, pull, push, rotate) with representative request and response JSON. - [Reference implementations](https://raw.githubusercontent.com/trqlmao/avp/main/examples/README.md): Five runnable reference implementations (Go, TypeScript, Rust, Python, Java), each with a client and in-memory HTTP/JSON server, all implementing the real envelope and key-wrap crypto verified against the conformance vectors. Cross-language wire compatibility is tested by the examples-interop workflow. +- [Conformance harness](https://raw.githubusercontent.com/trqlmao/avp/main/harness/README.md): A black-box conformance suite — point it at any running server and it drives the full wire contract, asserting the normative MUSTs (auth failure modes, optimistic-concurrency conflict, membership authorization, key rotation, zero-knowledge). ## Conformance vectors From 2051322a7a810a74eb8f86729d4808410b77bb0d Mon Sep 17 00:00:00 2001 From: trqlmao Date: Mon, 8 Jun 2026 11:58:55 -0700 Subject: [PATCH 3/4] style: avoid em dashes in prose per house style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md rule: no em dashes. Replace the em dashes introduced with the openapi/error/index/harness work (and one pre-existing in SPEC §6) with commas, colons, semicolons, or parentheses. No semantic change. --- CHANGELOG.md | 2 +- IMPLEMENTING.md | 4 ++-- README.md | 2 +- SPEC.md | 10 +++++----- Taskfile.yml | 4 ++-- harness/conformance.ts | 4 ++-- llms.txt | 6 +++--- openapi.yaml | 8 ++++---- vectors/README.md | 4 ++-- 9 files changed, 22 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a23d0f..6bd5166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ implementation's version. - Added a black-box conformance harness in `harness/`: point it at any running server and it drives the full wire contract, asserting the normative MUSTs (auth failure modes, optimistic-concurrency - conflict, membership authorization, key rotation, and zero-knowledge — the plaintext never surfaces in + conflict, membership authorization, key rotation, and zero-knowledge: the plaintext never surfaces in what the server stores). Reuses the vector-tested reference crypto. - Added a `conformance` CI workflow: reproduces the vectors, lints `openapi.yaml` with Spectral, checks that the schema / example bodies / vector index / OpenAPI `$refs` stay consistent, and runs the diff --git a/IMPLEMENTING.md b/IMPLEMENTING.md index 22505b7..e0ede0b 100644 --- a/IMPLEMENTING.md +++ b/IMPLEMENTING.md @@ -182,7 +182,7 @@ Note: `memberId` in the path is the base64 Ed25519 public key. Because it contai Every non-2xx response body is an error object: `{ "error": "", "code": "", "detail": "" }`. `error` is human-readable (do not parse it); `code` is an optional stable token (`unauthorized`, `forbidden`, `not_found`, `duplicate_repo`, `quota_exceeded`, `policy_denied`, -`bad_request`) you may switch on. Treat all of them as **terminal** — see the concurrency note below for +`bad_request`) you may switch on. Treat all of them as **terminal**; see the concurrency note below for the one outcome that is *not* an error. Optimistic concurrency: `push` returns `{ "accepted": false, "conflict": true }` with the @@ -220,7 +220,7 @@ operation the server performs is verifying the Ed25519 challenge signature in th 5. **Black-box your server.** Point the conformance harness in [`harness/`](harness/) at your running server (`bun harness/conformance.ts --server http://localhost:PORT`). It drives the whole flow and - asserts the normative MUSTs — the auth failure modes, optimistic-concurrency `conflict`, membership + asserts the normative MUSTs: the auth failure modes, optimistic-concurrency `conflict`, membership authorization, key rotation, and zero-knowledge (the plaintext never surfaces in what the server stores). CI runs it against the Go and TypeScript reference servers. diff --git a/README.md b/README.md index 4ff6416..8a321ae 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ conformant. ### Check your server The vectors prove your crypto; the black-box harness in [`harness/`](harness/) proves your *server*. -Point it at any running AVP server and it drives the full wire contract, asserting the normative MUSTs — +Point it at any running AVP server and it drives the full wire contract, asserting the normative MUSTs: the auth failure modes, optimistic-concurrency `conflict`, membership authorization, key rotation, and zero-knowledge: diff --git a/SPEC.md b/SPEC.md index e3fc4d4..0793ec7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -176,7 +176,7 @@ Semantics: **Implementation-defined errors (non-normative).** Beyond the protocol-defined outcomes (optimistic-concurrency `conflict`, *not found*, and membership *permission denied*), a server MAY -reject any operation with an implementation-defined resource or policy error — for example a quota +reject any operation with an implementation-defined resource or policy error, for example a quota limit (repositories per tenant, members per repository) or an operation disallowed by deployment policy. These are distinct from a concurrency `conflict`: a client MUST surface them as terminal failures and MUST NOT retry them as if they were a stale-version conflict. Recommended encodings: @@ -207,7 +207,7 @@ which status each operation can return, is [`openapi.yaml`](openapi.yaml). | `429` | `quota_exceeded` | A deployment resource limit was hit | A client MUST treat every error here as **terminal** and MUST NOT retry it as if it were a -stale-version conflict. The one retryable outcome — optimistic-concurrency conflict — is **not** an +stale-version conflict. The one retryable outcome (optimistic-concurrency conflict) is **not** an error: `push` returns HTTP `200` with `PushResponse { accepted: false, conflict: true }` and the current version (§10). The gRPC profile carries the same outcomes as status codes (`INVALID_ARGUMENT`, `UNAUTHENTICATED`, `PERMISSION_DENIED`, `NOT_FOUND`, `ALREADY_EXISTS`, @@ -299,12 +299,12 @@ A conformant server MUST uphold: An implementation is conformant if it satisfies every MUST above and reproduces the vectors in [`vectors/`](vectors/), indexed by [`vectors/index.json`](vectors/index.json): -- the **deterministic constructions** — the AAD layout (`aad.json`) and the canonical key-binding +- the **deterministic constructions**: the AAD layout (`aad.json`) and the canonical key-binding message (`key-binding-message.json`); -- the **RFC-anchored primitives** — HKDF-SHA256 (`hkdf.json`), X25519 (`x25519.json`), and Ed25519 +- the **RFC-anchored primitives**: HKDF-SHA256 (`hkdf.json`), X25519 (`x25519.json`), and Ed25519 sign/verify (`ed25519.json`). The Ed25519 vector anchors the signature used by the challenge→token flow (§3): signing the raw nonce bytes is exactly this primitive; -- the **envelope compositions** — payload AEAD (`payload-aead.json`) and the key wrap/unwrap +- the **envelope compositions**: payload AEAD (`payload-aead.json`) and the key wrap/unwrap (`key-wrap.json`). The payload-AEAD case includes the epoch-tamper assertion (decryption fails when the AAD's `keyEpoch` changes), which is the cryptographic core of rotation correctness (§10): a stale-epoch envelope cannot authenticate. diff --git a/Taskfile.yml b/Taskfile.yml index 3f7cc72..8cff347 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -2,7 +2,7 @@ # # Uses go-task (https://taskfile.dev): `task --list`. Each target mirrors the matching # .github/workflows/examples-*.yml job, so "green locally" means "green in CI". Each language -# is independent — run only the toolchains you have installed. +# is independent; run only the toolchains you have installed. # # Requirements per target: conformance/typescript -> Bun; go -> Go; python -> Python 3; # rust -> Cargo; java -> a JDK. `task test` runs every target and needs them all. @@ -81,7 +81,7 @@ tasks: - cd examples/java/client && javac Crypto.java CryptoVectors.java Client.java && java CryptoVectors interop: - desc: Cross-language wire interop (informational — runs in CI). + desc: Cross-language wire interop (informational; runs in CI). cmds: - | echo "The cross-language interop matrix (each lang's client -> Go server, Go client ->" diff --git a/harness/conformance.ts b/harness/conformance.ts index e92faa5..3f622ff 100644 --- a/harness/conformance.ts +++ b/harness/conformance.ts @@ -4,7 +4,7 @@ * Point it at any running AVP server and it exercises the full wire contract end to end, asserting the * normative MUSTs from SPEC §3/§6/§10 rather than just printing a transcript: the challenge→token flow * and its failure modes, optimistic concurrency (a stale push is a 200 conflict, never a 4xx), membership - * authorization, key rotation on member removal, and — the whole point — zero-knowledge: the plaintext we + * authorization, key rotation on member removal, and (the whole point) zero-knowledge: the plaintext we * encrypt never appears in anything the server stores or echoes back. * * It is server-agnostic: the only AVP-specific code is the client-side crypto, reused from the @@ -381,7 +381,7 @@ async function main(): Promise { let passed = 0; for (const r of results) { const mark = r.ok ? "PASS" : "FAIL"; - const tail = r.ok ? "" : ` — ${r.detail}`; + const tail = r.ok ? "" : ` (${r.detail})`; console.log(` [${mark}] ${r.section.padEnd(4)} ${r.name}${tail}`); if (r.ok) { passed += 1; diff --git a/llms.txt b/llms.txt index 7e12229..46f7f8c 100644 --- a/llms.txt +++ b/llms.txt @@ -15,17 +15,17 @@ Notes for implementers: field names are normative; do not rename or renumber the - [Specification (SPEC.md)](https://raw.githubusercontent.com/trqlmao/avp/main/SPEC.md): The normative protocol: identity and authentication, the cryptographic envelope, payload and provenance, the transport surface, federation, anti-MITM key binding, invariants, conformance, and security considerations. - [Protobuf schema (avp.proto)](https://raw.githubusercontent.com/trqlmao/avp/main/proto/avp.proto): Canonical gRPC schema with stable field numbers and names. - [JSON Schema (avp.schema.json)](https://raw.githubusercontent.com/trqlmao/avp/main/schema/avp.schema.json): Message shapes for the HTTP/JSON transport profile, including the error body (`$defs/Error`). -- [OpenAPI (openapi.yaml)](https://raw.githubusercontent.com/trqlmao/avp/main/openapi.yaml): OpenAPI 3.1 route description of the HTTP/JSON profile — paths, methods, status codes, and error responses, referencing the JSON Schema shapes. +- [OpenAPI (openapi.yaml)](https://raw.githubusercontent.com/trqlmao/avp/main/openapi.yaml): OpenAPI 3.1 route description of the HTTP/JSON profile: paths, methods, status codes, and error responses, referencing the JSON Schema shapes. ## Examples - [Worked flow and message bodies](https://raw.githubusercontent.com/trqlmao/avp/main/examples/README.md): An end-to-end walkthrough (create, invite, join, pull, push, rotate) with representative request and response JSON. - [Reference implementations](https://raw.githubusercontent.com/trqlmao/avp/main/examples/README.md): Five runnable reference implementations (Go, TypeScript, Rust, Python, Java), each with a client and in-memory HTTP/JSON server, all implementing the real envelope and key-wrap crypto verified against the conformance vectors. Cross-language wire compatibility is tested by the examples-interop workflow. -- [Conformance harness](https://raw.githubusercontent.com/trqlmao/avp/main/harness/README.md): A black-box conformance suite — point it at any running server and it drives the full wire contract, asserting the normative MUSTs (auth failure modes, optimistic-concurrency conflict, membership authorization, key rotation, zero-knowledge). +- [Conformance harness](https://raw.githubusercontent.com/trqlmao/avp/main/harness/README.md): A black-box conformance suite: point it at any running server and it drives the full wire contract, asserting the normative MUSTs (auth failure modes, optimistic-concurrency conflict, membership authorization, key rotation, zero-knowledge). ## Conformance vectors -- [Vectors index (index.json)](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/index.json): Machine-readable index of every vector file — kind, spec section, and RFC anchors — for a harness to enumerate. +- [Vectors index (index.json)](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/index.json): Machine-readable index of every vector file (kind, spec section, and RFC anchors) for a harness to enumerate. - [Vectors overview](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/README.md): How the vectors are organized and which are deterministic versus generated. - [AAD vectors](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/aad.json): Deterministic additional-authenticated-data construction (SPEC section 4). - [Key-binding message vectors](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/key-binding-message.json): Deterministic canonical key-binding message (SPEC section 9). diff --git a/openapi.yaml b/openapi.yaml index dbeaac2..b688783 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -3,13 +3,13 @@ # This is the route-level companion to schema/avp.schema.json (message shapes) and # proto/avp.proto (the canonical gRPC profile). It maps every operation in SPEC §6 to a # path, method, status set, and body, so an HTTP stack can scaffold a client or server and -# render interactive docs. Message schemas are referenced from schema/avp.schema.json — that +# render interactive docs. Message schemas are referenced from schema/avp.schema.json; that # file remains the single source of truth for shapes; do not duplicate them here. # # OpenAPI 3.1 (JSON Schema 2020-12). SPDX-License-Identifier: MIT openapi: 3.1.0 info: - title: Alt Vault Protocol — HTTP/JSON profile + title: Alt Vault Protocol (HTTP/JSON profile) version: "0.2" summary: Zero-knowledge, federated sharing of Minecraft alt accounts. description: | @@ -24,7 +24,7 @@ info: **Conflict is not an error.** A stale-version `push` returns `200` with `PushResponse.conflict = true`, not a 4xx. Every non-2xx body is an - [`Error`](schema/avp.schema.json) and is terminal — never retried as a conflict (SPEC §6). + [`Error`](schema/avp.schema.json) and is terminal; never retried as a conflict (SPEC §6). license: name: MIT url: https://github.com/trqlmao/avp/blob/main/LICENSE @@ -145,7 +145,7 @@ paths: description: | Applies only if `expectedPayloadVersion` equals the current version. A stale write is **not** an error: the response is `200` with `accepted = false`, `conflict = true`, and the current - version — pull, re-apply, and retry. Present `rotatedMembers` to replace the roster atomically + version; pull, re-apply, and retry. Present `rotatedMembers` to replace the roster atomically with the write. parameters: [{ $ref: "#/components/parameters/RepoId" }] requestBody: diff --git a/vectors/README.md b/vectors/README.md index 3998dec..f04fb64 100644 --- a/vectors/README.md +++ b/vectors/README.md @@ -4,8 +4,8 @@ Test vectors an implementation can check itself against. Each file is JSON with or more `cases`. The conformance runner in [`../examples/conformance`](../examples/conformance) loads and checks every file here with Node's `crypto`. -[`index.json`](index.json) is a machine-readable index of these files — each entry gives the file, its -`kind` (deterministic / rfc-primitive / composition), the spec section, and the published RFC anchors — +[`index.json`](index.json) is a machine-readable index of these files: each entry gives the file, its +`kind` (deterministic / rfc-primitive / composition), the spec section, and the published RFC anchors, so a harness can enumerate the vectors instead of hardcoding filenames. ## Deterministic construction vectors From 363fcc64d2488d30264023db54e0696ce85e0f86 Mon Sep 17 00:00:00 2001 From: trqlmao Date: Mon, 8 Jun 2026 11:58:55 -0700 Subject: [PATCH 4/4] docs(claude): document the new wire-surface and conformance artifacts Add openapi.yaml, the HTTP error body ($defs/Error), vectors/index.json, the harness/ black-box suite, the Taskfile runner, and the conformance CI workflow to the AI-agent mental model; list Go among the examples; extend the "one protocol, keep in sync" note to cover openapi.yaml. --- CLAUDE.md | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 164ac94..e611b35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,16 +20,31 @@ repo with Jekyll. The README is the home page. - `proto/avp.proto` is the **canonical** message definition. Field numbers and names are stable; add fields, never renumber or rename them. - `schema/avp.schema.json` mirrors the proto for the HTTP/JSON profile: the same - field names in `camelCase`. Keep it in sync with the proto and SPEC. + field names in `camelCase`. Keep it in sync with the proto and SPEC. It also + holds the HTTP error body (`$defs/Error` = `{ error, code?, detail? }`). +- `openapi.yaml` is the OpenAPI 3.1 description of the HTTP/JSON profile (paths, + methods, status codes, error responses). It does not redefine message shapes: + it `$ref`s them from `schema/avp.schema.json`, which stays the single source of + truth. Keep its routes and status set aligned with SPEC §6. - `vectors/` are conformance test vectors, RFC-anchored and three-way verified (published RFCs + a reference implementation + the Node runner all agree byte-for-byte). **Never edit a vector value without re-verifying all three.** - See `vectors/README.md`. -- `examples/` are illustrative reference clients and servers (TypeScript, Rust, - Python, Java) plus a Node conformance runner. They are not production code. + `vectors/index.json` is a machine-readable index of the files (kind, spec + section, RFC anchors); keep it in sync when adding or removing a vector. See + `vectors/README.md`. +- `examples/` are illustrative reference clients and servers (Go, TypeScript, + Rust, Python, Java) plus a Node conformance runner that also checks + schema/example/index/openapi consistency. They are not production code. +- `harness/` is the black-box conformance suite: point it at any running server + (`bun harness/conformance.ts --server URL`) and it drives the full wire + contract, asserting the SPEC §3/§6/§10 MUSTs (auth failure modes, + optimistic-concurrency conflict, membership authorization, key rotation, + zero-knowledge). It reuses the vector-tested example crypto; it is the + executable check for a *server*, the vectors are the check for the *crypto*. -When you change one of `SPEC.md` / `proto/avp.proto` / `schema/avp.schema.json`, -check whether the other two need the matching change. They describe one protocol. +When you change one of `SPEC.md` / `proto/avp.proto` / `schema/avp.schema.json` / +`openapi.yaml`, check whether the others need the matching change. They describe +one protocol. ## Hard rules @@ -61,9 +76,16 @@ AI). The committer is the responsible author. ## Build and test -- Conformance runner (checks the vectors): `cd examples/conformance && bun install && bun run test`. -- Per-language example CIs live in `.github/workflows/examples-{node,rust,java,python}.yml`; - each builds and tests its example client/server. +- Conformance runner (vectors + schema/example/index/openapi consistency): + `cd examples/conformance && bun install && bun run test`. +- Black-box a server: start one, then `bun harness/conformance.ts --server http://localhost:PORT`. +- One-command local run: `Taskfile.yml` (go-task). `task test` runs the conformance + runner and every language's example tests; `task harness` / `task openapi-lint` run + the server harness and the Spectral lint. Each target mirrors a CI job. +- CI workflows in `.github/workflows/`: per-language `examples-{go,node,rust,java,python}.yml`, + the cross-language `examples-interop.yml`, and `conformance.yml` (vector runner + + Spectral lint of `openapi.yaml` + schema-consistency + the black-box harness against + the Go and TypeScript reference servers). `no-leak.yml` is the vendor-neutrality gate. - The Pages site builds on push (no local step required). If you want to preview it locally and have Ruby, `bundle exec jekyll serve` renders `_layouts/default.html` with `assets/css/style.css`.