Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
run: bun run --cwd examples/conformance typecheck
- name: Reproduce vectors and check artifact consistency
run: bun run --cwd examples/conformance test
- name: Re-derive vectors from seeds (provenance and drift gate)
run: bun vectors/generate.ts --check

openapi:
name: openapi lint
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ implementation's version.

### Repository

- Added `vectors/negative.json`, a MUST-reject bank, and a conformance test for it: valid payload-aead,
key-wrap, and ed25519 constructions with one mutation each (flipped tag, bit flip, truncation, missing
tag, wrong AAD repoId/version/epoch, wrong key, wrong recipient/ephemeral wrap key, wrong message/public
key). Each MUST be rejected; reproducing the positive vectors is necessary but not sufficient.
- Added `vectors/generate.ts`, a reproducible derivation of every vector from documented seeds.
`--check` re-derives the committed positive vectors and asserts they match (the provenance and drift
gate, run in CI and by `task test`), and owns `negative.json` via `--write`. It reuses the vector-tested
reference crypto so it cannot drift from the runner.
- Added `vectors/federation.json` and a conformance test for it: the base64url join-handshake tokens
(invite request, repo locator) as a decode oracle plus canonical-encode round-trip, and `avp://`
repository URI parse/format (SPEC section 8). Indexed in `vectors/index.json`.
Expand Down
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ repo with Jekyll. The README is the home page.
`vectors/README.md`. `vectors/federation.json` is the exception to the crypto
pattern: base64url join-handshake tokens and `avp://` URI vectors (SPEC §8),
deterministic encoding rather than crypto, using `tokens`/`uris` arrays not
`cases`.
`cases`. `vectors/negative.json` is the MUST-reject bank (tampered/truncated/
wrong-key/wrong-AAD cases that decryption, unwrap, or verify MUST reject).
`vectors/generate.ts` re-derives every vector from documented seeds:
`bun vectors/generate.ts --check` is the provenance/drift gate (CI runs it; it
also owns `negative.json` via `--write`) and does not rewrite the reviewed
positive files.
- `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.
Expand Down
8 changes: 7 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,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;
- the **federation encodings**: the join-handshake tokens (§8.1) and `avp://` URIs (§8) in
`federation.json`, base64url and JSON constructions with no key material.
`federation.json`, base64url and JSON constructions with no key material;
- the **negative cases**: `negative.json`, valid constructions with one mutation each that a conformant
implementation MUST reject (payload-decrypt and key-unwrap fail authentication, ed25519-verify returns
false). Reproducing the positive vectors is necessary but not sufficient.

Every committed vector is reproducible from documented seeds by [`vectors/generate.ts`](vectors/generate.ts)
(`--check` re-derives and asserts them; it also owns `negative.json`).

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
Expand Down
6 changes: 6 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ tasks:
desc: Run the conformance suite and every language's example tests.
cmds:
- task: conformance
- task: vectors-check
- task: go
- task: typescript
- task: python
Expand All @@ -33,6 +34,11 @@ tasks:
- bun run typecheck
- bun run test

vectors-check:
desc: Re-derive every vector from its documented seeds (provenance and drift gate).
cmds:
- bun vectors/generate.ts --check

openapi-lint:
desc: Lint openapi.yaml with Spectral.
cmds:
Expand Down
62 changes: 62 additions & 0 deletions examples/conformance/test/negative.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Negative ("MUST reject") vectors (vectors/negative.json). Each case is a valid construction with one
* mutation that a conformant implementation MUST reject: payload-decrypt and key-unwrap MUST fail
* authentication (the AEAD throws), and ed25519-verify MUST return false. This guards against an
* implementation that "succeeds" on tampered, truncated, replayed, or wrong-key inputs.
*
* SPDX-License-Identifier: MIT
*/

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

import {
aesGcmDecrypt,
ed25519Verify,
importEd25519Public,
importX25519Private,
importX25519Public,
WRAP_INFO,
wrapKek,
x25519,
} from "../src/crypto.ts";
import { buildAad } from "../src/constructions.ts";

const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..", "..", "..");
const negative = JSON.parse(readFileSync(join(root, "vectors", "negative.json"), "utf8"));

