@ziffer-io/verify 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +57 -107
  2. package/package.json +12 -9
package/README.md CHANGED
@@ -1,121 +1,71 @@
1
1
  # @ziffer-io/verify
2
2
 
3
- The **third implementation** of ACP receipt verification. The first two live in the
4
- engine repository at the pin this workspace's `Cargo.toml` names: the Python reference
5
- (`reference/src/acp_executor.py`, the readable §9.3 checklist) and Rust
6
- (`crates/acp-decision`, the production decision path). This package lives in ziffer
7
- **only because the engine repository is publicly frozen and ziffer CI cannot reach new
8
- engine commits**; the mirrored corpus in `fixtures/` plus `tools/check-verify-mirror.py`
9
- is what keeps three copies of one rule honest. Engine-home for this package is revisited
10
- if the freeze lifts.
3
+ Verify a ZIFFER decision receipt in your own process, against a key you configured yourself.
4
+
5
+ ## Install
11
6
 
12
7
  ```bash
13
8
  npm install @ziffer-io/verify
14
9
  ```
15
10
 
16
- Most integrations do not install this directly. `@ziffer-io/client` depends on it and re-exports
17
- `verifyReceipt`, so there is one verifier in your dependency tree rather than two that can
18
- disagree. Install it on its own when you verify receipts and never call the API.
11
+ Node 22 or later. If you also call the ZIFFER API, install `@ziffer-io/client` instead. It
12
+ depends on this package and re-exports `verifyReceipt`, so one verifier is in your tree.
19
13
 
20
- ## What it does
14
+ ## Quickstart
21
15
 
22
16
  ```ts
23
- import { verifyReceipt, Refusal } from '@ziffer-io/verify';
24
-
25
- const verified = verifyReceipt(receiptJson, proposalBytes, {
26
- classical, // Ed25519 verification key, 32 bytes, from the signed bundle (PB-12)
27
- pq, // ML-DSA-65 verification key, 1,952 bytes, same home
28
- minSuite: 'hybrid-ed25519-mldsa65', // CR-4 floor, from the signed manifest
29
- });
30
- // throws Refusal on any defect; verified.proposalHash is the verifier's OWN hash
17
+ import { readFileSync } from 'node:fs';
18
+ import { verifyReceipt, type TrustAnchor } from '@ziffer-io/verify';
19
+
20
+ const keys = JSON.parse(readFileSync(process.env.ZIFFER_TRUST_ANCHOR, 'utf8'));
21
+ const anchor: TrustAnchor = {
22
+ classical: Buffer.from(keys.ed25519_pk_hex, 'hex'),
23
+ pq: Buffer.from(keys.mldsa65_pk_hex, 'hex'),
24
+ minSuite: process.env.ZIFFER_SUITE_FLOOR,
25
+ };
26
+
27
+ const proposalBytes = new TextEncoder().encode(JSON.stringify(proposal));
28
+ const verified = verifyReceipt(receipt, proposalBytes, anchor);
29
+ // It returned, so the receipt holds. verified.proposalHash is this verifier's own hash.
30
+ await bank.transfer(amount, toAccount); // your line, unchanged
31
31
  ```
32
32
 
