diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index d988aaf..f255899 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4a3f4..1efceb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/CLAUDE.md b/CLAUDE.md index 876c009..c7cea6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/SPEC.md b/SPEC.md index a05899b..ebb87b5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 diff --git a/Taskfile.yml b/Taskfile.yml index 8cff347..b8fed5b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 @@ -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: diff --git a/examples/conformance/test/negative.test.ts b/examples/conformance/test/negative.test.ts new file mode 100644 index 0000000..48a31d6 --- /dev/null +++ b/examples/conformance/test/negative.test.ts @@ -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) { + 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`); + }); +} diff --git a/llms.txt b/llms.txt index 2b37eab..b1d5edc 100644 --- a/llms.txt +++ b/llms.txt @@ -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 diff --git a/vectors/README.md b/vectors/README.md index 5d4352b..bbabe5c 100644 --- a/vectors/README.md +++ b/vectors/README.md @@ -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. diff --git a/vectors/generate.ts b/vectors/generate.ts new file mode 100644 index 0000000..e597672 --- /dev/null +++ b/vectors/generate.ts @@ -0,0 +1,245 @@ +/** + * Reproducible derivation of the AVP conformance vectors. + * + * Two jobs: + * --check (default): re-derive every committed vector's value fields from its documented seeds and + * assert the committed file matches. This is the provenance and drift gate: the vectors are not + * arbitrary bytes, they are exactly what this script computes. Read-only. + * --write: (re)generate vectors/negative.json, the MUST-reject bank, which is derived from the same + * seeds as the positive composition vectors. + * + * The positive vectors (aad, key-binding-message, hkdf, x25519, ed25519, payload-aead, key-wrap, + * federation) are RFC-anchored and cross-verified; this script re-derives and checks them but does not + * rewrite them, so their reviewed prose and formatting are preserved. The negative vectors are owned by + * this script. The crypto is reused from the vector-tested reference at + * ../examples/conformance/src so the generator cannot drift from the runner. + * + * bun vectors/generate.ts --check + * bun vectors/generate.ts --write + * + * SPDX-License-Identifier: MIT + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + aesGcmEncrypt, + ed25519PublicRaw, + ed25519Sign, + hkdfSha256, + importEd25519Private, + importX25519Private, + importX25519Public, + WRAP_INFO, + wrapKek, + x25519, +} from "../examples/conformance/src/crypto.ts"; +import { buildAad, buildAadHex, buildKeyBindingMessage } from "../examples/conformance/src/constructions.ts"; + +const dir = dirname(fileURLToPath(import.meta.url)); +const read = (f: string) => JSON.parse(readFileSync(join(dir, f), "utf8")); + +const hex = (b: Buffer) => b.toString("hex"); +const b64 = (b: Buffer) => b.toString("base64"); +const fromB64 = (s: string) => Buffer.from(s, "base64"); +const fromHex = (s: string) => Buffer.from(s, "hex"); + +// ─── Provenance checks: re-derive committed positive vectors from their seeds ───── + +interface Check { + name: string; + ok: boolean; + detail: string; +} + +function eq(name: string, got: string, want: string, results: Check[]): void { + results.push({ name, ok: got === want, detail: got === want ? "" : `got ${got} want ${want}` }); +} + +function checkPositives(): Check[] { + const r: Check[] = []; + + // aad.json: AAD = UTF8(repoId) || 0x1F || int64BE(version) || int64BE(epoch) + for (const c of read("aad.json").cases) { + eq(`aad ${c.repoId}/${c.payloadVersion}/${c.keyEpoch}`, buildAadHex(c.repoId, c.payloadVersion, c.keyEpoch), c.expectedAadHex, r); + } + + // key-binding-message.json: utf8(ed + "|" + x) + for (const c of read("key-binding-message.json").cases) { + eq(`key-binding ${c.name ?? c.ed25519PublicKey.slice(0, 8)}`, buildKeyBindingMessage(c.ed25519PublicKey, c.x25519PublicKey), c.expectedMessageUtf8, r); + } + + // hkdf.json: HKDF-SHA256 expand output + for (const c of read("hkdf.json").cases) { + const okm = hkdfSha256(fromHex(c.ikmHex), fromHex(c.saltHex), fromHex(c.infoHex), c.length); + eq(`hkdf ${c.name}`, hex(okm), c.okmHex, r); + } + + // x25519.json: raw ECDH output (unhashed) + for (const c of read("x25519.json").cases) { + const out = x25519(importX25519Private(fromHex(c.scalarHex)), importX25519Public(fromHex(c.uCoordinateHex))); + eq(`x25519 ${c.name}`, hex(out), c.outputHex, r); + } + + // ed25519.json: derive public key from seed and reproduce the signature + for (const c of read("ed25519.json").cases) { + const priv = importEd25519Private(fromHex(c.seedHex)); + eq(`ed25519 ${c.name} pubkey`, hex(ed25519PublicRaw(priv)), c.publicKeyHex, r); + eq(`ed25519 ${c.name} sig`, hex(ed25519Sign(priv, fromHex(c.messageHex))), c.signatureHex, r); + } + + // payload-aead.json: AES-256-GCM with the AVP AAD + for (const c of read("payload-aead.json").cases) { + const aad = buildAad(c.repoId, c.payloadVersion, c.keyEpoch); + eq(`payload-aead ${c.name} aad`, hex(aad), c.aadHex, r); + const ct = aesGcmEncrypt(fromB64(c.keyB64), fromB64(c.ivB64), aad, Buffer.from(c.plaintextUtf8, "utf8")); + eq(`payload-aead ${c.name} ciphertext`, b64(ct), c.ciphertextB64, r); + } + + // key-wrap.json: shared secret (ECDH symmetry), KEK, and the wrap ciphertext + for (const c of read("key-wrap.json").cases) { + const shared = x25519(importX25519Private(fromB64(c.recipientPrivateKeyB64)), importX25519Public(fromB64(c.wrappedKey.ephemeralPublicKey))); + eq(`key-wrap ${c.name} shared`, hex(shared), c.sharedSecretHex, r); + const kek = wrapKek(shared, fromB64(c.wrappedKey.ephemeralPublicKey)); + eq(`key-wrap ${c.name} kek`, hex(kek), c.kekHex, r); + const ct = aesGcmEncrypt(kek, fromB64(c.wrappedKey.iv), WRAP_INFO, fromB64(c.dataKeyB64)); + eq(`key-wrap ${c.name} ciphertext`, b64(ct), c.wrappedKey.ciphertext, r); + } + + // federation.json: base64url(canonical minified JSON) round-trips + for (const t of read("federation.json").tokens) { + eq(`federation ${t.name} encode`, Buffer.from(t.canonicalJson, "utf8").toString("base64url"), t.base64url, r); + eq(`federation ${t.name} decode`, JSON.stringify(JSON.parse(Buffer.from(t.base64url, "base64url").toString("utf8"))), JSON.stringify(t.decoded), r); + } + + return r; +} + +// ─── Negative ("MUST reject") bank, derived from the positive seeds ────────────── + +const flipFirst = (b: Buffer) => Buffer.concat([Buffer.from([b[0] ^ 0x01]), b.subarray(1)]); +const flipLast = (b: Buffer) => Buffer.concat([b.subarray(0, b.length - 1), Buffer.from([b[b.length - 1] ^ 0x01])]); +const dropLast = (b: Buffer) => b.subarray(0, b.length - 1); + +function buildNegatives(): any { + const pa = read("payload-aead.json").cases[0]; + const kw = read("key-wrap.json").cases[0]; + const ed = read("ed25519.json").cases.find((c: any) => c.name === "rfc8032-test2"); + const edOther = read("ed25519.json").cases.find((c: any) => c.name === "rfc8032-test3"); + + // Recompute the valid base ciphertexts so the mutations below are self-derived. + const paKey = fromB64(pa.keyB64); + const paIv = fromB64(pa.ivB64); + const paAad = buildAad(pa.repoId, pa.payloadVersion, pa.keyEpoch); + const paCt = aesGcmEncrypt(paKey, paIv, paAad, Buffer.from(pa.plaintextUtf8, "utf8")); + const altKeyB64 = b64(Buffer.alloc(32, 0xff)); // a wrong 32-byte data key + + const ephPub = fromB64(kw.wrappedKey.ephemeralPublicKey); + const recipPriv = fromB64(kw.recipientPrivateKeyB64); + const kwIv = fromB64(kw.wrappedKey.iv); + const kek = wrapKek(x25519(importX25519Private(recipPriv), importX25519Public(ephPub)), ephPub); + const kwCt = aesGcmEncrypt(kek, kwIv, WRAP_INFO, fromB64(kw.dataKeyB64)); + // RFC 7748 §6.1 Bob private scalar, used as a *wrong* recipient key (valid scalar, wrong KEK). + const wrongRecipientB64 = b64(fromHex("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb")); + + const sig = fromHex(ed.signatureHex); + + const payload = (name: string, mutation: string, over: Record) => ({ + name: `payload/${name}`, + op: "payload-decrypt", + mutation, + expect: "reject", + keyB64: pa.keyB64, + ivB64: pa.ivB64, + repoId: pa.repoId, + payloadVersion: pa.payloadVersion, + keyEpoch: pa.keyEpoch, + ciphertextB64: b64(paCt), + ...over, + }); + + const wrap = (name: string, mutation: string, over: Record) => ({ + name: `key-wrap/${name}`, + op: "key-unwrap", + mutation, + expect: "reject", + recipientPrivateKeyB64: kw.recipientPrivateKeyB64, + ephemeralPublicKeyB64: kw.wrappedKey.ephemeralPublicKey, + ivB64: kw.wrappedKey.iv, + ciphertextB64: b64(kwCt), + ...over, + }); + + const verify = (name: string, mutation: string, over: Record) => ({ + name: `ed25519/${name}`, + op: "ed25519-verify", + mutation, + expect: "reject", + publicKeyHex: ed.publicKeyHex, + messageHex: ed.messageHex, + signatureHex: ed.signatureHex, + ...over, + }); + + return { + description: + "MUST-reject vectors. Each case starts from a valid construction (the seeds of payload-aead.json, key-wrap.json, and ed25519.json) and applies exactly one mutation; a conformant implementation MUST reject it. 'op' is the operation: 'payload-decrypt' (AES-256-GCM with the AVP AAD) and 'key-unwrap' (X25519-HKDF-SHA256-AESGCM-v1) MUST fail authentication; 'ed25519-verify' MUST return false. Wire base64 strictness is implementation-specific and intentionally not tested here. Generated by vectors/generate.ts from the same seeds as the positive vectors.", + cases: [ + payload("flipped-tag", "flip the last byte of the ciphertext (inside the GCM tag)", { ciphertextB64: b64(flipLast(paCt)) }), + payload("bit-flipped-body", "flip a bit in the first ciphertext byte", { ciphertextB64: b64(flipFirst(paCt)) }), + payload("truncated-1-byte", "drop the last ciphertext byte", { ciphertextB64: b64(dropLast(paCt)) }), + payload("missing-tag", "drop the whole 16-byte tag, leaving only the ciphertext body", { ciphertextB64: b64(paCt.subarray(0, paCt.length - 16)) }), + payload("wrong-aad-repoId", "decrypt the valid ciphertext under an AAD with a different repoId", { repoId: "repo-aead-2" }), + payload("wrong-aad-version", "decrypt the valid ciphertext under an AAD with a different payloadVersion", { payloadVersion: pa.payloadVersion + 1 }), + payload("wrong-aad-epoch", "decrypt the valid ciphertext under an AAD with a different keyEpoch (rollback)", { keyEpoch: pa.keyEpoch + 1 }), + payload("wrong-key", "decrypt the valid ciphertext under a different data key", { keyB64: altKeyB64 }), + + wrap("flipped-tag", "flip the last byte of the wrap ciphertext (inside the GCM tag)", { ciphertextB64: b64(flipLast(kwCt)) }), + wrap("tampered-body", "flip a bit in the first wrap-ciphertext byte", { ciphertextB64: b64(flipFirst(kwCt)) }), + wrap("truncated-1-byte", "drop the last wrap-ciphertext byte", { ciphertextB64: b64(dropLast(kwCt)) }), + wrap("wrong-recipient-key", "unwrap with a different recipient private key (wrong KEK)", { recipientPrivateKeyB64: wrongRecipientB64 }), + wrap("wrong-ephemeral-key", "unwrap against a different ephemeral public key (wrong shared secret)", { ephemeralPublicKeyB64: kw.recipientPublicKeyB64 }), + + verify("flipped-signature", "flip the first signature byte", { signatureHex: hex(flipFirst(sig)) }), + verify("wrong-message", "verify the valid signature against a different message", { messageHex: "ff" }), + verify("wrong-public-key", "verify the valid signature under a different public key", { publicKeyHex: edOther.publicKeyHex }), + ], + }; +} + +// ─── Entry point ──────────────────────────────────────────────────────────────── + +const write = process.argv.includes("--write"); + +const positives = checkPositives(); +const negatives = buildNegatives(); + +let failed = positives.filter((c) => !c.ok); + +if (write) { + writeFileSync(join(dir, "negative.json"), JSON.stringify(negatives, null, 2) + "\n"); + console.log(`Wrote negative.json (${negatives.cases.length} cases).`); +} else { + const committed = JSON.stringify(read("negative.json")); + const regenerated = JSON.stringify(negatives); + if (committed !== regenerated) { + failed = failed.concat([{ name: "negative.json matches generator output", ok: false, detail: "run `bun vectors/generate.ts --write`" }]); + } +} + +for (const c of positives) { + console.log(` [${c.ok ? "OK" : "DRIFT"}] ${c.name}${c.ok ? "" : ` (${c.detail})`}`); +} +if (!write) { + const negOk = failed.every((f) => f.name !== "negative.json matches generator output"); + console.log(` [${negOk ? "OK" : "DRIFT"}] negative.json matches generator output`); +} + +if (failed.length > 0) { + console.error(`\n${failed.length} vector(s) drifted from their derivation. ${write ? "" : "Re-run with --write if the change is intended."}`); + process.exitCode = 1; +} else { + console.log(`\nAll ${positives.length} positive vectors reproduce from their seeds; negative bank is ${write ? "written" : "in sync"}.`); +} diff --git a/vectors/index.json b/vectors/index.json index 57147d0..8a8a419 100644 --- a/vectors/index.json +++ b/vectors/index.json @@ -4,7 +4,8 @@ "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." + "composition": "Full AVP envelope composition with fixed keys/IVs; reproduce and round-trip (decrypt/unwrap), including the documented failure assertion.", + "negative": "A valid construction with one mutation that a conformant implementation MUST reject." }, "vectors": [ { @@ -82,6 +83,15 @@ "expects": "decoding base64url yields the object (decode oracle); canonical minified encoding round-trips; avp:// URIs parse to {host, repoId} and format back", "shape": "uses top-level `tokens` and `uris` arrays, not `cases`", "anchors": ["RFC 4648 §5 (base64url)"] + }, + { + "id": "negative", + "file": "negative.json", + "kind": "negative", + "specSection": "4", + "construction": "valid payload-aead / key-wrap / ed25519 constructions with one mutation each (flipped tag, bit flip, truncation, wrong AAD repoId/version/epoch, wrong key, wrong recipient/ephemeral key, wrong message/public key)", + "expects": "each case MUST be rejected: payload-decrypt and key-unwrap fail authentication, ed25519-verify returns false", + "generatedBy": "vectors/generate.ts (from the same seeds as the positive vectors)" } ] } diff --git a/vectors/negative.json b/vectors/negative.json new file mode 100644 index 0000000..b21fd73 --- /dev/null +++ b/vectors/negative.json @@ -0,0 +1,178 @@ +{ + "description": "MUST-reject vectors. Each case starts from a valid construction (the seeds of payload-aead.json, key-wrap.json, and ed25519.json) and applies exactly one mutation; a conformant implementation MUST reject it. 'op' is the operation: 'payload-decrypt' (AES-256-GCM with the AVP AAD) and 'key-unwrap' (X25519-HKDF-SHA256-AESGCM-v1) MUST fail authentication; 'ed25519-verify' MUST return false. Wire base64 strictness is implementation-specific and intentionally not tested here. Generated by vectors/generate.ts from the same seeds as the positive vectors.", + "cases": [ + { + "name": "payload/flipped-tag", + "op": "payload-decrypt", + "mutation": "flip the last byte of the ciphertext (inside the GCM tag)", + "expect": "reject", + "keyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "ivB64": "CgsMDQ4PAAECAwQF", + "repoId": "repo-aead-1", + "payloadVersion": 7, + "keyEpoch": 2, + "ciphertextB64": "1FyqKaTft8CoC/t3T5pbS4qy5UjvxYcTc31QKQRdgVyfadaPbG0QT6IQ3KgVsQ==" + }, + { + "name": "payload/bit-flipped-body", + "op": "payload-decrypt", + "mutation": "flip a bit in the first ciphertext byte", + "expect": "reject", + "keyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "ivB64": "CgsMDQ4PAAECAwQF", + "repoId": "repo-aead-1", + "payloadVersion": 7, + "keyEpoch": 2, + "ciphertextB64": "1VyqKaTft8CoC/t3T5pbS4qy5UjvxYcTc31QKQRdgVyfadaPbG0QT6IQ3KgVsA==" + }, + { + "name": "payload/truncated-1-byte", + "op": "payload-decrypt", + "mutation": "drop the last ciphertext byte", + "expect": "reject", + "keyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "ivB64": "CgsMDQ4PAAECAwQF", + "repoId": "repo-aead-1", + "payloadVersion": 7, + "keyEpoch": 2, + "ciphertextB64": "1FyqKaTft8CoC/t3T5pbS4qy5UjvxYcTc31QKQRdgVyfadaPbG0QT6IQ3KgV" + }, + { + "name": "payload/missing-tag", + "op": "payload-decrypt", + "mutation": "drop the whole 16-byte tag, leaving only the ciphertext body", + "expect": "reject", + "keyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "ivB64": "CgsMDQ4PAAECAwQF", + "repoId": "repo-aead-1", + "payloadVersion": 7, + "keyEpoch": 2, + "ciphertextB64": "1FyqKaTft8CoC/t3T5pbS4qy5UjvxYcTc31QKQRd" + }, + { + "name": "payload/wrong-aad-repoId", + "op": "payload-decrypt", + "mutation": "decrypt the valid ciphertext under an AAD with a different repoId", + "expect": "reject", + "keyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "ivB64": "CgsMDQ4PAAECAwQF", + "repoId": "repo-aead-2", + "payloadVersion": 7, + "keyEpoch": 2, + "ciphertextB64": "1FyqKaTft8CoC/t3T5pbS4qy5UjvxYcTc31QKQRdgVyfadaPbG0QT6IQ3KgVsA==" + }, + { + "name": "payload/wrong-aad-version", + "op": "payload-decrypt", + "mutation": "decrypt the valid ciphertext under an AAD with a different payloadVersion", + "expect": "reject", + "keyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "ivB64": "CgsMDQ4PAAECAwQF", + "repoId": "repo-aead-1", + "payloadVersion": 8, + "keyEpoch": 2, + "ciphertextB64": "1FyqKaTft8CoC/t3T5pbS4qy5UjvxYcTc31QKQRdgVyfadaPbG0QT6IQ3KgVsA==" + }, + { + "name": "payload/wrong-aad-epoch", + "op": "payload-decrypt", + "mutation": "decrypt the valid ciphertext under an AAD with a different keyEpoch (rollback)", + "expect": "reject", + "keyB64": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "ivB64": "CgsMDQ4PAAECAwQF", + "repoId": "repo-aead-1", + "payloadVersion": 7, + "keyEpoch": 3, + "ciphertextB64": "1FyqKaTft8CoC/t3T5pbS4qy5UjvxYcTc31QKQRdgVyfadaPbG0QT6IQ3KgVsA==" + }, + { + "name": "payload/wrong-key", + "op": "payload-decrypt", + "mutation": "decrypt the valid ciphertext under a different data key", + "expect": "reject", + "keyB64": "//////////////////////////////////////////8=", + "ivB64": "CgsMDQ4PAAECAwQF", + "repoId": "repo-aead-1", + "payloadVersion": 7, + "keyEpoch": 2, + "ciphertextB64": "1FyqKaTft8CoC/t3T5pbS4qy5UjvxYcTc31QKQRdgVyfadaPbG0QT6IQ3KgVsA==" + }, + { + "name": "key-wrap/flipped-tag", + "op": "key-unwrap", + "mutation": "flip the last byte of the wrap ciphertext (inside the GCM tag)", + "expect": "reject", + "recipientPrivateKeyB64": "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=", + "ephemeralPublicKeyB64": "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=", + "ivB64": "qrvM3e7/ABEiM0RV", + "ciphertextB64": "PM93je2VfKwFXs8G6RCrrcRGjIr6C/+a4cz16NxRnZbTMbXNxyvWDPXbU4Xj+dH6" + }, + { + "name": "key-wrap/tampered-body", + "op": "key-unwrap", + "mutation": "flip a bit in the first wrap-ciphertext byte", + "expect": "reject", + "recipientPrivateKeyB64": "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=", + "ephemeralPublicKeyB64": "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=", + "ivB64": "qrvM3e7/ABEiM0RV", + "ciphertextB64": "Pc93je2VfKwFXs8G6RCrrcRGjIr6C/+a4cz16NxRnZbTMbXNxyvWDPXbU4Xj+dH7" + }, + { + "name": "key-wrap/truncated-1-byte", + "op": "key-unwrap", + "mutation": "drop the last wrap-ciphertext byte", + "expect": "reject", + "recipientPrivateKeyB64": "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=", + "ephemeralPublicKeyB64": "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=", + "ivB64": "qrvM3e7/ABEiM0RV", + "ciphertextB64": "PM93je2VfKwFXs8G6RCrrcRGjIr6C/+a4cz16NxRnZbTMbXNxyvWDPXbU4Xj+dE=" + }, + { + "name": "key-wrap/wrong-recipient-key", + "op": "key-unwrap", + "mutation": "unwrap with a different recipient private key (wrong KEK)", + "expect": "reject", + "recipientPrivateKeyB64": "XasIfmJKikt54X+Lg4AO5m87sSkmGLb9HC+LJ/+I4Os=", + "ephemeralPublicKeyB64": "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=", + "ivB64": "qrvM3e7/ABEiM0RV", + "ciphertextB64": "PM93je2VfKwFXs8G6RCrrcRGjIr6C/+a4cz16NxRnZbTMbXNxyvWDPXbU4Xj+dH7" + }, + { + "name": "key-wrap/wrong-ephemeral-key", + "op": "key-unwrap", + "mutation": "unwrap against a different ephemeral public key (wrong shared secret)", + "expect": "reject", + "recipientPrivateKeyB64": "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=", + "ephemeralPublicKeyB64": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=", + "ivB64": "qrvM3e7/ABEiM0RV", + "ciphertextB64": "PM93je2VfKwFXs8G6RCrrcRGjIr6C/+a4cz16NxRnZbTMbXNxyvWDPXbU4Xj+dH7" + }, + { + "name": "ed25519/flipped-signature", + "op": "ed25519-verify", + "mutation": "flip the first signature byte", + "expect": "reject", + "publicKeyHex": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", + "messageHex": "72", + "signatureHex": "93a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" + }, + { + "name": "ed25519/wrong-message", + "op": "ed25519-verify", + "mutation": "verify the valid signature against a different message", + "expect": "reject", + "publicKeyHex": "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", + "messageHex": "ff", + "signatureHex": "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" + }, + { + "name": "ed25519/wrong-public-key", + "op": "ed25519-verify", + "mutation": "verify the valid signature under a different public key", + "expect": "reject", + "publicKeyHex": "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "messageHex": "72", + "signatureHex": "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00" + } + ] +}