const fromB64 = (s: string) => Buffer.from(s, "base64");
const fromHex = (s: string) => Buffer.from(s, "hex");

for (const c of negative.cases as Array<any>) {
test(`negative ${c.name} (${c.mutation}) is rejected`, () => {
if (c.op === "ed25519-verify") {
let verified = false;
try {
verified = ed25519Verify(importEd25519Public(fromHex(c.publicKeyHex)), fromHex(c.messageHex), fromHex(c.signatureHex));
} catch {
verified = false; // a malformed key/signature that throws is also a rejection
}
assert.equal(verified, false, "tampered signature must not verify");
return;
}

assert.throws(() => {
if (c.op === "payload-decrypt") {
const aad = buildAad(c.repoId, c.payloadVersion, c.keyEpoch);
aesGcmDecrypt(fromB64(c.keyB64), fromB64(c.ivB64), aad, fromB64(c.ciphertextB64));
} else if (c.op === "key-unwrap") {
const ephPub = fromB64(c.ephemeralPublicKeyB64);
const shared = x25519(importX25519Private(fromB64(c.recipientPrivateKeyB64)), importX25519Public(ephPub));
const kek = wrapKek(shared, ephPub);
aesGcmDecrypt(kek, fromB64(c.ivB64), WRAP_INFO, fromB64(c.ciphertextB64));
} else {
throw new Error(`unknown op ${c.op}`);
}
}, `op ${c.op} must reject this case`);
});
}
1 change: 1 addition & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Notes for implementers: field names are normative; do not rename or renumber the
- [Payload AEAD vectors](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/payload-aead.json): AES-256-GCM payload encryption with the AVP AAD composition.
- [Key-wrap vectors](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/key-wrap.json): Full X25519-HKDF-SHA256-AESGCM-v1 wrap composition anchored to RFC 7748.
- [Federation vectors](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/federation.json): Base64url join-handshake tokens (invite request, repo locator) and avp:// repository URIs (SPEC section 8); decode oracle plus canonical-encode and URI parse/format.
- [Negative vectors](https://raw.githubusercontent.com/trqlmao/avp/main/vectors/negative.json): MUST-reject bank (flipped tag, truncation, wrong AAD repoId/version/epoch, wrong key, wrong recipient/ephemeral key, wrong message/public key); a conformant implementation rejects every case.

## Project

Expand Down
22 changes: 22 additions & 0 deletions vectors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,25 @@ independent sources agreeing byte-for-byte** before being committed:
An implementation proves conformance by reproducing the deterministic and RFC-anchored vectors exactly,
and by round-tripping the composition vectors (decrypting/unwrapping what a peer encrypted/wrapped,
verifying what a peer signed).

## Negative vectors

- [`negative.json`](negative.json), a bank of MUST-reject cases. Each case starts from a valid
construction (the seeds of `payload-aead.json`, `key-wrap.json`, and `ed25519.json`) and applies one
mutation: a flipped GCM tag, a flipped body bit, truncation, a missing tag, a wrong AAD repoId /
payloadVersion / keyEpoch, a wrong data key, a wrong recipient or ephemeral wrap key, a wrong message,
or a wrong public key. A conformant implementation MUST reject every case: `payload-decrypt` and
`key-unwrap` fail authentication, and `ed25519-verify` returns false. Passing the positive vectors is
not enough; an implementation that accepts a tampered or replayed envelope is not conformant. Wire
base64 strictness is implementation-specific and is intentionally not tested here.

## Regenerating the vectors

[`generate.ts`](generate.ts) is the reproducible derivation of these files. It reuses the vector-tested
reference crypto so it cannot drift from the runner.

- `bun vectors/generate.ts --check` re-derives every committed positive vector's value fields from its
documented seeds and asserts the committed file matches, then asserts `negative.json` equals the
generator's output. This is the provenance and drift gate; CI runs it. It does not rewrite the
RFC-anchored and cross-verified positive files (their reviewed prose and formatting are preserved).
- `bun vectors/generate.ts --write` regenerates `negative.json`, which the generator owns.
Loading
Loading