From 4e307c9cb6b9085c6b15d12229688b1b92f7ed51 Mon Sep 17 00:00:00 2001 From: trqlmao Date: Mon, 8 Jun 2026 10:40:36 -0700 Subject: [PATCH] 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)"] + } + ] +}