Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
- [Frontend Scope Derivation](frontend/src/seedVault.ts)
18 changes: 18 additions & 0 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 |
Expand All @@ -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. |
10 changes: 10 additions & 0 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down
13 changes: 7 additions & 6 deletions backend/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions backend/lineage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions backend/migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
""",
),
]


Expand Down Expand Up @@ -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": [
Expand Down
38 changes: 38 additions & 0 deletions backend/test_lineage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading