Skip to content

Scoped Credential Nullifier — Spec Amendment

Renamed 2026-05-03 — see docs/superpowers/specs/2026-05-03-zkqes-rename-design.md for the rename baseline. Historical references to QKB/QIE/Identity-Escrow in pre-2026-05-03 commits remain immutable in git history.

Amends §14.4 of 2026-04-17-qie-phase2-design.md and §13.4 of packages/contracts/CLAUDE.md. Date: 2026-04-18. Clarified 2026-04-23. Status: authoritative.

2026-04-23 clarification

This primitive is not a pan-eIDAS natural-person deduplicator. It derives a context-bound nullifier from the identifier namespace exposed by the QES certificate's subject.serialNumber value. eIDAS trust lists establish that a QTSP/certificate chain is trusted; they do not require every Member State, QTSP, passport, or tax-number namespace held by the same natural person to collapse to one EU-wide identifier.

Applications may treat this as "one registration per context per QES identifier namespace." If they need "one natural person across multiple national identifiers," that must be supplied by a separate identity-escrow or deduplication layer above QKB.

Motivation

The prior Phase-2 nullifier construction was:

secret    = Poseidon(subject_serial_limbs, issuer_cert_hash)
nullifier = Poseidon(secret, ctxHash)

This binds the nullifier to a specific certificate, not to the identifier namespace represented by the QES subject. Every eIDAS QES is reissued every 1–3 years with a fresh certificate serial and subject public key. Under the prior construction, the same local QES identity produced a different nullifier after each renewal. The construction below fixes renewal/provider churn inside the exposed identifier namespace, but it still does not unify different national identifiers held by the same natural person.

New construction

subjectSerialBytes  = subject.serialNumber attribute content (OID 2.5.4.5, PrintableString)
subjectSerialLen    = byte length of that content (1..32)
subjectSerialLimbs  = 4 × uint64 LE limbs packing the zero-padded-to-32 byte content
                      (limb[0] = bytes[0..8] LE, limb[1] = bytes[8..16] LE, …, limb[3] = bytes[24..32] LE)

secret              = Poseidon(subjectSerialLimbs[0], subjectSerialLimbs[1],
                                subjectSerialLimbs[2], subjectSerialLimbs[3],
                                subjectSerialLen)                              — Poseidon-5
nullifier           = Poseidon(secret, ctxHash)                                 — Poseidon-2

The limb packing is the one already produced by X509SubjectSerial.circom (committed as S0.2 on feat/qie-circuits at f5dea56): 32-byte capacity padded with zeros past subjectSerialLen, packed into 4 × uint64 little-endian limbs. MAX_SERIAL=32 comfortably covers every ETSI EN 319 412-1 semantics identifier observed in the wild (longest is ~24 chars for uncommon passport formats; typical PNOXX-… is 15–16). Hashing subjectSerialLen alongside the limbs prevents padding-collision between identifiers of different natural lengths (e.g. an 8-byte EDRPOU vs a 10-byte РНОКПП vs a 14-byte PNODE-12345678).

Design rationale over the earlier 16-byte / two-stage-Poseidon variant considered on 2026-04-18 morning:

  • Capacity: 16 was too tight for TINUA-3627506575 (exactly 16 bytes) — zero headroom. 32 is adequate.
  • Constraint cost: one Poseidon-5 (~400 constraints) beats Poseidon-16 + Poseidon-2 (~3500) by an order of magnitude. Relevant given the ECDSA presentation is at 7.63M / 8M budget.
  • Reuse: X509SubjectSerial.circom already emits these limbs. Refactoring it into a byte-array emitter would be pure overhead.

Cryptographic property is equivalent — the limb packing is a bijection over the padded bytes, so the inner hash over limbs is isomorphic to a hash over bytes modulo the field-element encoding.

eIDAS scope

ETSI EN 319 412-1 §5.1.3 — mandatory for every eIDAS QES — requires the subject serialNumber (OID 2.5.4.5) to carry a semantics identifier in the format:

<3-letter-type><2-letter-country>-<national-unique-id>

where <3-letter-type>{PAS, IDC, PNO, TAX, …} per ETSI TS 119 412-1 Annex A. Examples:

ValueCountryScheme
PNOUA-3456789012UAРНОКПП (natural person)
PNODE-12345678DESteuer-ID
PNOFR-1850799123456FRNIR
PNOPL-89030303030PLPESEL
TINPL-1234567890PLTax identification number
PASDE-C01X00T47DEPassport number

