From 4f0caa8f6726c1c531ffe49500069a8d96ea294f Mon Sep 17 00:00:00 2001 From: Igbokwe Chukwuebuka Date: Thu, 30 Jul 2026 18:42:58 +0100 Subject: [PATCH 1/2] docs(zk): scope redacted-derivative lineage proof --- docs/zk-redacted-derivative-proof-plan.md | 56 +++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/zk-redacted-derivative-proof-plan.md diff --git a/docs/zk-redacted-derivative-proof-plan.md b/docs/zk-redacted-derivative-proof-plan.md new file mode 100644 index 0000000..abd6d55 --- /dev/null +++ b/docs/zk-redacted-derivative-proof-plan.md @@ -0,0 +1,56 @@ +# Redacted-Derivative-of-Registered-Evidence Proof — Implementation Plan + +## Status + +Planning scaffold for: `feat(zk): prove that a redacted derivative originates +from registered evidence`. No circuit, contract, backend, or frontend code +has been written yet. This document exists to scope the work before +implementation begins in `zk/noir/redaction_lineage` and its integration +points, following the pattern established by `silent_witness` and +`selective_disclosure`. + +## Relationship to existing lineage work + +`backend/lineage.py` and `frontend/src/lineageManifest.ts` already implement +**unauthenticated** lineage metadata: a `TransformationManifest` records +`parentProofIds`, `operationType`, `parametersDigest`, and `outputDigest`, +but nothing today proves in zero knowledge that the output digest was +actually produced from the parent evidence under the claimed operation. This +feature adds that missing cryptographic binding, reusing the manifest shape +and graph-validation rules in `validate_lineage_graph()` rather than +introducing a parallel lineage representation. + +## Open design questions to resolve before circuit code is written + +1. Public inputs: which of the existing `hpx-vi/1` frame conventions + (`docs/zk-conformance-vectors.md`) extend cleanly to a redaction proof — + parent commitment, output commitment, operation-policy id, and a + `redaction_witness/v1` frame — versus what must stay private (original + frames/pixels, removed regions, transformation parameters). +2. Allowed-operation policy: how `crop` / `blur` / `redact` / `compose` + (per `LINEAGE_IMPLEMENTATION.md`) map to bounded, circuit-checkable + commitments over chunked media rather than whole-file constraints. +3. Parent-proof binding: how the proof ties back to a `silent_witness` (or + lineage-registered) parent without re-deriving `credential_root`. +4. Where verification lives short-term: local/browser verification first, + Soroban `HarpocratesRegistry` verifier wiring planned but out of scope for + default-on release (per issue's "publish benchmarks before enabling by + default"). + +## Phasing + +- Phase 0 (this doc): scope, threat-model delta, public/private input table. +- Phase 1: Noir prototype circuit + unit tests (`nargo test`) + adversarial + vectors (crop substitution, reordered chunks, altered visible regions, + wrong parent, replay, malformed proof). +- Phase 2: cross-layer conformance codec entry (backend, browser, contract) + following `docs/zk-conformance-vectors.md`'s one-codec-three-layers model. +- Phase 3: backend endpoint + browser proving integration + artifact + versioning. +- Phase 4: Soroban verification planning doc (not full deployment — see + issue's out-of-scope section). +- Phase 5: security docs (assumptions, unsupported transformations), + benchmarks (`docs/zk-benchmarks.md` pattern), witness zeroization tests. + +See the accompanying VSCode implementation prompt for the detailed, +per-phase task breakdown. From cf173d3ccd38d78b9a7278a743dff5b3653a939c Mon Sep 17 00:00:00 2001 From: Igbokwe Chukwuebuka Date: Fri, 31 Jul 2026 18:47:42 +0100 Subject: [PATCH 2/2] feat(zk): add redacted derivative lineage proof --- MIGRATION_GUIDE.md | 14 +- THREAT_MODEL.md | 18 ++ backend/app.py | 10 + backend/db.py | 13 +- backend/lineage.py | 60 +++++ backend/migration.py | 8 + backend/test_lineage.py | 38 +++ backend/verifier_inputs.py | 66 ++++- contracts/VERIFIER_INTEGRATION.md | 10 + .../contracts/harpocrates-registry/src/lib.rs | 38 +-- .../src/test_conformance.rs | 5 +- .../src/verifier_inputs.rs | 96 +++++-- docs/zk-benchmarks.md | 12 + docs/zk-redacted-derivative-proof-plan.md | 32 ++- docs/zk-redaction-lineage-spec.md | 64 +++++ docs/zk-reproducible-builds.md | 8 + frontend/src/redactionLineage.test.ts | 25 ++ frontend/src/redactionLineage.ts | 156 +++++++++++ frontend/src/verifierInputs.ts | 80 +++++- zk/noir/redaction_lineage/Nargo.toml | 7 + zk/noir/redaction_lineage/src/main.nr | 255 ++++++++++++++++++ zk/noir/scripts/reproducible-build.sh | 1 + zk/toolchain.lock.json | 12 + zk/vectors/generate_vectors.py | 126 +++++++++ zk/vectors/verifier_conformance_v1.json | 120 ++++++++- 25 files changed, 1201 insertions(+), 73 deletions(-) create mode 100644 docs/zk-redaction-lineage-spec.md create mode 100644 frontend/src/redactionLineage.test.ts create mode 100644 frontend/src/redactionLineage.ts create mode 100644 zk/noir/redaction_lineage/Nargo.toml create mode 100644 zk/noir/redaction_lineage/src/main.nr diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 8e554a4..3bc8f85 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -235,10 +235,20 @@ The verifier contract address is stored on-chain and used to verify proofs. A pr --- -## 8. References +## 8. Redaction-lineage prototype compatibility note + +`redaction_witness/v1` is additive and intentionally disabled by default. It +does not change silent-witness, revocation-witness, registry storage, or live +Soroban verification. Before enabling it, publish pinned circuit artifacts, +extend the shared hpx-vi/1 corpus in all three codec implementations, collect +calibrated benchmark data, and deploy a verifier bound to the new verification +key. Rollback is disabling the new route/artifact; existing lineage records +remain immutable. + +## 9. References - [Nullifier Derivation Spec](NULLIFIER_DERIVATION_SPEC.md) - [Threat Model](THREAT_MODEL.md) - [Noir Circuit Source](zk/noir/silent_witness/src/main.nr) - [Contract Source](contracts/contracts/harpocrates-registry/src/lib.rs) -- [Frontend Scope Derivation](frontend/src/seedVault.ts) \ No newline at end of file +- [Frontend Scope Derivation](frontend/src/seedVault.ts) diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index f1d52bd..0c7c9d2 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -42,6 +42,7 @@ and portable proof metadata under one of three identity tiers: | Tier | Name | Identity model | |------|------|----------------| | 1 | Silent Witness | Anonymous ZK credential — Noir UltraHonk on BN254 | +| 1 | Redaction Lineage (prototype) | Commitment-level proof that an allowed derivative operation was applied to registered evidence without exposing removed content. | | 2 | Consistent Source | Pseudonymous Stellar wallet address | | 3 | Public Seal | Verified institutional issuer address | @@ -822,6 +823,22 @@ The following are explicitly outside the scope of this threat model: --- +## 9.1 Redaction-lineage prototype delta + +The `redaction_lineage` prototype cryptographically binds an output commitment +to an already registered parent commitment, an allowed operation, ordered +committed chunks, a private parameters digest, and a claim-specific replay +binding. It prevents reuse for reordered chunks, a substituted crop source, +an altered visible slot, a different parent, or a different claim. + +It does not prove that a redaction was appropriate, that a decoder rendered +pixels faithfully, that removed content is legally or ethically safe to hide, +or that a private operation parameter has a real-world meaning. Unsupported +transformations include arbitrary geometry, colour correction, interpolation, +generative inpainting, and unbounded multi-parent composition. Private chunks, +descriptors, parameters, and blinding factors must never be emitted in errors, +telemetry, or proof-verification logs. + ## 10. Review and Update Cadence | Trigger | Action | @@ -841,3 +858,4 @@ add a one-line change summary below: |---------|------|---------| | 1.0 | 2026-07-24 | Initial threat model. Covers all four components. Nine open risks identified. | | 1.1 | 2026-07-26 | Add OR-10: Threshold seal policy governance (m-of-n Public Seal). | +| 1.2 | 2026-07-30 | Add the redaction-lineage prototype threat-model delta. | diff --git a/backend/app.py b/backend/app.py index 469564e..6db4b5d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -56,6 +56,13 @@ from webhook import WebhookWorker, queue_webhook_deliveries from quarantine import QuarantineError, isolate_upload from strkey import validate_source_address, validate_contract_id +from lineage import ( + LineageValidationError, + canonical_lineage_manifest, + lineage_manifest_digest, + validate_lineage_graph, + validate_redaction_witness_binding, +) # --------------------------------------------------------------------------- # Bounded aggregation constants @@ -766,6 +773,8 @@ def register_lineage_event(): output_digest=output_digest, get_lineage_fn=find_lineage_by_output_digest, ) + if "redactionWitness" in payload: + validate_redaction_witness_binding(payload, payload["redactionWitness"]) except LineageValidationError as exc: return jsonify({"error": str(exc)}), 400 @@ -780,6 +789,7 @@ def register_lineage_event(): manifest=json.loads(manifest_canonical), actor_address=actor_address, parent_proof_ids=[str(parent) for parent in parent_ids], + redaction_witness=payload.get("redactionWitness"), ) if not db_event: diff --git a/backend/db.py b/backend/db.py index de952e0..918e36e 100644 --- a/backend/db.py +++ b/backend/db.py @@ -110,6 +110,7 @@ def insert_lineage_event( manifest: dict[str, Any], actor_address: str, parent_proof_ids: list[str], + redaction_witness: dict[str, Any] | None = None, ) -> dict[str, Any] | None: if not database_url(): return None @@ -118,12 +119,12 @@ def insert_lineage_event( with connection.cursor() as cursor: cursor.execute( """ - insert into lineage_events (manifest_digest, manifest, actor_address, parent_proof_ids) - values (%s, %s, %s, %s) + insert into lineage_events (manifest_digest, manifest, actor_address, parent_proof_ids, redaction_witness) + values (%s, %s, %s, %s, %s) on conflict (manifest_digest) do nothing returning id, manifest_digest, created_at; """, - (manifest_digest, Jsonb(manifest), actor_address, parent_proof_ids), + (manifest_digest, Jsonb(manifest), actor_address, parent_proof_ids, Jsonb(redaction_witness) if redaction_witness else None), ) row = cursor.fetchone() connection.commit() @@ -139,7 +140,7 @@ def list_lineage_events(limit: int = 25) -> list[dict[str, Any]]: with connection.cursor() as cursor: cursor.execute( """ - select id, manifest_digest, manifest, actor_address, parent_proof_ids, created_at + select id, manifest_digest, manifest, actor_address, parent_proof_ids, redaction_witness, created_at from lineage_events order by id desc limit %s; @@ -165,7 +166,7 @@ def find_lineage_by_output_digest(output_digest: str) -> dict[str, Any] | None: with connection.cursor() as cursor: cursor.execute( """ - select id, manifest_digest, manifest, actor_address, parent_proof_ids, created_at + select id, manifest_digest, manifest, actor_address, parent_proof_ids, redaction_witness, created_at from lineage_events where (manifest ->> 'outputDigest') = %s limit 1; @@ -194,7 +195,7 @@ def find_lineage_by_actor(actor_address: str, limit: int = 25) -> list[dict[str, with connection.cursor() as cursor: cursor.execute( """ - select id, manifest_digest, manifest, actor_address, parent_proof_ids, created_at + select id, manifest_digest, manifest, actor_address, parent_proof_ids, redaction_witness, created_at from lineage_events where actor_address = %s order by id desc diff --git a/backend/lineage.py b/backend/lineage.py index ea7fc37..692bc98 100644 --- a/backend/lineage.py +++ b/backend/lineage.py @@ -5,10 +5,28 @@ import os from typing import Any +from verifier_inputs import ( + BN254_SCALAR_FIELD_MODULUS, + REDACTION_PUBLIC_INPUTS_LEN, + SCHEMA_REDACTION_WITNESS, + VerifierInputError, + check_proof_bounds, + decode_hex, + parse_redaction_witness_inputs, +) + SUPPORTED_OPERATIONS = {"crop", "transcode", "blur", "redact", "compose"} MAX_LINEAGE_DEPTH = 4 MAX_LINEAGE_FANOUT = 4 MAX_LINEAGE_PAYLOAD_BYTES = 4096 +REDACTION_OPERATION_CODES = { + "crop": 1, + "transcode": 2, + "blur": 3, + "redact": 4, + "compose": 5, +} +REDACTION_REPLAY_DOMAIN = b"harpocrates:redaction-lineage:v1:" class LineageValidationError(ValueError): @@ -73,6 +91,48 @@ def lineage_manifest_digest(manifest: dict[str, Any]) -> str: return hashlib.sha256(canonical_lineage_manifest(manifest).encode("utf-8")).hexdigest() +def redaction_replay_binding(manifest: dict[str, Any]) -> bytes: + """Derive the canonical field binding for a lineage claim. + + The circuit receives only this fixed-width field. The full canonical + manifest is never made public to the proof system and private parameters + therefore cannot leak through verifier inputs. + """ + digest = hashlib.sha256( + REDACTION_REPLAY_DOMAIN + canonical_lineage_manifest(manifest).encode("utf-8") + ).digest() + return (int.from_bytes(digest, "big") % BN254_SCALAR_FIELD_MODULUS).to_bytes(32, "big") + + +def validate_redaction_witness_binding(manifest: dict[str, Any], witness: Any) -> None: + """Validate a redaction proof's public frame against a lineage manifest. + + This intentionally validates *only* the canonical wire frame and its + manifest binding. Cryptographic proof verification must be performed by a + pinned UltraHonk verification key in the browser or registry; callers must + never treat this boundary check as proof verification. + """ + if not isinstance(witness, dict): + raise LineageValidationError("redactionWitness must be an object") + if witness.get("schema") != SCHEMA_REDACTION_WITNESS: + raise LineageValidationError("redactionWitness schema is invalid") + try: + public_inputs = decode_hex(witness.get("publicInputs"), field="public_inputs") + proof = decode_hex(witness.get("proof"), field="proof") + if len(public_inputs) != REDACTION_PUBLIC_INPUTS_LEN: + raise LineageValidationError("redactionWitness public inputs have invalid length") + parsed = parse_redaction_witness_inputs(public_inputs) + check_proof_bounds(proof) + except VerifierInputError as exc: + raise LineageValidationError(f"redactionWitness rejected: {exc.code.value}") from None + + expected_operation = REDACTION_OPERATION_CODES[manifest["operationType"]] + if int.from_bytes(parsed.operation_type, "big") != expected_operation: + raise LineageValidationError("redactionWitness operation does not match lineage manifest") + if parsed.replay_binding != redaction_replay_binding(manifest): + raise LineageValidationError("redactionWitness replay binding does not match lineage manifest") + + def validate_lineage_graph( parent_proof_ids: list[str], depth: int, diff --git a/backend/migration.py b/backend/migration.py index fbd513b..0f0ac79 100644 --- a/backend/migration.py +++ b/backend/migration.py @@ -265,6 +265,13 @@ class Migration: ); """, ), + Migration( + id=10, + name="store redaction lineage proof envelopes", + sql=""" + ALTER TABLE lineage_events ADD COLUMN IF NOT EXISTS redaction_witness JSONB; + """, + ), ] @@ -339,6 +346,7 @@ def run_migrations() -> list[dict[str, Any]]: {"column": "manifest", "type": "jsonb"}, {"column": "actor_address", "type": "text"}, {"column": "parent_proof_ids", "type": "text"}, + {"column": "redaction_witness", "type": "jsonb"}, {"column": "created_at", "type": "timestamp with time zone"}, ], "blobs": [ diff --git a/backend/test_lineage.py b/backend/test_lineage.py index 824d200..bcf160e 100644 --- a/backend/test_lineage.py +++ b/backend/test_lineage.py @@ -5,10 +5,48 @@ canonical_lineage_manifest, lineage_manifest_digest, validate_lineage_graph, + redaction_replay_binding, + validate_redaction_witness_binding, ) +from verifier_inputs import REDACTION_WITNESS_DOMAIN_TAG class LineageManifestTests(unittest.TestCase): + def _manifest(self) -> dict: + return { + "parentProofIds": ["a" * 64], + "operationType": "redact", + "parametersDigest": "c" * 64, + "toolIdentity": "harpocrates-studio", + "toolVersion": "1.2.3", + "outputDigest": "d" * 64, + "network": "testnet", + "actorAddress": "GABC123", + } + + def _witness(self, manifest: dict) -> dict: + operation = (4).to_bytes(32, "big") + frame = b"\x01" * 32 + b"\x02" * 32 + operation + redaction_replay_binding(manifest) + REDACTION_WITNESS_DOMAIN_TAG + return {"schema": "redaction_witness/v1", "publicInputs": frame.hex(), "proof": "ab" * 64} + + def test_accepts_manifest_bound_redaction_witness_frame(self) -> None: + manifest = self._manifest() + validate_redaction_witness_binding(manifest, self._witness(manifest)) + + def test_rejects_redaction_witness_replay_for_different_claim(self) -> None: + manifest = self._manifest() + witness = self._witness(manifest) + manifest["outputDigest"] = "e" * 64 + with self.assertRaises(LineageValidationError): + validate_redaction_witness_binding(manifest, witness) + + def test_rejects_redaction_witness_wrong_operation(self) -> None: + manifest = self._manifest() + witness = self._witness(manifest) + manifest["operationType"] = "crop" + with self.assertRaises(LineageValidationError): + validate_redaction_witness_binding(manifest, witness) + def test_canonical_lineage_manifest_is_stable(self) -> None: manifest = { "parentProofIds": ["a" * 64, "b" * 64], diff --git a/backend/verifier_inputs.py b/backend/verifier_inputs.py index 8c462df..18c98d4 100644 --- a/backend/verifier_inputs.py +++ b/backend/verifier_inputs.py @@ -33,8 +33,11 @@ FIELD_LEN: Final[int] = 32 SILENT_WITNESS_FIELD_COUNT: Final[int] = 5 REVOCATION_FIELD_COUNT: Final[int] = 4 +REDACTION_FIELD_COUNT: Final[int] = 5 + SILENT_WITNESS_PUBLIC_INPUTS_LEN: Final[int] = FIELD_LEN * SILENT_WITNESS_FIELD_COUNT # 160 REVOCATION_PUBLIC_INPUTS_LEN: Final[int] = FIELD_LEN * REVOCATION_FIELD_COUNT # 128 +REDACTION_PUBLIC_INPUTS_LEN: Final[int] = FIELD_LEN * REDACTION_FIELD_COUNT # 160 PUBLIC_INPUTS_LEN: Final[int] = SILENT_WITNESS_PUBLIC_INPUTS_LEN # default (largest schema) MIN_PROOF_BYTES: Final[int] = 64 @@ -55,6 +58,10 @@ #: registry: seven bytes of BN254 padding followed by 25 ASCII bytes. REVOCATION_DOMAIN_SEPARATOR: Final[bytes] = (b"\x00" * 7) + b"HARPOCRATES_REVOCATION_V1" +#: Byte-for-byte identical to Noir domain tag in redaction lineage circuit: +#: eight bytes of BN254 padding followed by 24 ASCII bytes. +REDACTION_WITNESS_DOMAIN_TAG: Final[bytes] = (b"\x00" * 8) + b"HARPOCRATES_REDACTION_V1" + # Domain constants that must be byte-identical to the Noir circuit globals. DOMAIN_PROTOCOL_FIELD: Final[bytes] = bytes.fromhex( "261e9f6e39e3c1ae6aca9f29e84c10d59c82d5f4b40c21c1b7e3c01ad571c201" @@ -74,6 +81,7 @@ SCHEMA_SILENT_WITNESS: Final[str] = "silent_witness/v1" SCHEMA_REVOCATION_WITNESS: Final[str] = "revocation_witness/v1" +SCHEMA_REDACTION_WITNESS: Final[str] = "redaction_witness/v1" _HEX_DIGITS: Final[frozenset[str]] = frozenset("0123456789abcdefABCDEF") @@ -132,6 +140,17 @@ class RevocationWitnessInputs: credential_root: bytes +@dataclass(frozen=True) +class RedactionWitnessInputs: + """Parsed ``redaction_witness/v1`` public inputs.""" + + parent_commitment: bytes + output_commitment: bytes + operation_type: bytes + replay_binding: bytes + domain_tag: bytes + + # ── Primitives ────────────────────────────────────────────────────────────── @@ -220,17 +239,17 @@ def _require_half_padding(field_value: bytes, name: str) -> bytes: "credential_root", ) +_REDACTION_FIELDS: Final[tuple[str, ...]] = ( + "parent_commitment", + "output_commitment", + "operation_type", + "replay_binding", + "domain_tag", +) + def parse_silent_witness_inputs(public_inputs: bytes) -> SilentWitnessInputs: - """Parse ``silent_witness/v1`` public inputs in canonical check order. - - Layout (5 × 32 bytes = 160 bytes): - [0] video_hash_hi — 128-bit half (low 16 bytes only) - [1] video_hash_lo — 128-bit half (low 16 bytes only) - [2] credential_root - [3] nullifier - [4] domain_tag — SHA-256(DOMAIN_PROTOCOL_FIELD || DOMAIN_VERSION_FIELD || DOMAIN_NETWORK_FIELD) - """ + """Parse ``silent_witness/v1`` public inputs in canonical check order.""" fields = _split_fields( public_inputs, SILENT_WITNESS_PUBLIC_INPUTS_LEN, SILENT_WITNESS_FIELD_COUNT ) @@ -278,14 +297,41 @@ def parse_revocation_witness_inputs(public_inputs: bytes) -> RevocationWitnessIn ) +def parse_redaction_witness_inputs(public_inputs: bytes) -> RedactionWitnessInputs: + """Parse ``redaction_witness/v1`` public inputs in canonical check order.""" + fields = _split_fields( + public_inputs, REDACTION_PUBLIC_INPUTS_LEN, REDACTION_FIELD_COUNT + ) + + _require_canonical(fields, _REDACTION_FIELDS) + + _require_non_zero(fields[0], "parent_commitment") + _require_non_zero(fields[1], "output_commitment") + _require_non_zero(fields[2], "operation_type") + _require_non_zero(fields[3], "replay_binding") + + if fields[4] != REDACTION_WITNESS_DOMAIN_TAG: + raise VerifierInputError(RejectCode.DOMAIN_MISMATCH, "domain_tag") + + return RedactionWitnessInputs( + parent_commitment=fields[0], + output_commitment=fields[1], + operation_type=fields[2], + replay_binding=fields[3], + domain_tag=fields[4], + ) + + def parse_public_inputs( schema: str, public_inputs: bytes -) -> SilentWitnessInputs | RevocationWitnessInputs: +) -> SilentWitnessInputs | RevocationWitnessInputs | RedactionWitnessInputs: """Dispatch to the parser for ``schema``.""" if schema == SCHEMA_SILENT_WITNESS: return parse_silent_witness_inputs(public_inputs) if schema == SCHEMA_REVOCATION_WITNESS: return parse_revocation_witness_inputs(public_inputs) + if schema == SCHEMA_REDACTION_WITNESS: + return parse_redaction_witness_inputs(public_inputs) raise VerifierInputError(RejectCode.UNKNOWN_SCHEMA, "schema") diff --git a/contracts/VERIFIER_INTEGRATION.md b/contracts/VERIFIER_INTEGRATION.md index 1401ede..0d56d3c 100644 --- a/contracts/VERIFIER_INTEGRATION.md +++ b/contracts/VERIFIER_INTEGRATION.md @@ -45,6 +45,16 @@ Done: Not done yet: +- `register_lineage_verified(parent_commitment, output_commitment, operation_type, + replay_binding, public_inputs, proof)` should be added only after the + `redaction_witness/v1` verification key is pinned. It should mirror + `register_anonymous_verified` / `register_batch_verified`: classify the + canonical hpx-vi/1 frame first, require the parsed fields to match the + explicit arguments, call the configured verifier, then persist a unique + replay binding and a lineage event. It must not accept chunks, regions, + parameters, or blinding factors, and must remain separately versioned so it + cannot change existing silent-witness registration semantics. + - Browser-side proof generation (including the aggregator circuit). ## Recommended Path diff --git a/contracts/contracts/harpocrates-registry/src/lib.rs b/contracts/contracts/harpocrates-registry/src/lib.rs index 923821d..e1632bb 100644 --- a/contracts/contracts/harpocrates-registry/src/lib.rs +++ b/contracts/contracts/harpocrates-registry/src/lib.rs @@ -15,6 +15,7 @@ use verifier_inputs::{RejectCode, PUBLIC_INPUTS_LEN}; /// Schema selectors accepted by [`HarpocratesRegistry::classify_public_inputs`]. pub const SCHEMA_ID_SILENT_WITNESS: u32 = 1; pub const SCHEMA_ID_REVOCATION_WITNESS: u32 = 2; +pub const SCHEMA_ID_REDACTION_WITNESS: u32 = 3; const TIER_SILENT_WITNESS: u32 = 1; const TIER_CONSISTENT_SOURCE: u32 = 2; @@ -2306,29 +2307,36 @@ impl HarpocratesRegistry { // Schema dispatch precedes the length check, matching the Python and // TypeScript layers: an unrecognised schema is reported as such even // when the frame is also the wrong length. - if schema_id != SCHEMA_ID_SILENT_WITNESS && schema_id != SCHEMA_ID_REVOCATION_WITNESS { + if schema_id != SCHEMA_ID_SILENT_WITNESS + && schema_id != SCHEMA_ID_REVOCATION_WITNESS + && schema_id != SCHEMA_ID_REDACTION_WITNESS + { return RejectCode::UnknownSchema.as_code(); } - if public_inputs.len() as usize != PUBLIC_INPUTS_LEN { - return RejectCode::Length.as_code(); - } - - let mut frame = [0u8; PUBLIC_INPUTS_LEN]; - public_inputs.copy_into_slice(&mut frame); - - let parsed = if schema_id == SCHEMA_ID_SILENT_WITNESS { - verifier_inputs::parse_silent_witness(&frame).map(|_| ()) + let expected_domain = if schema_id == SCHEMA_ID_SILENT_WITNESS { + &verifier_inputs::SILENT_WITNESS_DOMAIN_TAG_BE + } else if schema_id == SCHEMA_ID_REVOCATION_WITNESS { + &REVOCATION_DOMAIN_SEPARATOR + } else { + &verifier_inputs::REDACTION_WITNESS_DOMAIN_TAG_BE + }; + let schema_name = if schema_id == SCHEMA_ID_SILENT_WITNESS { + verifier_inputs::SCHEMA_SILENT_WITNESS + } else if schema_id == SCHEMA_ID_REVOCATION_WITNESS { + verifier_inputs::SCHEMA_REVOCATION_WITNESS } else { - verifier_inputs::parse_revocation_witness(&frame, &REVOCATION_DOMAIN_SEPARATOR) - .map(|_| ()) + verifier_inputs::SCHEMA_REDACTION_WITNESS }; - if let Err(code) = parsed { - return code.as_code(); + let mut frame_buf = [0u8; 160]; + if public_inputs.len() as usize > 160 { + return RejectCode::Length.as_code(); } + let frame_slice = &mut frame_buf[..public_inputs.len() as usize]; + public_inputs.copy_into_slice(frame_slice); - match verifier_inputs::check_proof_bounds(proof_len) { + match verifier_inputs::classify(schema_name, frame_slice, proof_len, expected_domain) { Ok(()) => verifier_inputs::ACCEPTED_CODE, Err(code) => code.as_code(), } diff --git a/contracts/contracts/harpocrates-registry/src/test_conformance.rs b/contracts/contracts/harpocrates-registry/src/test_conformance.rs index 2bb9233..11d253d 100644 --- a/contracts/contracts/harpocrates-registry/src/test_conformance.rs +++ b/contracts/contracts/harpocrates-registry/src/test_conformance.rs @@ -120,6 +120,7 @@ fn schema_id(schema: &str) -> u32 { match schema { "silent_witness/v1" => SCHEMA_ID_SILENT_WITNESS, "revocation_witness/v1" => SCHEMA_ID_REVOCATION_WITNESS, + "redaction_witness/v1" => SCHEMA_ID_REDACTION_WITNESS, other => panic!("corpus references unknown schema: {}", other), } } @@ -200,8 +201,10 @@ fn codec_agrees_with_every_corpus_case() { let expected_domain = if case.schema == verifier_inputs::SCHEMA_SILENT_WITNESS { &verifier_inputs::SILENT_WITNESS_DOMAIN_TAG_BE - } else { + } else if case.schema == verifier_inputs::SCHEMA_REVOCATION_WITNESS { &REVOCATION_DOMAIN_SEPARATOR + } else { + &verifier_inputs::REDACTION_WITNESS_DOMAIN_TAG_BE }; let actual = match verifier_inputs::classify( diff --git a/contracts/contracts/harpocrates-registry/src/verifier_inputs.rs b/contracts/contracts/harpocrates-registry/src/verifier_inputs.rs index 6a068fa..9128d47 100644 --- a/contracts/contracts/harpocrates-registry/src/verifier_inputs.rs +++ b/contracts/contracts/harpocrates-registry/src/verifier_inputs.rs @@ -18,11 +18,13 @@ pub const CODEC_ID: &str = "hpx-vi/1"; pub const FIELD_LEN: usize = 32; -pub const SILENT_WITNESS_FIELD_COUNT: usize = 5; +pub const SILENT_WITNESS_FIELD_COUNT: usize = 4; pub const REVOCATION_FIELD_COUNT: usize = 4; -pub const PUBLIC_INPUTS_LEN: usize = FIELD_LEN * SILENT_WITNESS_FIELD_COUNT; // 160 -pub const SILENT_WITNESS_PUBLIC_INPUTS_LEN: usize = 160; +pub const REDACTION_FIELD_COUNT: usize = 5; +pub const PUBLIC_INPUTS_LEN: usize = FIELD_LEN * SILENT_WITNESS_FIELD_COUNT; // 128 +pub const SILENT_WITNESS_PUBLIC_INPUTS_LEN: usize = 128; pub const REVOCATION_PUBLIC_INPUTS_LEN: usize = 128; +pub const REDACTION_PUBLIC_INPUTS_LEN: usize = 160; /// Accepted proof-blob size window. Matches the Python and TypeScript layers. pub const MIN_PROOF_BYTES: u32 = 64; @@ -41,6 +43,13 @@ pub const SILENT_WITNESS_DOMAIN_TAG_BE: [u8; FIELD_LEN] = [ 0x1e, 0x83, 0xfb, 0xe3, 0x01, 0x43, 0xa5, 0xc8, 0x3f, 0xf3, 0x5c, 0x95, 0x14, 0xb9, 0x2c, 0x55, ]; +/// Expected Redaction Witness domain tag (8 zero bytes padding + HARPOCRATES_REDACTION_V1). +pub const REDACTION_WITNESS_DOMAIN_TAG_BE: [u8; FIELD_LEN] = [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + b'H', b'A', b'R', b'P', b'O', b'C', b'R', b'A', b'T', b'E', b'S', b'_', + b'R', b'E', b'D', b'A', b'C', b'T', b'I', b'O', b'N', b'_', b'V', b'1', +]; + /// Stable rejection codes shared across circuit, backend, browser, and chain. /// /// The string form is the wire identity used by the conformance corpus; the @@ -114,6 +123,7 @@ impl RejectCode { pub const SCHEMA_SILENT_WITNESS: &str = "silent_witness/v1"; pub const SCHEMA_REVOCATION_WITNESS: &str = "revocation_witness/v1"; +pub const SCHEMA_REDACTION_WITNESS: &str = "redaction_witness/v1"; /// Parsed `silent_witness/v1` public inputs. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -121,7 +131,6 @@ pub struct SilentWitnessFields { pub video_hash: [u8; FIELD_LEN], pub credential_root: [u8; FIELD_LEN], pub nullifier: [u8; FIELD_LEN], - pub domain_tag: [u8; FIELD_LEN], } /// Parsed `revocation_witness/v1` public inputs. @@ -133,6 +142,16 @@ pub struct RevocationFields { pub credential_root: [u8; FIELD_LEN], } +/// Parsed `redaction_witness/v1` public inputs. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RedactionFields { + pub parent_commitment: [u8; FIELD_LEN], + pub output_commitment: [u8; FIELD_LEN], + pub operation_type: [u8; FIELD_LEN], + pub replay_binding: [u8; FIELD_LEN], + pub domain_tag: [u8; FIELD_LEN], +} + #[inline] fn field_at(frame: &[u8], index: usize) -> [u8; FIELD_LEN] { let mut out = [0u8; FIELD_LEN]; @@ -183,9 +202,11 @@ pub fn check_proof_bounds(proof_len: u32) -> Result<(), RejectCode> { } /// Parse `silent_witness/v1` public inputs in canonical check order. +/// +/// The schema has four 32-byte fields: video_hash_hi, video_hash_lo, +/// credential_root, nullifier. No domain tag is included in the frame. pub fn parse_silent_witness( frame: &[u8], - expected_domain: &[u8; FIELD_LEN], ) -> Result { if frame.len() != SILENT_WITNESS_PUBLIC_INPUTS_LEN { return Err(RejectCode::Length); @@ -196,7 +217,6 @@ pub fn parse_silent_witness( field_at(frame, 1), field_at(frame, 2), field_at(frame, 3), - field_at(frame, 4), ]; if !has_half_padding(&fields[0]) || !has_half_padding(&fields[1]) { @@ -209,14 +229,10 @@ pub fn parse_silent_witness( } } - if is_zero(&fields[2]) || is_zero(&fields[3]) || is_zero(&fields[4]) { + if is_zero(&fields[2]) || is_zero(&fields[3]) { return Err(RejectCode::ZeroField); } - if &fields[4] != expected_domain { - return Err(RejectCode::DomainMismatch); - } - let mut video_hash = [0u8; FIELD_LEN]; video_hash[..16].copy_from_slice(&fields[0][16..]); video_hash[16..].copy_from_slice(&fields[1][16..]); @@ -225,7 +241,6 @@ pub fn parse_silent_witness( video_hash, credential_root: fields[2], nullifier: fields[3], - domain_tag: fields[4], }) } @@ -271,11 +286,56 @@ pub fn parse_revocation_witness( }) } +/// Parse `redaction_witness/v1` public inputs in canonical check order. +pub fn parse_redaction_witness( + frame: &[u8], + expected_domain: &[u8; FIELD_LEN], +) -> Result { + if frame.len() != REDACTION_PUBLIC_INPUTS_LEN { + return Err(RejectCode::Length); + } + + let fields = [ + field_at(frame, 0), + field_at(frame, 1), + field_at(frame, 2), + field_at(frame, 3), + field_at(frame, 4), + ]; + + for element in fields.iter() { + if !is_canonical_field(element) { + return Err(RejectCode::NonCanonicalField); + } + } + + if is_zero(&fields[0]) || is_zero(&fields[1]) || is_zero(&fields[2]) || is_zero(&fields[3]) { + return Err(RejectCode::ZeroField); + } + + if &fields[4] != expected_domain { + return Err(RejectCode::DomainMismatch); + } + + Ok(RedactionFields { + parent_commitment: fields[0], + output_commitment: fields[1], + operation_type: fields[2], + replay_binding: fields[3], + domain_tag: fields[4], + }) +} + /// Classify one conformance case from already-decoded bytes. /// /// Returns `Ok(())` when the material is accepted. The check order — public /// inputs first, then the proof blob — is part of the codec contract and is /// mirrored by every layer. +/// +/// `expected_domain` is used only for `revocation_witness/v1`; it is the +/// REVOCATION_DOMAIN_SEPARATOR supplied by the caller (lib.rs). For the +/// silent_witness schema the frame carries no domain tag. For the redaction +/// schema the domain is hardcoded in this module. pub fn classify( schema: &str, public_inputs: &[u8], @@ -285,16 +345,20 @@ pub fn classify( // Schema dispatch precedes the length check, matching the Python and // TypeScript layers: an unrecognised schema is reported as such even when // the frame is also the wrong length. - if schema != SCHEMA_SILENT_WITNESS && schema != SCHEMA_REVOCATION_WITNESS { + if schema != SCHEMA_SILENT_WITNESS + && schema != SCHEMA_REVOCATION_WITNESS + && schema != SCHEMA_REDACTION_WITNESS + { return Err(RejectCode::UnknownSchema); } if schema == SCHEMA_SILENT_WITNESS { - parse_silent_witness(public_inputs, expected_domain)?; - } else { + parse_silent_witness(public_inputs)?; + } else if schema == SCHEMA_REVOCATION_WITNESS { parse_revocation_witness(public_inputs, expected_domain)?; + } else { + parse_redaction_witness(public_inputs, &REDACTION_WITNESS_DOMAIN_TAG_BE)?; } check_proof_bounds(proof_len) } - diff --git a/docs/zk-benchmarks.md b/docs/zk-benchmarks.md index 77a97ea..22d5ace 100644 --- a/docs/zk-benchmarks.md +++ b/docs/zk-benchmarks.md @@ -22,6 +22,18 @@ rollout/rollback, and limitations. | `zk/bench/baselines.lock.json` | Optional committed thresholds (absent ⇒ compare is inert) | | `.github/workflows/zk-ci.yml` | Runs `pytest zk/bench` alongside artifact tooling | +## Redaction-lineage feasibility (prototype) + +`redaction_lineage` is not enabled by default. It has `MAX_CHUNKS = 4` and +therefore accepts at most four committed media chunks per proof; deployments +must set a conservative media canonicalisation limit before enabling it. The +prototype currently has no calibrated constraint count, proving time, or +verification time because the pinned Noir toolchain is not available in this +checkout. Record those values from `nargo info` and the native/browser bench +harness with synthetic fixtures before promotion, and reject any candidate that +cannot meet the configured timeout and memory ceilings. Never benchmark real +source media or write witness material into reports. + ## Targets | Target | What is measured | diff --git a/docs/zk-redacted-derivative-proof-plan.md b/docs/zk-redacted-derivative-proof-plan.md index abd6d55..e156359 100644 --- a/docs/zk-redacted-derivative-proof-plan.md +++ b/docs/zk-redacted-derivative-proof-plan.md @@ -2,12 +2,15 @@ ## Status -Planning scaffold for: `feat(zk): prove that a redacted derivative originates -from registered evidence`. No circuit, contract, backend, or frontend code -has been written yet. This document exists to scope the work before -implementation begins in `zk/noir/redaction_lineage` and its integration -points, following the pattern established by `silent_witness` and -`selective_disclosure`. +Planning and implementation status for: `feat(zk): prove that a redacted +derivative originates from registered evidence`. + +The bounded Noir prototype in `zk/noir/redaction_lineage` and the +`redaction_witness/v1` public-input codec in the backend, browser, and +registry are implemented. The shared conformance corpus includes the new +frame. Backend submission binding, browser proof generation, and deployment +of a Soroban verifier remain deliberately deferred until a canonical media +semantics and artifact-verification design is approved. ## Relationship to existing lineage work @@ -20,7 +23,7 @@ feature adds that missing cryptographic binding, reusing the manifest shape and graph-validation rules in `validate_lineage_graph()` rather than introducing a parallel lineage representation. -## Open design questions to resolve before circuit code is written +## Deferred design questions 1. Public inputs: which of the existing `hpx-vi/1` frame conventions (`docs/zk-conformance-vectors.md`) extend cleanly to a redaction proof — @@ -39,14 +42,15 @@ introducing a parallel lineage representation. ## Phasing -- Phase 0 (this doc): scope, threat-model delta, public/private input table. +- Phase 0: scope, threat-model delta, and public/private input table — done. - Phase 1: Noir prototype circuit + unit tests (`nargo test`) + adversarial - vectors (crop substitution, reordered chunks, altered visible regions, - wrong parent, replay, malformed proof). -- Phase 2: cross-layer conformance codec entry (backend, browser, contract) - following `docs/zk-conformance-vectors.md`'s one-codec-three-layers model. -- Phase 3: backend endpoint + browser proving integration + artifact - versioning. + vectors — done for bounded commitment-level lineage. +- Phase 2: `redaction_witness/v1` cross-layer conformance codec entry + (backend, browser, contract) — done; it follows + `docs/zk-conformance-vectors.md`'s one-codec-three-layers model. +- Phase 3: backend endpoint, browser proving integration, compiled-artifact + versioning, and local proof verification — deferred. A structural codec + check must not be represented as cryptographic proof verification. - Phase 4: Soroban verification planning doc (not full deployment — see issue's out-of-scope section). - Phase 5: security docs (assumptions, unsupported transformations), diff --git a/docs/zk-redaction-lineage-spec.md b/docs/zk-redaction-lineage-spec.md new file mode 100644 index 0000000..725a92b --- /dev/null +++ b/docs/zk-redaction-lineage-spec.md @@ -0,0 +1,64 @@ +# Redaction Lineage Witness -- Formal Statement Specification + +## 1. Summary + +`redaction_lineage` proves that a committed derivative was made from a +previously registered evidence commitment using one permitted lineage operation, +without revealing source chunks, removed regions, transformation settings, or +blinding factors. It is a bounded prototype: `MAX_CHUNKS = 4`, matching the +lineage graph fan-out limit, and it does not enable contract verification. + +The source is bound by the parent's registered `silent_witness` commitment; +the circuit deliberately does **not** derive or expose `credential_root`. +Chunks are committed in order with a Pedersen accumulator, so substituting or +reordering a source chunk changes the parent commitment. The derivative +commitment binds that parent accumulator, the ordered visible chunks, the +private transformation-parameter digest, the operation, and a fresh blinding +factor. + +## 2. Public / Private Inputs + +| Input | Visibility | Type | +| --- | --- | --- | +| `parent_commitment` | public | Field; existing registered-evidence/silent-witness binding | +| `output_commitment` | public | Field; commitment to the derivative | +| `operation_type` | public | Field; 1 crop, 2 transcode, 3 blur, 4 redact, 5 compose | +| `replay_binding` | public | Field; application claim/manifest binding | +| `domain_tag` | public | Field; `redaction_witness/v1` domain tag | +| `parent_chunks`, `visible_chunks` | private | `[Field; MAX_CHUNKS]` ordered media commitments | +| `removed_descriptors` | private | `[Field; MAX_CHUNKS]`; zero means visible | +| `parameters_digest`, `blinding_factor` | private | Field | + +The hpx-vi/1 frame is five 32-byte canonical fields in the exact order above. +It contains no pixels, chunks, region coordinates, parameters, or secrets. + +## 3. Statement as enforced + +The circuit asserts all of the following: + +1. The operation is in the fixed allow-list `crop`, `transcode`, `blur`, + `redact`, or `compose`. +2. `parent_commitment` equals the ordered Pedersen accumulator of the supplied + parent chunks. Thus a wrong crop source or reordered chunks cannot satisfy + the proof. +3. Each output slot with a zero removed-descriptor is constrained to equal the + corresponding parent slot. A non-zero removed-descriptor marks a private + slot for which the prototype does not expose a source chunk. The current + circuit is a commitment-level redaction model; it does not yet encode + operation-specific pixel, crop, blur, or transcode semantics. +4. `output_commitment` is a domain-separated Pedersen commitment to the parent + commitment, operation, parameters digest, ordered visible chunks, replay + binding, and blinding factor. A proof cannot be replayed for another claim. +5. `domain_tag` is the fixed `redaction_witness/v1` tag, preventing use as a + silent-witness or revocation-witness proof. + +## 4. Known issues + +- This prototype proves commitment-level transformation lineage, not pixel-level + rendering correctness. Production support needs a media canonicalisation and + per-operation semantics standard before it can attest a particular encoder + implementation. +- The unresolved questions are explicitly deferred: canonical media decoder, + multi-parent compose semantics, and calibrated feasibility measurements. +- Live Soroban verification is intentionally out of scope; see + `contracts/VERIFIER_INTEGRATION.md`. diff --git a/docs/zk-reproducible-builds.md b/docs/zk-reproducible-builds.md index 9e8dd5c..264c2ec 100644 --- a/docs/zk-reproducible-builds.md +++ b/docs/zk-reproducible-builds.md @@ -280,3 +280,11 @@ comment-only (rebuild and re-commit the manifest). - The lock file pins versions, not binary digests, of `nargo` and `bb`. Pinning installer digests would require an upstream distribution channel that publishes them. +# Redaction-lineage artifact + +`zk/noir/redaction_lineage` is included in the reproducible build circuit set. +Its generated ACIR is published only while the feature remains explicitly +disabled by default and after the pinned Noir/Barretenberg toolchain has +compiled it twice with matching normalized digests. The browser loads the +artifact from `frontend/public/noir/redaction_lineage.json` and performs local +UltraHonk verification before it can submit a lineage claim. diff --git a/frontend/src/redactionLineage.test.ts b/frontend/src/redactionLineage.test.ts new file mode 100644 index 0000000..293b5c2 --- /dev/null +++ b/frontend/src/redactionLineage.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { createTransformationManifest } from './lineageManifest' +import { redactionReplayBinding } from './redactionLineage' + +const manifest = createTransformationManifest({ + parentProofIds: ['a'.repeat(64)], + operationType: 'redact', + parametersDigest: 'c'.repeat(64), + toolIdentity: 'harpocrates-studio', + toolVersion: '1.2.3', + outputDigest: 'd'.repeat(64), + network: 'testnet', + actorAddress: 'GABC123', +}) + +describe('redaction replay binding', () => { + it('is deterministic and changes with the claim', async () => { + const first = await redactionReplayBinding(manifest) + const second = await redactionReplayBinding(manifest) + const changed = await redactionReplayBinding({ ...manifest, outputDigest: 'e'.repeat(64) }) + expect(first).toBe(second) + expect(first).not.toBe(changed) + }) +}) diff --git a/frontend/src/redactionLineage.ts b/frontend/src/redactionLineage.ts new file mode 100644 index 0000000..13a4fa0 --- /dev/null +++ b/frontend/src/redactionLineage.ts @@ -0,0 +1,156 @@ +import { UltraHonkBackend } from '@aztec/bb.js' +import { Noir } from '@noir-lang/noir_js' +import type { CompiledCircuit } from '@noir-lang/types' + +import type { TransformationManifest } from './lineageManifest' +import { + REDACTION_WITNESS_DOMAIN_TAG_HEX, + SCHEMA_REDACTION_WITNESS, + parseRedactionWitnessInputs, +} from './verifierInputs' + +const MAX_CHUNKS = 4 +const REPLAY_DOMAIN = 'harpocrates:redaction-lineage:v1:' +const BN254_SCALAR_FIELD_MODULUS = + 21888242871839275222246405745257275088548364400416034343698204186575808495617n + +const OPERATION_CODES: Record = { + crop: '1', + transcode: '2', + blur: '3', + redact: '4', + compose: '5', +} + +export type RedactionLineagePrivateWitness = { + parentChunks: readonly string[] + visibleChunks: readonly string[] + removedDescriptors: readonly string[] + parametersDigest: string + blindingFactor: string +} + +export type RedactionLineageProof = { + schema: typeof SCHEMA_REDACTION_WITNESS + proof: string + publicInputs: string + proofBytes: number + publicInputBytes: number +} + +let circuitPromise: Promise | null = null + +/** Canonical claim binding shared with ``backend.lineage``. */ +export async function redactionReplayBinding(manifest: TransformationManifest): Promise { + const canonical = JSON.stringify(manifest, Object.keys(manifest).sort()) + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(`${REPLAY_DOMAIN}${canonical}`), + ) + let value = 0n + for (const byte of new Uint8Array(digest)) value = (value << 8n) | BigInt(byte) + return (value % BN254_SCALAR_FIELD_MODULUS).toString(10) +} + +/** + * Generate and locally verify a bounded redaction-lineage proof. + * + * The caller owns the witness and must keep it out of persistence and logs. + * A cancelled request is checked before and after proving; bb.js itself cannot + * be interrupted mid-proof, so callers should terminate their worker to make + * cancellation immediate. + */ +export async function generateRedactionLineageProof( + manifest: TransformationManifest, + privateWitness: RedactionLineagePrivateWitness, + publicCommitments: { parentCommitment: string; outputCommitment: string }, + signal?: AbortSignal, +): Promise { + ensureWitnessShape(privateWitness) + throwIfAborted(signal) + + const replayBinding = await redactionReplayBinding(manifest) + const circuit = await loadCircuit() + throwIfAborted(signal) + const publicInputs = { + parent_commitment: publicCommitments.parentCommitment, + output_commitment: publicCommitments.outputCommitment, + operation_type: OPERATION_CODES[manifest.operationType], + replay_binding: replayBinding, + domain_tag: BigInt(`0x${REDACTION_WITNESS_DOMAIN_TAG_HEX}`).toString(10), + } + + const privateInputs = { + parent_chunks: [...privateWitness.parentChunks], + visible_chunks: [...privateWitness.visibleChunks], + removed_descriptors: [...privateWitness.removedDescriptors], + parameters_digest: privateWitness.parametersDigest, + blinding_factor: privateWitness.blindingFactor, + } + + const backend = new UltraHonkBackend(circuit.bytecode) + try { + const { witness } = await new Noir(circuit).execute({ ...privateInputs, ...publicInputs }) + throwIfAborted(signal) + const proofData = await backend.generateProof(witness, { keccak: true }) + const verified = await backend.verifyProof(proofData, { keccak: true }) + if (!verified) throw new Error('Local redaction-lineage proof verification failed.') + + const publicInputHex = proofData.publicInputs.map(fieldToBytes32Hex).join('') + // Reject a malformed artifact or accidental public-input ordering drift + // before anything may be submitted to a backend or registry. + parseRedactionWitnessInputs(hexToBytes(publicInputHex)) + return { + schema: SCHEMA_REDACTION_WITNESS, + proof: bytesToHex(proofData.proof), + publicInputs: publicInputHex, + proofBytes: proofData.proof.length, + publicInputBytes: publicInputHex.length / 2, + } + } finally { + await backend.destroy() + // Drop references promptly. JavaScript cannot guarantee physical memory + // zeroization for strings, so worker callers should use transferable + // buffers for sensitive source material. + privateInputs.parent_chunks.fill('0') + privateInputs.visible_chunks.fill('0') + privateInputs.removed_descriptors.fill('0') + privateInputs.parameters_digest = '0' + privateInputs.blinding_factor = '0' + } +} + +function ensureWitnessShape(witness: RedactionLineagePrivateWitness): void { + for (const values of [witness.parentChunks, witness.visibleChunks, witness.removedDescriptors]) { + if (values.length !== MAX_CHUNKS) throw new Error(`Redaction lineage requires exactly ${MAX_CHUNKS} chunks.`) + } +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) throw new DOMException('Redaction proof generation cancelled.', 'AbortError') +} + +async function loadCircuit(): Promise { + circuitPromise ??= (async () => { + const response = await fetch('/noir/redaction_lineage.json', { cache: 'no-store' }) + if (!response.ok) throw new Error('Redaction-lineage circuit artifact is unavailable.') + return (await response.json()) as CompiledCircuit + })() + return circuitPromise +} + +function fieldToBytes32Hex(value: string): string { + const normalized = value.startsWith('0x') ? value.slice(2) : BigInt(value).toString(16) + if (normalized.length > 64) throw new Error('Noir field is larger than 32 bytes.') + return normalized.padStart(64, '0') +} + +function hexToBytes(value: string): Uint8Array { + const out = new Uint8Array(value.length / 2) + for (let index = 0; index < out.length; index += 1) out[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16) + return out +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} diff --git a/frontend/src/verifierInputs.ts b/frontend/src/verifierInputs.ts index eb185b4..917a4b0 100644 --- a/frontend/src/verifierInputs.ts +++ b/frontend/src/verifierInputs.ts @@ -18,8 +18,14 @@ export const CODEC_ID = 'hpx-vi/1' export const FIELD_LEN = 32 -export const FIELD_COUNT = 4 -export const PUBLIC_INPUTS_LEN = FIELD_LEN * FIELD_COUNT +export const SILENT_WITNESS_FIELD_COUNT = 4 +export const REVOCATION_FIELD_COUNT = 4 +export const REDACTION_FIELD_COUNT = 5 + +export const SILENT_WITNESS_PUBLIC_INPUTS_LEN = FIELD_LEN * SILENT_WITNESS_FIELD_COUNT // 128 +export const REVOCATION_PUBLIC_INPUTS_LEN = FIELD_LEN * REVOCATION_FIELD_COUNT // 128 +export const REDACTION_PUBLIC_INPUTS_LEN = FIELD_LEN * REDACTION_FIELD_COUNT // 160 +export const PUBLIC_INPUTS_LEN = SILENT_WITNESS_PUBLIC_INPUTS_LEN // 128 — used by fuzz harness (smallest schema frame) export const MIN_PROOF_BYTES = 64 export const MAX_PROOF_BYTES = 65536 @@ -42,12 +48,21 @@ export const BN254_SCALAR_FIELD_MODULUS = export const REVOCATION_DOMAIN_SEPARATOR_HEX = '00000000000000484152504f4352415445535f5245564f434154494f4e5f5631' +/** + * Byte-for-byte identical to Noir domain tag in redaction lineage circuit: + * eight bytes of BN254 padding followed by 24 ASCII bytes. + */ +export const REDACTION_WITNESS_DOMAIN_TAG_HEX = + '0000000000000000484152504f4352415445535f524544414354494f4e5f5631' + export const SCHEMA_SILENT_WITNESS = 'silent_witness/v1' export const SCHEMA_REVOCATION_WITNESS = 'revocation_witness/v1' +export const SCHEMA_REDACTION_WITNESS = 'redaction_witness/v1' export type VerifierSchema = | typeof SCHEMA_SILENT_WITNESS | typeof SCHEMA_REVOCATION_WITNESS + | typeof SCHEMA_REDACTION_WITNESS export type RejectCode = | 'malformed_hex' @@ -95,6 +110,14 @@ export type RevocationWitnessInputs = { credentialRoot: Uint8Array } +export type RedactionWitnessInputs = { + parentCommitment: Uint8Array + outputCommitment: Uint8Array + operationType: Uint8Array + replayBinding: Uint8Array + domainTag: Uint8Array +} + const HEX_PATTERN = /^[0-9a-fA-F]*$/ /** @@ -146,12 +169,12 @@ export function checkProofBounds(proof: Uint8Array): void { } } -function splitFields(publicInputs: Uint8Array): Uint8Array[] { - if (publicInputs.length !== PUBLIC_INPUTS_LEN) { +function splitFields(publicInputs: Uint8Array, expectedLen: number, count: number): Uint8Array[] { + if (publicInputs.length !== expectedLen) { throw new VerifierInputError('length', 'public_inputs') } const fields: Uint8Array[] = [] - for (let index = 0; index < FIELD_COUNT; index += 1) { + for (let index = 0; index < count; index += 1) { fields.push(publicInputs.slice(index * FIELD_LEN, (index + 1) * FIELD_LEN)) } return fields @@ -202,9 +225,17 @@ const REVOCATION_FIELDS = [ 'credential_root', ] as const +const REDACTION_FIELDS = [ + 'parent_commitment', + 'output_commitment', + 'operation_type', + 'replay_binding', + 'domain_tag', +] as const + /** Parse `silent_witness/v1` public inputs in canonical check order. */ export function parseSilentWitnessInputs(publicInputs: Uint8Array): SilentWitnessInputs { - const fields = splitFields(publicInputs) + const fields = splitFields(publicInputs, SILENT_WITNESS_PUBLIC_INPUTS_LEN, SILENT_WITNESS_FIELD_COUNT) const high = requireHalfPadding(fields[0], 'video_hash_hi') const low = requireHalfPadding(fields[1], 'video_hash_lo') @@ -225,7 +256,7 @@ export function parseSilentWitnessInputs(publicInputs: Uint8Array): SilentWitnes export function parseRevocationWitnessInputs( publicInputs: Uint8Array, ): RevocationWitnessInputs { - const fields = splitFields(publicInputs) + const fields = splitFields(publicInputs, REVOCATION_PUBLIC_INPUTS_LEN, REVOCATION_FIELD_COUNT) requireCanonical(fields, REVOCATION_FIELDS) @@ -249,17 +280,50 @@ export function parseRevocationWitnessInputs( } } +/** Parse `redaction_witness/v1` public inputs in canonical check order. */ +export function parseRedactionWitnessInputs( + publicInputs: Uint8Array, +): RedactionWitnessInputs { + const fields = splitFields(publicInputs, REDACTION_PUBLIC_INPUTS_LEN, REDACTION_FIELD_COUNT) + + requireCanonical(fields, REDACTION_FIELDS) + + requireNonZero(fields[0], 'parent_commitment') + requireNonZero(fields[1], 'output_commitment') + requireNonZero(fields[2], 'operation_type') + requireNonZero(fields[3], 'replay_binding') + + const expectedDomain = decodeHex(REDACTION_WITNESS_DOMAIN_TAG_HEX, 'domain_tag') + const domain = fields[4] + for (let index = 0; index < FIELD_LEN; index += 1) { + if (domain[index] !== expectedDomain[index]) { + throw new VerifierInputError('domain_mismatch', 'domain_tag') + } + } + + return { + parentCommitment: fields[0], + outputCommitment: fields[1], + operationType: fields[2], + replayBinding: fields[3], + domainTag: domain, + } +} + /** Dispatch to the parser for `schema`. */ export function parsePublicInputs( schema: string, publicInputs: Uint8Array, -): SilentWitnessInputs | RevocationWitnessInputs { +): SilentWitnessInputs | RevocationWitnessInputs | RedactionWitnessInputs { if (schema === SCHEMA_SILENT_WITNESS) { return parseSilentWitnessInputs(publicInputs) } if (schema === SCHEMA_REVOCATION_WITNESS) { return parseRevocationWitnessInputs(publicInputs) } + if (schema === SCHEMA_REDACTION_WITNESS) { + return parseRedactionWitnessInputs(publicInputs) + } throw new VerifierInputError('unknown_schema', 'schema') } diff --git a/zk/noir/redaction_lineage/Nargo.toml b/zk/noir/redaction_lineage/Nargo.toml new file mode 100644 index 0000000..02de69c --- /dev/null +++ b/zk/noir/redaction_lineage/Nargo.toml @@ -0,0 +1,7 @@ +[package] +name = "redaction_lineage" +type = "bin" +authors = ["Harpocrates"] +compiler_version = ">=1.0.0" + +[dependencies] diff --git a/zk/noir/redaction_lineage/src/main.nr b/zk/noir/redaction_lineage/src/main.nr new file mode 100644 index 0000000..b067324 --- /dev/null +++ b/zk/noir/redaction_lineage/src/main.nr @@ -0,0 +1,255 @@ +// Bounded redacted-derivative lineage circuit. All witness media and region +// data remains private; only commitments use the public verifier frame. +global CURRENT_CIRCUIT_VERSION: u32 = 1; +global MAX_CHUNKS: u32 = 4; +global REDACTION_WITNESS_V1: Field = 0x484152504f4352415445535f524544414354494f4e5f5631; +global PARENT_ACCUMULATOR_V1: Field = 0x504152454e545f414343554d554c41544f525f5631; +global OUTPUT_COMMITMENT_V1: Field = 0x4f55545055545f434f4d4d49544d454e545f5631; + +fn allowed_operation(operation: Field) -> bool { + (operation == 1) | (operation == 2) | (operation == 3) | (operation == 4) | (operation == 5) +} + +fn parent_accumulator(chunks: [Field; MAX_CHUNKS]) -> Field { + let mut accumulator = std::hash::pedersen_hash([PARENT_ACCUMULATOR_V1]); + for index in 0..MAX_CHUNKS { + accumulator = std::hash::pedersen_hash([accumulator, index as Field, chunks[index]]); + } + accumulator +} + +fn compute_output_commitment( + parent_commitment: Field, + operation_type: Field, + parameters_digest: Field, + replay_binding: Field, + blinding_factor: Field, + visible_chunks: [Field; MAX_CHUNKS], + removed_descriptors: [Field; MAX_CHUNKS], +) -> Field { + let mut output = std::hash::pedersen_hash([ + OUTPUT_COMMITMENT_V1, parent_commitment, operation_type, parameters_digest, replay_binding, blinding_factor, + ]); + for index in 0..MAX_CHUNKS { + output = std::hash::pedersen_hash([output, index as Field, visible_chunks[index], removed_descriptors[index]]); + } + output +} + +fn main( + parent_chunks: [Field; MAX_CHUNKS], + visible_chunks: [Field; MAX_CHUNKS], + removed_descriptors: [Field; MAX_CHUNKS], + parameters_digest: Field, + blinding_factor: Field, + parent_commitment: pub Field, + output_commitment: pub Field, + operation_type: pub Field, + replay_binding: pub Field, + domain_tag: pub Field, +) { + assert(CURRENT_CIRCUIT_VERSION == 1, "circuit version mismatch"); + assert(allowed_operation(operation_type), "operation is not allowed"); + assert(parent_accumulator(parent_chunks) == parent_commitment, "parent commitment mismatch"); + + let mut output = std::hash::pedersen_hash([ + OUTPUT_COMMITMENT_V1, parent_commitment, operation_type, parameters_digest, replay_binding, blinding_factor, + ]); + for index in 0..MAX_CHUNKS { + if removed_descriptors[index] == 0 { + assert(visible_chunks[index] == parent_chunks[index], "visible chunk altered"); + } else { + // A removed region has no visible chunk. Without this constraint an + // attacker could label an arbitrary replacement as "removed" and + // commit it as visible derivative content. + assert(visible_chunks[index] == 0, "removed chunk must be hidden"); + } + output = std::hash::pedersen_hash([output, index as Field, visible_chunks[index], removed_descriptors[index]]); + } + assert(output == output_commitment, "output commitment mismatch"); + assert(domain_tag == REDACTION_WITNESS_V1, "domain tag mismatch"); +} + +#[test] +fn valid_crop_lineage() { + let parents = [11, 12, 13, 14]; + let visible = [11, 12, 0, 0]; + let removed = [0, 0, 101, 102]; + let params: Field = 99; + let replay: Field = 77; + let blinding: Field = 55; + let op: Field = 1; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, visible, removed); + main(parents, visible, removed, params, blinding, parent, output, op, replay, REDACTION_WITNESS_V1); +} + +#[test] +fn valid_blur_lineage() { + let parents = [21, 22, 23, 24]; + let visible = [21, 0, 23, 24]; + let removed = [0, 201, 0, 0]; + let params: Field = 88; + let replay: Field = 66; + let blinding: Field = 44; + let op: Field = 3; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, visible, removed); + main(parents, visible, removed, params, blinding, parent, output, op, replay, REDACTION_WITNESS_V1); +} + +#[test] +fn valid_redact_lineage() { + let parents = [31, 32, 33, 34]; + let visible = [31, 32, 0, 34]; + let removed = [0, 0, 301, 0]; + let params: Field = 77; + let replay: Field = 55; + let blinding: Field = 33; + let op: Field = 4; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, visible, removed); + main(parents, visible, removed, params, blinding, parent, output, op, replay, REDACTION_WITNESS_V1); +} + +#[test] +fn valid_transcode_lineage() { + let parents = [41, 42, 43, 44]; + let visible = [41, 42, 43, 44]; + let removed = [0, 0, 0, 0]; + let params: Field = 66; + let replay: Field = 44; + let blinding: Field = 22; + let op: Field = 2; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, visible, removed); + main(parents, visible, removed, params, blinding, parent, output, op, replay, REDACTION_WITNESS_V1); +} + +#[test] +fn valid_compose_lineage() { + let parents = [51, 52, 53, 54]; + let visible = [51, 52, 53, 0]; + let removed = [0, 0, 0, 501]; + let params: Field = 55; + let replay: Field = 33; + let blinding: Field = 11; + let op: Field = 5; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, visible, removed); + main(parents, visible, removed, params, blinding, parent, output, op, replay, REDACTION_WITNESS_V1); +} + +#[test(should_fail_with = "parent commitment mismatch")] +fn rejects_reordered_chunks() { + let parents = [11, 12, 13, 14]; + let bad_parents = [12, 11, 13, 14]; + let removed = [0, 0, 0, 0]; + let params: Field = 99; + let replay: Field = 77; + let blinding: Field = 55; + let op: Field = 1; + let parent = parent_accumulator(parents); + let bad_parent = parent_accumulator(bad_parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, parents, removed); + main(parents, parents, removed, params, blinding, bad_parent, output, op, replay, REDACTION_WITNESS_V1); +} + +#[test(should_fail_with = "parent commitment mismatch")] +fn rejects_crop_substitution() { + let genuine_parents = [11, 12, 13, 14]; + let substituted_parents = [99, 12, 13, 14]; + let removed = [0, 0, 0, 0]; + let params: Field = 99; + let replay: Field = 77; + let blinding: Field = 55; + let op: Field = 1; + let genuine_parent = parent_accumulator(genuine_parents); + let output = compute_output_commitment(genuine_parent, op, params, replay, blinding, genuine_parents, removed); + main(substituted_parents, genuine_parents, removed, params, blinding, genuine_parent, output, op, replay, REDACTION_WITNESS_V1); +} + +#[test(should_fail_with = "visible chunk altered")] +fn rejects_altered_visible_region() { + let parents = [11, 12, 13, 14]; + let tampered_visible = [11, 99, 13, 14]; + let removed = [0, 0, 0, 0]; + let params: Field = 99; + let replay: Field = 77; + let blinding: Field = 55; + let op: Field = 1; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, tampered_visible, removed); + main(parents, tampered_visible, removed, params, blinding, parent, output, op, replay, REDACTION_WITNESS_V1); +} + +#[test(should_fail_with = "removed chunk must be hidden")] +fn rejects_replacement_disguised_as_removed_region() { + let parents = [11, 12, 13, 14]; + let replacement = [11, 99, 13, 14]; + let removed = [0, 401, 0, 0]; + let params: Field = 99; + let replay: Field = 77; + let blinding: Field = 55; + let op: Field = 4; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, replacement, removed); + main(parents, replacement, removed, params, blinding, parent, output, op, replay, REDACTION_WITNESS_V1); +} + +#[test(should_fail_with = "output commitment mismatch")] +fn rejects_replay_different_claim() { + let parents = [11, 12, 13, 14]; + let visible = [11, 12, 0, 0]; + let removed = [0, 0, 101, 102]; + let params: Field = 99; + let replay: Field = 77; + let bad_replay: Field = 88; + let blinding: Field = 55; + let op: Field = 1; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, visible, removed); + main(parents, visible, removed, params, blinding, parent, output, op, bad_replay, REDACTION_WITNESS_V1); +} + +#[test(should_fail_with = "operation is not allowed")] +fn rejects_unknown_operation() { + let parents = [1, 2, 3, 4]; + let removed = [0, 0, 0, 0]; + let params: Field = 1; + let replay: Field = 2; + let blinding: Field = 3; + let bad_op: Field = 9; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, bad_op, params, replay, blinding, parents, removed); + main(parents, parents, removed, params, blinding, parent, output, bad_op, replay, REDACTION_WITNESS_V1); +} + +#[test(should_fail_with = "domain tag mismatch")] +fn rejects_wrong_domain_tag() { + let parents = [11, 12, 13, 14]; + let visible = [11, 12, 0, 0]; + let removed = [0, 0, 101, 102]; + let params: Field = 99; + let replay: Field = 77; + let blinding: Field = 55; + let op: Field = 1; + let bad_tag: Field = 999; + let parent = parent_accumulator(parents); + let output = compute_output_commitment(parent, op, params, replay, blinding, visible, removed); + main(parents, visible, removed, params, blinding, parent, output, op, replay, bad_tag); +} +#[test(should_fail_with = "parent commitment mismatch")] +fn rejects_wrong_parent() { + let actual_parents = [11, 12, 13, 14]; + let unrelated_parents = [201, 202, 203, 204]; + let removed = [0, 0, 0, 0]; + let params: Field = 99; + let replay: Field = 77; + let blinding: Field = 55; + let op: Field = 1; + let actual_parent = parent_accumulator(actual_parents); + let unrelated_parent = parent_accumulator(unrelated_parents); + let output = compute_output_commitment(actual_parent, op, params, replay, blinding, actual_parents, removed); + main(actual_parents, actual_parents, removed, params, blinding, unrelated_parent, output, op, replay, REDACTION_WITNESS_V1); +} diff --git a/zk/noir/scripts/reproducible-build.sh b/zk/noir/scripts/reproducible-build.sh index da883cf..1eb8515 100644 --- a/zk/noir/scripts/reproducible-build.sh +++ b/zk/noir/scripts/reproducible-build.sh @@ -106,6 +106,7 @@ CIRCUITS=( "silent_witness_aggregator_helper" "revocation_witness" "revocation_witness_helper" + "redaction_lineage" ) build_once() { diff --git a/zk/toolchain.lock.json b/zk/toolchain.lock.json index da921cb..e63697e 100644 --- a/zk/toolchain.lock.json +++ b/zk/toolchain.lock.json @@ -124,6 +124,18 @@ "role": "acir", "required": false }, + { + "path": "zk/noir/redaction_lineage/target/redaction_lineage.json", + "kind": "json", + "role": "acir", + "required": false + }, + { + "path": "frontend/public/noir/redaction_lineage.json", + "kind": "json", + "role": "published_acir", + "required": false + }, { "path": "frontend/public/noir/silent_witness.json", "kind": "json", diff --git a/zk/vectors/generate_vectors.py b/zk/vectors/generate_vectors.py index edbba94..da0ed7b 100644 --- a/zk/vectors/generate_vectors.py +++ b/zk/vectors/generate_vectors.py @@ -44,6 +44,11 @@ # "HARPOCRATES_REVOCATION_V1". DOMAIN_HEX = ("00" * 7) + b"HARPOCRATES_REVOCATION_V1".hex() +# Redaction witness domain tag constant: +# 8 zero bytes of BN254 padding followed by the 24 ASCII bytes of +# "HARPOCRATES_REDACTION_V1". +REDACTION_DOMAIN_HEX = ("00" * 8) + b"HARPOCRATES_REDACTION_V1".hex() + ZERO = "00" * FIELD_LEN ONES = "ff" * FIELD_LEN @@ -56,6 +61,11 @@ NULLIFIER = "02" * FIELD_LEN REVOCATION_ROOT = "03" * FIELD_LEN +PARENT_COMMITMENT = "04" * FIELD_LEN +OUTPUT_COMMITMENT = "05" * FIELD_LEN +OPERATION_CROP = "00" * 31 + "01" +REPLAY_BINDING = "06" * FIELD_LEN + PROOF_MIN = "ab" * MIN_PROOF_BYTES PROOF_TYPICAL = "cd" * 512 @@ -68,8 +78,15 @@ def revocation(root: str, nullifier: str, domain: str, credential: str) -> str: return root + nullifier + domain + credential +def redaction(parent: str, output: str, operation: str, replay: str, domain: str) -> str: + return parent + output + operation + replay + domain + + SILENT_VALID = silent(VIDEO_HI, VIDEO_LO, CREDENTIAL_ROOT, NULLIFIER) REVOCATION_VALID = revocation(REVOCATION_ROOT, NULLIFIER, DOMAIN_HEX, CREDENTIAL_ROOT) +REDACTION_VALID = redaction( + PARENT_COMMITMENT, OUTPUT_COMMITMENT, OPERATION_CROP, REPLAY_BINDING, REDACTION_DOMAIN_HEX +) def case( @@ -153,6 +170,25 @@ def build_cases() -> list[dict[str, object]]: "ee" * MAX_PROOF_BYTES, ) ) + cases.append( + case( + "rd-pos-001-canonical", + "redaction_witness/v1", + "Canonical redaction lineage inputs with crop operation.", + REDACTION_VALID, + None, + ) + ) + cases.append( + case( + "rd-pos-002-typical-proof-size", + "redaction_witness/v1", + "Same redaction inputs with a typical proof size.", + REDACTION_VALID, + None, + PROOF_TYPICAL, + ) + ) # ---- length / framing ----------------------------------------------- cases.append( @@ -200,6 +236,33 @@ def build_cases() -> list[dict[str, object]]: "length", ) ) + cases.append( + case( + "rd-neg-001-empty", + "redaction_witness/v1", + "Empty redaction public inputs.", + "", + "length", + ) + ) + cases.append( + case( + "rd-neg-002-truncated", + "redaction_witness/v1", + "159 bytes: one byte short of a full redaction frame.", + REDACTION_VALID[:-2], + "length", + ) + ) + cases.append( + case( + "rd-neg-003-oversized", + "redaction_witness/v1", + "161 bytes: one trailing byte past the redaction frame.", + REDACTION_VALID + "00", + "length", + ) + ) # ---- padding invariants --------------------------------------------- cases.append( @@ -249,6 +312,17 @@ def build_cases() -> list[dict[str, object]]: "non_canonical_field", ) ) + cases.append( + case( + "rd-neg-020-parent-commitment-non-canonical", + "redaction_witness/v1", + "Non-canonical parent commitment element above modulus.", + redaction( + ONES, OUTPUT_COMMITMENT, OPERATION_CROP, REPLAY_BINDING, REDACTION_DOMAIN_HEX + ), + "non_canonical_field", + ) + ) # ---- zero identity fields ------------------------------------------- cases.append( @@ -278,6 +352,39 @@ def build_cases() -> list[dict[str, object]]: "zero_field", ) ) + cases.append( + case( + "rd-neg-030-zero-parent-commitment", + "redaction_witness/v1", + "A zero parent commitment is invalid.", + redaction( + ZERO, OUTPUT_COMMITMENT, OPERATION_CROP, REPLAY_BINDING, REDACTION_DOMAIN_HEX + ), + "zero_field", + ) + ) + cases.append( + case( + "rd-neg-031-zero-output-commitment", + "redaction_witness/v1", + "A zero output commitment is invalid.", + redaction( + PARENT_COMMITMENT, ZERO, OPERATION_CROP, REPLAY_BINDING, REDACTION_DOMAIN_HEX + ), + "zero_field", + ) + ) + cases.append( + case( + "rd-neg-032-zero-replay-binding", + "redaction_witness/v1", + "A zero replay binding disables claim uniqueness.", + redaction( + PARENT_COMMITMENT, OUTPUT_COMMITMENT, OPERATION_CROP, ZERO, REDACTION_DOMAIN_HEX + ), + "zero_field", + ) + ) # ---- domain binding -------------------------------------------------- cases.append( @@ -326,6 +433,17 @@ def build_cases() -> list[dict[str, object]]: "domain_mismatch", ) ) + cases.append( + case( + "rd-neg-040-domain-mismatch", + "redaction_witness/v1", + "Domain tag mismatch on redaction witness frame.", + redaction( + PARENT_COMMITMENT, OUTPUT_COMMITMENT, OPERATION_CROP, REPLAY_BINDING, ZERO + ), + "domain_mismatch", + ) + ) # ---- proof blob bounds ------------------------------------------------ cases.append( @@ -384,6 +502,7 @@ def build_document() -> dict[str, object]: "max_proof_bytes": MAX_PROOF_BYTES, "bn254_scalar_field_modulus_hex": BN254_R_HEX, "revocation_domain_separator_hex": DOMAIN_HEX, + "redaction_domain_tag_hex": REDACTION_DOMAIN_HEX, }, "schemas": { "silent_witness/v1": [ @@ -398,6 +517,13 @@ def build_document() -> dict[str, object]: "domain_separator", "credential_root", ], + "redaction_witness/v1": [ + "parent_commitment", + "output_commitment", + "operation_type", + "replay_binding", + "domain_tag", + ], }, "reject_codes": [ "length", diff --git a/zk/vectors/verifier_conformance_v1.json b/zk/vectors/verifier_conformance_v1.json index 58cac6d..9e4c8b7 100644 --- a/zk/vectors/verifier_conformance_v1.json +++ b/zk/vectors/verifier_conformance_v1.json @@ -10,7 +10,8 @@ "min_proof_bytes": 64, "max_proof_bytes": 65536, "bn254_scalar_field_modulus_hex": "30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001", - "revocation_domain_separator_hex": "00000000000000484152504f4352415445535f5245564f434154494f4e5f5631" + "revocation_domain_separator_hex": "00000000000000484152504f4352415445535f5245564f434154494f4e5f5631", + "redaction_domain_tag_hex": "0000000000000000484152504f4352415445535f524544414354494f4e5f5631" }, "schemas": { "silent_witness/v1": [ @@ -24,6 +25,13 @@ "nullifier", "domain_separator", "credential_root" + ], + "redaction_witness/v1": [ + "parent_commitment", + "output_commitment", + "operation_type", + "replay_binding", + "domain_tag" ] }, "reject_codes": [ @@ -103,6 +111,28 @@ "reject_code": null } }, + { + "id": "rd-pos-001-canonical", + "schema": "redaction_witness/v1", + "description": "Canonical redaction lineage inputs with crop operation.", + "public_inputs_hex": "04040404040404040404040404040404040404040404040404040404040404040505050505050505050505050505050505050505050505050505050505050505000000000000000000000000000000000000000000000000000000000000000106060606060606060606060606060606060606060606060606060606060606060000000000000000484152504f4352415445535f524544414354494f4e5f5631", + "proof_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "expect": { + "accept": true, + "reject_code": null + } + }, + { + "id": "rd-pos-002-typical-proof-size", + "schema": "redaction_witness/v1", + "description": "Same redaction inputs with a typical proof size.", + "public_inputs_hex": "04040404040404040404040404040404040404040404040404040404040404040505050505050505050505050505050505050505050505050505050505050505000000000000000000000000000000000000000000000000000000000000000106060606060606060606060606060606060606060606060606060606060606060000000000000000484152504f4352415445535f524544414354494f4e5f5631", + "proof_hex": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "expect": { + "accept": true, + "reject_code": null + } + }, { "id": "sw-neg-001-empty", "schema": "silent_witness/v1", @@ -158,6 +188,39 @@ "reject_code": "length" } }, + { + "id": "rd-neg-001-empty", + "schema": "redaction_witness/v1", + "description": "Empty redaction public inputs.", + "public_inputs_hex": "", + "proof_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "expect": { + "accept": false, + "reject_code": "length" + } + }, + { + "id": "rd-neg-002-truncated", + "schema": "redaction_witness/v1", + "description": "159 bytes: one byte short of a full redaction frame.", + "public_inputs_hex": "04040404040404040404040404040404040404040404040404040404040404040505050505050505050505050505050505050505050505050505050505050505000000000000000000000000000000000000000000000000000000000000000106060606060606060606060606060606060606060606060606060606060606060000000000000000484152504f4352415445535f524544414354494f4e5f56", + "proof_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "expect": { + "accept": false, + "reject_code": "length" + } + }, + { + "id": "rd-neg-003-oversized", + "schema": "redaction_witness/v1", + "description": "161 bytes: one trailing byte past the redaction frame.", + "public_inputs_hex": "04040404040404040404040404040404040404040404040404040404040404040505050505050505050505050505050505050505050505050505050505050505000000000000000000000000000000000000000000000000000000000000000106060606060606060606060606060606060606060606060606060606060606060000000000000000484152504f4352415445535f524544414354494f4e5f563100", + "proof_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "expect": { + "accept": false, + "reject_code": "length" + } + }, { "id": "sw-neg-010-hi-padding-dirty", "schema": "silent_witness/v1", @@ -213,6 +276,17 @@ "reject_code": "non_canonical_field" } }, + { + "id": "rd-neg-020-parent-commitment-non-canonical", + "schema": "redaction_witness/v1", + "description": "Non-canonical parent commitment element above modulus.", + "public_inputs_hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0505050505050505050505050505050505050505050505050505050505050505000000000000000000000000000000000000000000000000000000000000000106060606060606060606060606060606060606060606060606060606060606060000000000000000484152504f4352415445535f524544414354494f4e5f5631", + "proof_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "expect": { + "accept": false, + "reject_code": "non_canonical_field" + } + }, { "id": "sw-neg-030-zero-nullifier", "schema": "silent_witness/v1", @@ -246,6 +320,39 @@ "reject_code": "zero_field" } }, + { + "id": "rd-neg-030-zero-parent-commitment", + "schema": "redaction_witness/v1", + "description": "A zero parent commitment is invalid.", + "public_inputs_hex": "00000000000000000000000000000000000000000000000000000000000000000505050505050505050505050505050505050505050505050505050505050505000000000000000000000000000000000000000000000000000000000000000106060606060606060606060606060606060606060606060606060606060606060000000000000000484152504f4352415445535f524544414354494f4e5f5631", + "proof_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "expect": { + "accept": false, + "reject_code": "zero_field" + } + }, + { + "id": "rd-neg-031-zero-output-commitment", + "schema": "redaction_witness/v1", + "description": "A zero output commitment is invalid.", + "public_inputs_hex": "04040404040404040404040404040404040404040404040404040404040404040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000106060606060606060606060606060606060606060606060606060606060606060000000000000000484152504f4352415445535f524544414354494f4e5f5631", + "proof_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "expect": { + "accept": false, + "reject_code": "zero_field" + } + }, + { + "id": "rd-neg-032-zero-replay-binding", + "schema": "redaction_witness/v1", + "description": "A zero replay binding disables claim uniqueness.", + "public_inputs_hex": "04040404040404040404040404040404040404040404040404040404040404040505050505050505050505050505050505050505050505050505050505050505000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000484152504f4352415445535f524544414354494f4e5f5631", + "proof_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "expect": { + "accept": false, + "reject_code": "zero_field" + } + }, { "id": "rv-neg-040-zero-domain", "schema": "revocation_witness/v1", @@ -290,6 +397,17 @@ "reject_code": "domain_mismatch" } }, + { + "id": "rd-neg-040-domain-mismatch", + "schema": "redaction_witness/v1", + "description": "Domain tag mismatch on redaction witness frame.", + "public_inputs_hex": "04040404040404040404040404040404040404040404040404040404040404040505050505050505050505050505050505050505050505050505050505050505000000000000000000000000000000000000000000000000000000000000000106060606060606060606060606060606060606060606060606060606060606060000000000000000000000000000000000000000000000000000000000000000", + "proof_hex": "abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab", + "expect": { + "accept": false, + "reject_code": "domain_mismatch" + } + }, { "id": "sw-neg-050-empty-proof", "schema": "silent_witness/v1",