33
- `verifyReceipt` runs exactly the **stateless** portion of the §9.3 checklist — the part
34
- answerable with only receipt + proposal + identity: AB-0 (receipt version 3 only),
35
- AB-5/AB-6 (signed-body exclusion and byte cap), CR-1/CR-4/CR-3 (suite known, floor by
36
- containment, hybrid **conjunctive** — every primitive must verify), 9.3-2 (decision
37
- domain), 9.3-3 (proposal hash **recomputed** from the caller's own bytes, never read
38
- from the message — RES-8), 9.3-5/L-14 (temporal position and window ceiling, over
39
- strictly parsed RFC 3339 UTC instants), WE-4/L-17 (nonce type and size).
40
-
41
- A pass means "the stateless set found nothing", never "the receipt may be consumed":
42
- steps 4 (policy basis), 6's CL-2 claim, 7 (grading recomputation), 7b (the quorum),
43
- 8 (tenant scoping) and 9–10 need the signed bundle, the attester registry, a ledger and
44
- a context store, and are deliberately absent rather than approximated. The module doc in
45
- `src/verify.ts` carries the full table and the R/B/T classification.
46
-
47
- Every refusal is a thrown `Refusal` whose `clause` is spelled **exactly as the engine's
48
- Rust clause constants** spell it (`AB-0`, `AB-6`, `CR-1`, `CR-4`, `AT-8a`, `9.3-1`,
49
- `9.3-2`, `9.3-3`, `9.3-5`, `L-14`, `WE-4`, `L-17`) — the clause is what the three
50
- implementations are compared on.
51
-
52
- ## What it does not do
53
-
54
- - **It is not a receipt gate.** A pass is "the stateless set found nothing", not "this receipt may
55
- be consumed" — the paragraph above lists the six §9.3 steps that need a signed bundle, an
56
- attester registry, a ledger and a context store, and are absent rather than approximated.
57
- - **It fetches nothing and trusts nothing you did not hand it.** The identity keys and the CR-4
58
- suite floor are arguments. It will not read them from the receipt, because a verifier that takes
59
- a derived security value from the party it is verifying is the defect class this specification
60
- has published five corrections for (RES-8).
61
- - **It does not hash your proposal for you.** You pass the bytes your own code passes to the API,
62
- and the verifier recomputes the hash from them. Handing it the hash would verify nothing.
63
- - **It has no `approve`, and no test mode that mints a receipt.** Signing lives behind keys this
64
- package never holds.
65
-
66
- ## Strict Ed25519, written out
67
-
68
- `@noble/curves`' `verify` with `{ zip215: false }` enforces canonical encodings but —
69
- like OpenSSL — does **not** refuse small-order points. The ACP-106 rule (a small-order
70
- public key or a small-order `R` makes the verification equation stop mentioning the
71
- message) is therefore written out in `src/ed25519.ts`, exactly as the Python reference
72
- writes it in `verify_prim` and Rust gets from `verify_strict`. The mirrored
73
- `fixtures/refused_ed25519.json` is the executable statement: every vector is accepted by
74
- RFC 8032 as OpenSSL implements it and refused by all three implementations. Deleting the
75
- guard here was observed to accept the corpus's `r_is_the_identity_point` vector.
76
-
77
- ## Canonicalization, proven not transcribed
78
-
79
- `src/canon.ts` reproduces the engine canon (sorted keys at every depth by **code
80
- point** — not JS's UTF-16 unit order — no whitespace, raw UTF-8). The proof is a
81
- triangle: `fixtures/canon_vectors.json` was generated by importing and executing the
82
- engine's own `acp_executor.canon` (`fixtures/gen_canon_vectors.py`), the TS tests assert
83
- byte-identity with the fixture on every case, and `tools/check-verify-mirror.py`
84
- re-derives every case against the engine at the pin.
85
-
86
- **Disclosed divergence (fail-closed):** the engine accepts a float *nested* in a signed
87
- structure (the ACP-75 bug-for-bug agreement between Python and Rust). This
88
- implementation refuses any non-integer number at any depth under `AT-8a`, because
89
- `JSON.parse` collapses `1000.0` and `1000` into one value and reproducing the engine's
90
- bytes would be a guess about bytes it never saw. Integers beyond `2^53−1` are refused
91
- for the same reason (the ACP-54 integer-domain family reached from the JS side). No
92
- conforming wire receipt carries either — every temporal field is an RFC 3339 string
93
- since v1.3.28 (ACP-167). Both refusals are pinned by tests so the divergence cannot
94
- vanish silently.
95
-
96
- ## Fixtures
97
-
98
- Byte-identical mirrors of engine files, each with a `.provenance.json` sidecar (source
99
- path, rev, sha256 — a header *inside* the JSON would change the bytes the identity claim
100
- is about): `refused_ed25519.json`, `python_signatures.json` (the reference's signatures,
101
- which this side must accept — the TS leg of `tests/python_interop.rs`' claim; its seeds
102
- also pin the test-key derivation mirror in `src/testkeys.ts`), and
103
- `instant-type-vectors.json` (this package is the shared instant corpus's fourth
104
- consumer). Plus the derived `canon_vectors.json` above. Re-mirror at a pin bump;
105
- `tools/check-verify-mirror.py` refuses a mirror whose provenance rev is not the
106
- workspace pin.
107
-
108
- ## Running
109
-
110
- ```sh
111
- pnpm --filter @ziffer-io/verify test # tsc -b && node --test (27 tests)
112
- python3 tools/check-verify-mirror.py # from the repo root; needs ACP_REPO_PATH at the pin
113
- ```
33
+ It reaches no network and trusts nothing it was not handed. The keys and the suite floor are
34
+ arguments you pass in. The proposal hash is recomputed from your bytes, never read from the
35
+ receipt.
36
+ Key order and spacing do not matter, because the verifier canonicalises your bytes itself.
37
+
38
+ `verifyReceipt` checks the answer. Your own `if` is what stops the action.
39
+
40
+ ## Configuration
41
+
42
+ | Variable | What it is | Where the value comes from |
43
+ | --- | --- | --- |
44
+ | `ZIFFER_TRUST_ANCHOR` | Path to the public key file your receipts are signed under. | We give you the file. Take it from us, never from the API you are checking. |
45
+ | `ZIFFER_SUITE_FLOOR` | The weakest signature suite you will accept. | You choose it. There is no default. |
46
+
47
+ ## When a request is refused
48
+
49
+ Every refusal is a thrown `Refusal` whose `clause` names the rule that fired; the table of every
50
+ clause, what it means and what to do is at https://ziffer.io/docs/refusals. Narrow with
51
+ `instanceof Refusal`, record the clause, and do not retry it.
52
+
53
+ ## Documentation
54
+
55
+ - Quickstart: https://ziffer.io/docs/quickstart
56
+ - Integrating the SDK: https://ziffer.io/docs/developers/sdk
57
+ - Every refusal: https://ziffer.io/docs/refusals
58
+ - The specification: https://ziffer.io/docs/specification
59
+ - Glossary: https://ziffer.io/docs/glossary
60
+
61
+ ## Support
62
+
63
+ Write to hello@ziffer.io. Your trust anchor file and your suite floor come from us. So does an
64
+ answer about a refusal you cannot explain.
114
65
 
115
- ## Licence
66
+ ## License
116
67
 
117
- Proprietary. Copyright (c) 2026 code75 SASU, Paris, France. ZIFFER is a registered trademark of code75
118
- SASU. This package is **not open source**: it is licensed for use with the ZIFFER service under
119
- your agreement with code75, on the terms in `LICENSE` beside this file. The third-party
120
- open-source components it redistributes are listed in `THIRD-PARTY-NOTICES` and are governed by
121
- their own licences.
68
+ Proprietary. Copyright (c) 2026 code75 SASU, Paris, France. ZIFFER is a registered trademark of
69
+ code75 SASU. This package is not open source. Its use is governed by your agreement with code75
70
+ and by `LICENSE` beside this file. The open-source components it redistributes are listed in
71
+ `THIRD-PARTY-NOTICES`, under their own licences.
package/package.json CHANGED
@@ -1,14 +1,20 @@
1
1
  {
2
2
  "name": "@ziffer-io/verify",
3
- "version": "0.1.0",
4
- "description": "ZIFFER receipt verification: the stateless half of the ACP 9.3 checklist, refusals named by clause.",
3
+ "version": "0.1.1",
4
+ "description": "Verify a ZIFFER decision receipt in your own process, against a key you configured.",
5
+ "keywords": [
6
+ "ziffer",
7
+ "agent",
8
+ "ai-agent",
9
+ "guardrail",
10
+ "approval",
11
+ "receipt"
12
+ ],
5
13
  "author": "code75 SASU",
6
14
  "license": "SEE LICENSE IN LICENSE",
7
- "comment-license": "The SDK is proprietary (code75 SASU). \"license\" is the SPDX escape hatch for exactly this case: there is no SPDX identifier for these terms, so the field points at the file that states them, and LICENSE ships in the tarball beside THIRD-PARTY-NOTICES. Do not put an OSI identifier here -- Apache-2.0 stood in these four files until ACP-214 and was wrong the whole time.",
8
15
  "type": "module",
9
16
  "main": "./dist/index.js",
10
17
  "types": "./dist/index.d.ts",
11
- "comment-exports": "The `./dist/*` entry is not laziness and removing it breaks a caller. `exports` is a gate as well as a map: declaring only `.` makes every other path unreachable, and packages/mcp/src/tools.test.ts imports `@ziffer-io/verify/dist/testkeys.js` ON PURPOSE -- testkeys is deliberately absent from index.ts so it cannot be reached by accident, and the deep import is how the MCP package's tests get a signing key without a second definition of one identity. That import resolves today because there is no exports map; adding a `.`-only one would break it at publish time, which is the class of defect an exports map introduces rather than prevents. So the dist is declared addressable, and the reason is here rather than in a reviewer's memory.",
12
18
  "exports": {
13
19
  ".": {
14
20
  "types": "./dist/index.d.ts",
@@ -34,12 +40,9 @@
34
40
  "publishConfig": {
35
41
  "access": "public"
36
42
  },
37
- "comment-no-provenance": "There is deliberately no \"provenance\": true here, and it was removed rather than never added. npm generates a provenance attestation only from a recognised CI runner, and its documentation states plainly that provenance is NOT SUPPORTED for private repositories (docs.npmjs.com/trusted-publishers, read 2026-09-03) -- ziffer-hq/ziffer is private (`gh api` says so). Setting the flag does not degrade to a warning: npm attempts the attestation and the publish FAILS, so the field would have broken the operator's very first publish from a laptop and every CI publish after it. tools/release-npm.sh asserts the field stays absent, and that assertion is the thing to delete on the day this repository becomes public -- at which point trusted publishing generates provenance on its own, with no flag at all.",
38
43
  "ziffer": {
39
- "enginePin": "fed43d10b427e0a4435a8f04e474334d88a5aa6e"
44
+ "enginePin": "8ae262196dbe75210a975a456785df5d9f0524a3"
40
45
  },
41
- "comment-enginePin": "The engine commit whose clause spellings, canon and mirrored corpus this implementation is bound to. A COPY of the rev in Cargo.toml, which is the one authority tools/guard.sh reads; tools/release-npm.sh refuses to release when the two differ, by name. tools/bump-pin.sh does not move this field -- see packages/types/package.json for why and what closing it costs.",
42
- "comment-deps": "Exactly the two signature primitives, at the versions services/approval already pins -- the DR-2 permitted common set minus the wire types, which this package does not consume: it verifies raw parsed JSON, and typing the wire is the client's (@ziffer-io/client) business. Adding anything else needs written justification (ACP-197 runbook section 5).",
43
46
  "dependencies": {
44
47
  "@noble/curves": "2.3.0",
45
48
  "@noble/post-quantum": "0.7.0"
@@ -51,7 +54,7 @@
51
54
  "scripts": {
52
55
  "build": "tsc -b",
53
56
  "typecheck": "tsc -b",
54
- "pretest": "tsc -b && if ! find dist -name '*.test.js' -print -quit | grep -q .; then echo 'ACP-248: no compiled test file under dist/ -- node --test reports 0 tests and exits 0, so this package would go green having run nothing' >&2; exit 1; fi",
57
+ "pretest": "tsc -b && if ! find dist -name '*.test.js' -print -quit | grep -q .; then echo 'no compiled test file under dist/ -- node --test reports 0 tests and exits 0, so this package would go green having run nothing' >&2; exit 1; fi",
55
58
  "test": "tsc -b && node --test \"dist/**/*.test.js\""
56
59
  }
57
60
  }