chiproof
v0.9.1
Published
Verifier SDK for attested, windowed disclosure from ICAO 9303 chip documents. Part of the zkagent project. Exercised end to end against the project's reference scanner and demo on real hardware (a Pixel 6a, a US passport and an NL ID card); no external ad
Maintainers
Readme
chiproof
Status: released (see the version on npm). The zkagent project was closed as a complete experiment (D108) and then revived for tier C (D109); tier C is built and device-checked and shipped in 0.9.0. This package has been exercised end to end against the project's reference scanner and demo on real hardware (a Pixel 6a, a US passport and an NL ID card) — see docs/logs/ in the repository — but it has no external adopter and has not been independently audited. A "verified" verdict from bare mode proves nothing about a person; do not treat it as identity-grade until an evidence plug you trust is configured.
chiproof is the verifier SDK of the zkagent project: a stateless Node library that checks an attested, windowed disclosure derived from an ICAO 9303 chip document — the chip already in a passport or a modern national ID card.
The intended shape, in one paragraph: a phone reads the chip, verifies the issuing government's own signature against a public masterlist, answers exactly one question about the holder (over 18?), and attests that unmodified code did the reading. chiproof verifies that attestation and returns a verdict. No document, date of birth, name or number ever reaches the server. By default no identifier does either — two presentations by the same person are unlinkable. A second, explicitly requested mode adds a pseudonym scoped to one domain, for the cases that need to recognise a returning human.
With no evidence plug configured (the default), the result is bare mode: captcha-grade, not identity-grade — a "verified" verdict means only "a nonce was spent once, on time, under a challenge this process minted"; it says nothing about a person, a document, or a device. The evidence slot and four plugs (signed-receipt/1, zk-passport/1, sig-ed25519/1, sig-p256/1) exist, but an adopter must explicitly configure evidence.require/evidence.accept to leave bare mode (evidence.require may be per-tier — {B: [...]} — so one instance serves a bare tier A next to an evidence-required tier B; a plain array applies at every tier, as in 0.2.0). Each require entry is either a registry-key string (all-of, unchanged) or a non-empty array of registry-key strings — an alternatives group (D31/D36): satisfied when at least one member is present and verifies, e.g. require: { B: [['sig-ed25519/1', 'sig-p256/1']] } lets the device present whichever of the two it can produce. A present-but-invalid group member is never masked by another member passing — it still fails the presentation, exactly like today's all-of items. A plug may declare binds.zktag to additionally tie its evidence to the presented zktag (ctx.zktag; null at tier A). zk-passport/1 is tier-A-only: its underlying circuit has no nonce input, so its nullifier can't double as a stable tier-B/C pseudonym (see the context file below for why). sig-ed25519/1 and sig-p256/1 (D30/D31/D32/D36/D38, FR12, tier ceiling B) are the attester-key family: an app's own device-bound signing key vouches for the challenge binding, algorithm chosen per-device by whichever the app's Keystore actually supports — the two are co-equal reference-default alternatives, not one privileged plug (D31). D38 (2026-09-01): the key is per-origin on the device, so an item may carry data.pubkey (SubjectPublicKeyInfo DER, base64) alongside key_id; the verifier always recomputes key_id from pubkey and refuses a mismatch. An unpinned pubkey is trust-on-first-sight, bound to (scope, zktag) via a new pluggable attesterStore ({ get({scope,zktag}), bind({scope,zktag,key_id,pubkey}) }, InMemoryAttesterStore reference implementation, same conventions as InMemoryNonceStore) — a later presentation for the same (scope, zktag) must carry the identical pubkey, or it's attester_key_mismatch.
Quickstart
import { createVerifier, InMemoryNonceStore } from 'chiproof';
process.env.NODE_ENV = 'test'; // InMemoryNonceStore is test-only; see the context file
const verifier = createVerifier({
scopeDomain: 'example.com',
challengeSecret: 'a-secret-at-least-16-bytes-long',
stores: { nonce: new InMemoryNonceStore({ quiet: true }) },
});
const challenge = verifier.issueChallenge({ tier: 'A', ttlMs: 60_000 });
const verdict = await verifier.verify({
spec: 'zkagent/1', tier: 'A',
claim: { over_threshold: true, threshold: 18 }, challenge,
});Tier C: asking specific questions
Tier C asks a pinned site's chosen questions about the document (expiry bucket, date-of-birth range, nationality, name, document number) and gets back one yes/no per question. It is off unless you turn it on in two places, and the challenge must be signed by an issuer you trust:
import { generateKeyPairSync } from 'node:crypto';
import { createVerifier, InMemoryNonceStore, verifyTierCRefusal } from 'chiproof';
const issuerKeys = generateKeyPairSync('ed25519'); // your challenge-signing key; keep the private half server-side
const verifier = createVerifier({
scopeDomain: 'example.com',
challengeSecret: 'a-secret-at-least-16-bytes-long',
stores: { nonce: new InMemoryNonceStore({ quiet: true }) },
allowInMemoryStore: true, // examples only: use a persistent NonceStore in production
tiers: { max: 'C' }, // 1. allow tier C
trustedChallengeIssuers: [{ pubkey: issuerKeys.publicKey, key_id: 'my-issuer', maxTier: 'C' }], // 2. trust this signer for C
});
const challenge = verifier.issueChallenge({
tier: 'C',
predicates: [
{ name: 'expiry_beyond', months: 6 }, // months: 3 | 6 | 12
{ name: 'dob_at_least', age: 18 }, // age: 15 | 16 | 18 | 21 | 60 | 65
{ name: 'nationality_not_in', codes: ['CUB', 'RUS'] }, // 1 to 10 codes
],
ttlMs: 60_000,
issuer: { privateKey: issuerKeys.privateKey, key_id: 'my-issuer' },
});
// `presentation` is what the phone posts back: { spec: 'zkagent/1', tier: 'C', challenge, answers }.
const verdict = await verifier.verify(presentation);
// verdict.answers: [{ name: 'expiry_beyond', months: 6, result: true }, ...] one per question, in order
// If the holder declines, the app posts an OpenID4VP error; classify it:
const refusal = verifyTierCRefusal({ error: 'access_denied', state }); // { ok: true, allowed: false, refused: true, ... }- Read
verdict.answers[].result, neververdict.allowed.allowed: trueonly means "a well-formed answer to exactly what you asked arrived". Aresult: falseis a real answer, not a failure. - The refusal is generic.
verifyTierCRefusalreturns the same verdict for every cause (D110); you cannot tell why the holder said no. You matchstateto your own transaction. - Limits, checked when you issue the challenge (
issueChallengethrows, andvalidateTierCPredicates(list)returns{ok:false, reason}without throwing): an exclusion set holds at most 10 country codes (D102), and a request carries at most onenationality_isand at most onenationality_not_in(D118, reasonnationality_asked_more_than_once), and at most onedocument_number_is(D119, reasondocument_number_asked_more_than_once). Other questions are not capped. - Without
tiers: { max: 'C' }and atrustedChallengeIssuersentry withmaxTier: 'C',verify()returnstier_exceeds_maxorsignature_required_at_tier_c.
The full contract is in chiproof.context.md.
Full integration guide
Every config option, the complete public API, the NonceStore and evidence-plug
extension contracts, the threat model, and the sharp edges live in
chiproof.context.md — read that (not the source) to
wire this library up, or point an integrating agent at it directly.
Running the tests
cd packages/chiproof
node --testTests are split per AGENT_RULES.md: tests/unit/ covers the pure logic (verdict.js, canonical.js); tests/integration/ exercises challenge.js, index.js (boot checks) and verify() against the real in-memory store and real Ed25519 keys; tests/e2e/ runs against the gitignored spikes/m1-zk/out/ artefacts and skips cleanly when they or bb are absent.
What this is not
- Not a zero-knowledge proof by itself: bare mode is captcha-grade. The
zk-passport/1evidence plug verifies a third-party zero-knowledge proof, validation-grade, tier A only — see chiproof.context.md. (amended 2026-08-30, D24/D25) Absent that plug, the verifier checks a hardware attestation that trustworthy code computed an answer; it does not check a proof it could verify itself. The accurate description is attested windowed disclosure. See the design doc. - Not an identity provider, wallet, issuer, or certificate authority.
- Not certified under any regulatory scheme, and not claiming to satisfy any legal age-verification requirement anywhere.
Design
- Product requirements:
docs/product/zkagent-prd.md - Design and disclosure model:
docs/product/zkagent-design.md
License
Apache-2.0