The circuit hashes the raw PrintableString content bytes. It does NOT parse the semantics prefix. Consequently:

  • Pan-eIDAS coverage: any ETSI-compliant QES works without circuit changes. The primitive generalizes beyond Ukraine.
  • Identifier-scheme-scoped: a person who holds both PNODE-… and PASDE-… certs from the same QTSP produces two distinct nullifiers — one per identifier scheme. This is intentional. Applications that need strict one-human-ever semantics need an escrow/deduplication layer; pinning a single identifier-type prefix only narrows the namespace and does not solve cross-country identity unification.
  • Non-ETSI QES: certs without OID 2.5.4.5 fail witness generation with witness.rnokppMissing. The web SPA surfaces this as "This flow currently requires an ETSI EN 319 412-1 compliant eIDAS QES."

On-chain / interface compatibility

  • QKBVerifier.Inputs.nullifier (bytes32) — unchanged.
  • Public-signal index 13 — unchanged.
  • QKBRegistry.usedNullifiers / nullifierToPk / revokedNullifiers — unchanged.
  • revokeNullifier(bytes32, bytes32) — unchanged.

Contracts require no rebuild. The change is circuit-internal (new witness inputs, new Poseidon sub-circuit) plus witness-builder (new offset + padded-bytes fields).

Backwards compatibility

None required. The Phase-1 Sepolia deployment at 0x7F36aF783538Ae8f981053F2b0E45421a1BF4815 shipped with 13-signal proofs (no nullifier) and remains addressable for existing Phase-1 bindings. The Phase-2 QKBRegistryV2 at 0xcac30ff7B0566b6E991061cAA5C169c82A4319a4 (deployed 2026-04-18) has usedNullifiers empty — no prior production registrations to migrate. This amendment therefore lands transparently; the first registration against the rebuilt circuit writes the first entry.

Constraint budget

ECDSA leaf+chain: currently 7.63 M constraints; hard cap 8 M. The new sub-circuit adds:

  • 16× byte-range check (LessThan(9)) — ~16 × 12 = 192 constraints
  • 2× length bound check — ~40 constraints
  • 16× padding-zero invariant — ~16 × 30 = 480 constraints
  • Asn1ShortTLVCheck (already present pattern) — ~2 × 12 = 24 constraints
  • 16× Multiplexer slice from leafDER — ~16 × MAX_CERT = ~24 k constraints
  • LessEqThan(16) / GreaterEqThan(16) bounds — ~30 constraints
  • Poseidon(16) — ~3.5 k constraints
  • Poseidon(2) — ~600 constraints

Total estimate: ~30 k constraints, well under the 80 k ceiling I allowed in the plan. If compile reports > 7.95 M, fall back to a split proof (auxiliary nullifier circuit chained by leafSpkiCommit equality).

Witness-builder contract

The witness builder MUST supply these new fields alongside existing inputs:

FieldTypeSource
rnokppOffsetuint (absolute offset in leafDER)pkijs-parsed subject RDN walk, located via unique subarray match
rnokppLenuint 1..16length of the PrintableString content
rnokppPadded[16]uint[16]content bytes zero-padded to 16

Public signal nullifier is derived off-circuit by buildPersonSecret + buildNullifier (see packages/circuits/src/witness/nullifier.ts) and compared constraint-side.

Test surface

  • test/nullifier.test.ts — unit tests for the witness helpers (stable, differs-for-different-input, length-bound, length-hashing).
  • test/PersonNullifier.test.ts — circuit unit test with KAT vectors.
  • test/QKBPresentationEcdsa.e2e.test.ts — extended to assert the E2E-produced nullifier matches fixtures/nullifier-kat.json#admin-ecdsa.
  • fixtures/nullifier-kat.json — contains admin-ecdsa and synth-de entries to prove pan-eIDAS coverage.

Out of scope

  • RSA variant. Still deferred until we have non-Diia RSA QES test material. When it lands, the same PersonNullifier primitive wires in unchanged — only the leaf-cert DER path differs.
  • Normalization across schemes. See eIDAS scope note above — explicitly out.
  • Pan-eIDAS natural-person deduplication. Two QTSPs issuing certs with the same subject.serialNumber namespace/value produce the same nullifier. Two different national identifier namespaces for the same natural person do not. Cross-namespace deduplication is an identity-escrow problem, not a QES certificate-proof property.

Released under the MIT License. zk-QES — a zero-knowledge protocol over qualified electronic signatures.