2026-07-03 12:09:34 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from .postquantum import detect_ml_dsa
|
2026-07-06 13:21:17 +00:00
|
|
|
from .signing import canonical_json, public_key_fingerprint, sign_payload_ed25519_detailed, verify_payload_ed25519_detailed
|
2026-07-03 12:09:34 +00:00
|
|
|
from .yamlio import load_data
|
|
|
|
|
|
|
|
|
|
HASH_ALGORITHM = "RFC9162_SHA256"
|
|
|
|
|
LEAF_TYPE = "pacta.transparency.attestation_leaf.v1"
|
|
|
|
|
RECEIPT_TYPE = "pacta.transparency.receipt.v1"
|
|
|
|
|
STH_TYPE = "pacta.transparency.signed_tree_head.v1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(slots=True)
|
|
|
|
|
class ReceiptVerificationResult:
|
|
|
|
|
accepted: bool
|
|
|
|
|
diagnostics: list[str] = field(default_factory=list)
|
|
|
|
|
log_id: str | None = None
|
|
|
|
|
tree_size: int | None = None
|
|
|
|
|
root_hash: str | None = None
|
|
|
|
|
leaf_hash: str | None = None
|
|
|
|
|
signatures: dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
def evidence(self) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"transparency_receipt_status": "accepted" if self.accepted else "rejected",
|
|
|
|
|
"transparency_log_id": self.log_id,
|
|
|
|
|
"transparency_tree_size": self.tree_size,
|
|
|
|
|
"transparency_root_hash": self.root_hash,
|
|
|
|
|
"transparency_leaf_hash": self.leaf_hash,
|
|
|
|
|
"transparency_signature_status": self.signatures,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def leaf_hash(data: bytes) -> bytes:
|
|
|
|
|
return hashlib.sha256(b"\x00" + data).digest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def node_hash(left: bytes, right: bytes) -> bytes:
|
|
|
|
|
return hashlib.sha256(b"\x01" + left + right).digest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def merkle_root(leaves: list[bytes]) -> bytes:
|
|
|
|
|
if not leaves:
|
|
|
|
|
return hashlib.sha256(b"").digest()
|
|
|
|
|
if len(leaves) == 1:
|
|
|
|
|
return leaf_hash(leaves[0])
|
|
|
|
|
split = _largest_power_of_two_less_than(len(leaves))
|
|
|
|
|
return node_hash(merkle_root(leaves[:split]), merkle_root(leaves[split:]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def inclusion_proof(leaves: list[bytes], index: int) -> list[bytes]:
|
|
|
|
|
if index < 0 or index >= len(leaves):
|
|
|
|
|
raise ValueError(f"Leaf index {index} is outside tree size {len(leaves)}")
|
|
|
|
|
return _inclusion_path(leaves, index)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_inclusion(leaf: bytes, index: int, tree_size: int, proof: list[bytes], root_hash: bytes) -> bool:
|
|
|
|
|
if tree_size <= 0 or index < 0 or index >= tree_size:
|
|
|
|
|
return False
|
|
|
|
|
try:
|
|
|
|
|
calculated, consumed = _calculate_inclusion_root(leaf_hash(leaf), index, tree_size, proof, 0)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return False
|
|
|
|
|
return consumed == len(proof) and calculated == root_hash
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def consistency_proof(leaves: list[bytes], old_tree_size: int) -> list[bytes]:
|
|
|
|
|
if old_tree_size < 0 or old_tree_size > len(leaves):
|
|
|
|
|
raise ValueError("old_tree_size must be between 0 and the current tree size")
|
|
|
|
|
if old_tree_size in (0, len(leaves)):
|
|
|
|
|
return []
|
|
|
|
|
return _consistency_path(old_tree_size, leaves, complete=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_consistency(
|
|
|
|
|
old_tree_size: int,
|
|
|
|
|
new_tree_size: int,
|
|
|
|
|
old_root_hash: bytes,
|
|
|
|
|
new_root_hash: bytes,
|
|
|
|
|
proof: list[bytes],
|
|
|
|
|
) -> bool:
|
|
|
|
|
if old_tree_size < 0 or new_tree_size < old_tree_size:
|
|
|
|
|
return False
|
|
|
|
|
if old_tree_size == 0:
|
|
|
|
|
return True
|
|
|
|
|
if old_tree_size == new_tree_size:
|
|
|
|
|
return old_root_hash == new_root_hash and not proof
|
|
|
|
|
if not proof:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
nodes = list(proof)
|
|
|
|
|
if _is_power_of_two(old_tree_size):
|
|
|
|
|
nodes.insert(0, old_root_hash)
|
|
|
|
|
fn = old_tree_size - 1
|
|
|
|
|
sn = new_tree_size - 1
|
|
|
|
|
while fn & 1:
|
|
|
|
|
fn >>= 1
|
|
|
|
|
sn >>= 1
|
|
|
|
|
|
|
|
|
|
old_hash = nodes.pop(0)
|
|
|
|
|
new_hash = old_hash
|
|
|
|
|
|
|
|
|
|
while nodes:
|
|
|
|
|
node = nodes.pop(0)
|
|
|
|
|
if sn == 0:
|
|
|
|
|
return False
|
|
|
|
|
if fn & 1 or fn == sn:
|
|
|
|
|
old_hash = node_hash(node, old_hash)
|
|
|
|
|
new_hash = node_hash(node, new_hash)
|
|
|
|
|
if not (fn & 1):
|
|
|
|
|
while fn & 1 == 0 and fn != 0:
|
|
|
|
|
fn >>= 1
|
|
|
|
|
sn >>= 1
|
|
|
|
|
else:
|
|
|
|
|
new_hash = node_hash(new_hash, node)
|
|
|
|
|
fn >>= 1
|
|
|
|
|
sn >>= 1
|
|
|
|
|
|
fix(verify_consistency): restore RFC 9162 Step-7 terminal sn==0 check
The deployed consistency verifier implemented the RFC 9162 2.1.4.2
bit-navigation loop but its final return checked only the two
reconstructed roots, omitting the terminal condition that the new-size
navigation counter reach zero. That condition couples the consumed proof
length to the claimed tree sizes; without it, a valid proof for one
transition verifies under a lied (power-of-two) old size. Flagship: a
valid 2->3 proof is accepted under the false claim 1->3 with the size-2
root.
Fix: add `and sn == 0` to the final return.
This is the corpus's Known Gap 14 (3,867 deployed-accepts-only cases in a
pinned 73,573-case family, recorded in public log entry 13). It was
found by the project's own differential harness; a post-appeal review
round added a faithful RFC oracle as a third comparison, which showed
the deployed verifier — not the mechanized model — was the one deviating
from RFC 9162, and traced it to the missing terminal check.
Scope: verify_consistency's only production caller is the consumer-side
pin store, reached only behind a verified head signature. Generation is
RFC-correct and unaffected; the live provider service does not run this
verifier; the published standalone verify.py has no consistency verifier.
An empirical search found 0 realizable pin-advance poisons against an
honestly pinned consumer, consistent with Known Gap 14's non-claim.
Verification:
- New fail-first three-way regression test
test_consistency_lied_size_three_way_agreement (deployed / recursive
ConsRec model / independent faithful RFC 9162 transliteration) over the
honest AND lied-size families; fails pre-fix, passes post-fix.
- Historical differential tests (164,479 inclusion; 164,224 consistency)
unchanged — the fix rejects nothing honest.
- Full suite: 145 passed, 0 failed.
Public log entry 13, the attested accumulator commit, and the IACR
submission PDF are all unchanged. Vulnerable state tagged
vulnerable/sn0-consistency-fd2f6ba. See
docs/security-2026-07-23-consistency-terminal-check.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:34:23 +00:00
|
|
|
# RFC 9162 2.1.4.2 Step 7 requires the new-size navigation counter to reach
|
|
|
|
|
# zero: the consumed proof length must match the claimed tree sizes. Without
|
|
|
|
|
# it, distinct (false) old-size claims can navigate one proof to the same
|
|
|
|
|
# reconstructed roots, so a valid proof for one transition verifies under a
|
|
|
|
|
# lied size. Reconstructing both roots is necessary but not sufficient.
|
|
|
|
|
return old_hash == old_root_hash and new_hash == new_root_hash and sn == 0
|
2026-07-03 12:09:34 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def attestation_leaf(attestation: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"type": LEAF_TYPE,
|
|
|
|
|
"attestation": attestation,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def leaf_bytes_for_attestation(attestation: dict[str, Any]) -> bytes:
|
|
|
|
|
return canonical_json(attestation_leaf(attestation))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def leaf_hash_hex_for_attestation(attestation: dict[str, Any]) -> str:
|
|
|
|
|
return leaf_hash(leaf_bytes_for_attestation(attestation)).hex()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def signed_tree_head_payload(sth: dict[str, Any]) -> bytes:
|
|
|
|
|
payload = {key: value for key, value in sth.items() if key != "signatures"}
|
|
|
|
|
return canonical_json(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_signed_tree_head(
|
|
|
|
|
log_id: str,
|
|
|
|
|
tree_size: int,
|
|
|
|
|
root_hash_hex: str,
|
|
|
|
|
timestamp: str,
|
|
|
|
|
private_key_path: str | Path,
|
|
|
|
|
public_key_path: str | Path,
|
2026-07-06 13:21:17 +00:00
|
|
|
signing_provenance: dict[str, Any] | None = None,
|
slhdsa: the post-quantum signing path (deterministic, parameter-locked, additive)
Phase 2b+3 of the step-3 rehearsal, under the four operator decisions of
2026-08-06: deterministic signing, separate slh_dsa block, additive posture,
keygen executed same day (key in provider state, 0600, git-ignored — verified
before generation, not after).
src/pacta/slhdsa.py — the module that did not exist (register:
pq-slot-names-unproven-algorithm). Parameter set LOCKED to SLH-DSA-SHA2-128s:
every entry point asserts the key's reported algorithm and refuses anything
else, because any other set sits outside all eleven certificates while looking
like dogfood. Deterministic via -pkeyopt deterministic:1, so the byte-level
reproducibility check that caught a real defect on the Ed25519 side survives
here. Verification runs two ways: OpenSSL, and pacta-verify-slhdsa built from
the pinned proven source — the one signature check in the estate performed by
code whose verify path the certificates cover. The proven-verifier path is
package-anchored, not cwd-relative: the lesson of signer-backend-depends-on-cwd
applied on day one, not retrofitted.
make_signed_tree_head grows optional slhdsa key parameters. With them, the head
carries a signed slh_dsa block; without, an honest not-configured slot exactly
as ml_dsa always has. ml_dsa itself is untouched. Signatures stay outside the
signed payload for both algorithms — tested by asserting the payload is
byte-identical with and without the slh_dsa key.
Honesty carried in the artifact: signing_backend says "openssl" because no
proven signer exists for any algorithm; the module docstring states that
nothing here is Lean-proven and that the certificates cover the verify path of
the extracted model only.
Tests: 7 new, suite 152 passed, 0 failed, 0 skipped — including determinism
(two signings, identical bytes), the foreign-key refusal (Ed25519 key raises),
corruption rejected by both verifiers, and the proven/OpenSSL agreement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 20:03:39 +00:00
|
|
|
slhdsa_private_key_path: str | Path | None = None,
|
|
|
|
|
slhdsa_public_key_path: str | Path | None = None,
|
2026-07-03 12:09:34 +00:00
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
sth: dict[str, Any] = {
|
|
|
|
|
"schema_version": 1,
|
|
|
|
|
"type": STH_TYPE,
|
|
|
|
|
"log_id": log_id,
|
|
|
|
|
"tree_size": tree_size,
|
|
|
|
|
"timestamp": timestamp,
|
|
|
|
|
"root_hash": root_hash_hex,
|
|
|
|
|
"hash_algorithm": HASH_ALGORITHM,
|
|
|
|
|
}
|
|
|
|
|
payload = signed_tree_head_payload(sth)
|
2026-07-06 13:21:17 +00:00
|
|
|
signature_base64, signing_backend = sign_payload_ed25519_detailed(payload, private_key_path)
|
slhdsa: the post-quantum signing path (deterministic, parameter-locked, additive)
Phase 2b+3 of the step-3 rehearsal, under the four operator decisions of
2026-08-06: deterministic signing, separate slh_dsa block, additive posture,
keygen executed same day (key in provider state, 0600, git-ignored — verified
before generation, not after).
src/pacta/slhdsa.py — the module that did not exist (register:
pq-slot-names-unproven-algorithm). Parameter set LOCKED to SLH-DSA-SHA2-128s:
every entry point asserts the key's reported algorithm and refuses anything
else, because any other set sits outside all eleven certificates while looking
like dogfood. Deterministic via -pkeyopt deterministic:1, so the byte-level
reproducibility check that caught a real defect on the Ed25519 side survives
here. Verification runs two ways: OpenSSL, and pacta-verify-slhdsa built from
the pinned proven source — the one signature check in the estate performed by
code whose verify path the certificates cover. The proven-verifier path is
package-anchored, not cwd-relative: the lesson of signer-backend-depends-on-cwd
applied on day one, not retrofitted.
make_signed_tree_head grows optional slhdsa key parameters. With them, the head
carries a signed slh_dsa block; without, an honest not-configured slot exactly
as ml_dsa always has. ml_dsa itself is untouched. Signatures stay outside the
signed payload for both algorithms — tested by asserting the payload is
byte-identical with and without the slh_dsa key.
Honesty carried in the artifact: signing_backend says "openssl" because no
proven signer exists for any algorithm; the module docstring states that
nothing here is Lean-proven and that the certificates cover the verify path of
the extracted model only.
Tests: 7 new, suite 152 passed, 0 failed, 0 skipped — including determinism
(two signings, identical bytes), the foreign-key refusal (Ed25519 key raises),
corruption rejected by both verifiers, and the proven/OpenSSL agreement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 20:03:39 +00:00
|
|
|
# slh_dsa is a SEPARATE block (operator decision 2026-08-06): ml_dsa keeps
|
|
|
|
|
# its truthful not-configured disclosure; no algorithm is swapped inside a
|
|
|
|
|
# field that names a different one. ADDITIVE: ed25519 remains the signature
|
|
|
|
|
# consumers must check; a head without an SLH-DSA key carries the honest
|
|
|
|
|
# not-configured slot, exactly as ml_dsa always has.
|
|
|
|
|
from .slhdsa import slh_dsa_not_configured_block, slh_dsa_signature_block
|
|
|
|
|
|
|
|
|
|
if slhdsa_private_key_path and slhdsa_public_key_path:
|
|
|
|
|
slh_block = slh_dsa_signature_block(payload, slhdsa_private_key_path, slhdsa_public_key_path)
|
|
|
|
|
else:
|
|
|
|
|
slh_block = slh_dsa_not_configured_block()
|
2026-07-03 12:09:34 +00:00
|
|
|
sth["signatures"] = {
|
|
|
|
|
"ed25519": {
|
|
|
|
|
"scheme": "openssl-ed25519",
|
2026-07-06 13:21:17 +00:00
|
|
|
"signing_backend": signing_backend,
|
2026-07-03 12:09:34 +00:00
|
|
|
"status": "signed",
|
|
|
|
|
"payload_digest_sha256": hashlib.sha256(payload).hexdigest(),
|
2026-07-06 13:21:17 +00:00
|
|
|
"signature_base64": signature_base64,
|
2026-07-03 12:09:34 +00:00
|
|
|
"public_key_fingerprint_sha256": public_key_fingerprint(public_key_path),
|
2026-07-06 13:21:17 +00:00
|
|
|
**({"signing_provenance": signing_provenance} if signing_provenance else {}),
|
2026-07-03 12:09:34 +00:00
|
|
|
},
|
|
|
|
|
"ml_dsa": detect_ml_dsa().to_signature_slot(),
|
slhdsa: the post-quantum signing path (deterministic, parameter-locked, additive)
Phase 2b+3 of the step-3 rehearsal, under the four operator decisions of
2026-08-06: deterministic signing, separate slh_dsa block, additive posture,
keygen executed same day (key in provider state, 0600, git-ignored — verified
before generation, not after).
src/pacta/slhdsa.py — the module that did not exist (register:
pq-slot-names-unproven-algorithm). Parameter set LOCKED to SLH-DSA-SHA2-128s:
every entry point asserts the key's reported algorithm and refuses anything
else, because any other set sits outside all eleven certificates while looking
like dogfood. Deterministic via -pkeyopt deterministic:1, so the byte-level
reproducibility check that caught a real defect on the Ed25519 side survives
here. Verification runs two ways: OpenSSL, and pacta-verify-slhdsa built from
the pinned proven source — the one signature check in the estate performed by
code whose verify path the certificates cover. The proven-verifier path is
package-anchored, not cwd-relative: the lesson of signer-backend-depends-on-cwd
applied on day one, not retrofitted.
make_signed_tree_head grows optional slhdsa key parameters. With them, the head
carries a signed slh_dsa block; without, an honest not-configured slot exactly
as ml_dsa always has. ml_dsa itself is untouched. Signatures stay outside the
signed payload for both algorithms — tested by asserting the payload is
byte-identical with and without the slh_dsa key.
Honesty carried in the artifact: signing_backend says "openssl" because no
proven signer exists for any algorithm; the module docstring states that
nothing here is Lean-proven and that the certificates cover the verify path of
the extracted model only.
Tests: 7 new, suite 152 passed, 0 failed, 0 skipped — including determinism
(two signings, identical bytes), the foreign-key refusal (Ed25519 key raises),
corruption rejected by both verifiers, and the proven/OpenSSL agreement.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 20:03:39 +00:00
|
|
|
"slh_dsa": slh_block,
|
2026-07-03 12:09:34 +00:00
|
|
|
}
|
|
|
|
|
return sth
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_signed_tree_head(
|
|
|
|
|
sth: dict[str, Any],
|
|
|
|
|
public_key_path: str | Path,
|
|
|
|
|
require_signatures: str = "ed25519",
|
|
|
|
|
) -> tuple[bool, list[str], dict[str, str]]:
|
|
|
|
|
diagnostics: list[str] = []
|
|
|
|
|
statuses: dict[str, str] = {}
|
|
|
|
|
if sth.get("type") != STH_TYPE:
|
|
|
|
|
diagnostics.append(f"Unexpected signed tree head type: {sth.get('type')}")
|
|
|
|
|
if sth.get("hash_algorithm") != HASH_ALGORITHM:
|
|
|
|
|
diagnostics.append(f"Unexpected hash algorithm: {sth.get('hash_algorithm')}")
|
|
|
|
|
|
|
|
|
|
signatures = sth.get("signatures") or {}
|
|
|
|
|
ed25519 = signatures.get("ed25519") or {}
|
|
|
|
|
if ed25519.get("scheme") != "openssl-ed25519" or ed25519.get("status") != "signed":
|
|
|
|
|
diagnostics.append("Signed tree head is missing a usable Ed25519 signature.")
|
|
|
|
|
statuses["ed25519"] = str(ed25519.get("status") or "missing")
|
|
|
|
|
else:
|
|
|
|
|
payload = signed_tree_head_payload(sth)
|
|
|
|
|
expected_digest = ed25519.get("payload_digest_sha256")
|
|
|
|
|
actual_digest = hashlib.sha256(payload).hexdigest()
|
|
|
|
|
if expected_digest and expected_digest != actual_digest:
|
|
|
|
|
diagnostics.append("Signed tree head digest does not match signature metadata.")
|
|
|
|
|
statuses["ed25519"] = "digest_mismatch"
|
|
|
|
|
else:
|
|
|
|
|
expected_fingerprint = ed25519.get("public_key_fingerprint_sha256")
|
|
|
|
|
if expected_fingerprint and expected_fingerprint != public_key_fingerprint(public_key_path):
|
|
|
|
|
diagnostics.append("Signed tree head Ed25519 public-key fingerprint mismatch.")
|
|
|
|
|
statuses["ed25519"] = "key_mismatch"
|
|
|
|
|
else:
|
Dogfood cryptography: pacta verifies signatures through the PROVEN code path
"Eat your own dogfood": pacta consumes certificates about a verified
Ed25519 implementation while checking those certificates' signatures
with OpenSSL. Now it can use the object of its own evidence:
- dogfood/pacta-verified-verify: a ~90-line Rust binary built against
the PINNED proven source workspace (saymrwulf/curve25519-dalek-source
at the exact commit the dalek certificates pin - the build records it:
aa0f6ab...) with the serial backend pinned via RUSTFLAGS exactly as
the verified extraction pins it. Cargo.toml is committed as a template
({{SOURCE}} placeholder) so no machine path is hardcoded; the rendered
file, target/, and the built binary are gitignored.
- pacta dogfood-build --source <workspace>: renders, builds, installs
to dogfood/state/, and writes a provenance sidecar (source commit,
backend cfg, rustc, and an honest coverage note: the certificates
cover verify_sha512, the extraction-refactored image of this verify
path; SHA-512 and the wire glue remain the theorems' documented
boundary). pacta dogfood-status reports the active backend.
- signing.verify_payload_ed25519_detailed: dispatch - the dogfood
binary when present (backend "verified-dalek-serial"), OpenSSL
fallback otherwise, and the backend that ACTUALLY ran is recorded in
receipt signature statuses and attestation evidence. Fallback is
never silent.
- --require-verified-verifier (receipt-verify + agent): policy fails
closed when verification did not run on the certificate-covered
path.
- ML-DSA is deliberately unchanged: no proven implementation exists,
so the slot stays fail-closed "unavailable" - the honest hybrid-PQC
posture is one proven-classical signature plus one required-but-
unproven PQC slot, never a pretend backend.
Validated live: receipt verification through the proven verifier
(backend recorded), a corrupted signature bit rejected BY the proven
binary, tampered attestations rejected, and the policy failing closed
when the binary is absent. 49/49 tests green (incl. PEM-SPKI raw-key
cross-check against openssl, dispatch/backend recording with a stub,
and a real-binary roundtrip that skips gracefully where unbuilt).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:13:48 +00:00
|
|
|
ok, error, backend = verify_payload_ed25519_detailed(payload, str(ed25519.get("signature_base64") or ""), public_key_path)
|
2026-07-03 12:09:34 +00:00
|
|
|
statuses["ed25519"] = "verified" if ok else "failed"
|
Dogfood cryptography: pacta verifies signatures through the PROVEN code path
"Eat your own dogfood": pacta consumes certificates about a verified
Ed25519 implementation while checking those certificates' signatures
with OpenSSL. Now it can use the object of its own evidence:
- dogfood/pacta-verified-verify: a ~90-line Rust binary built against
the PINNED proven source workspace (saymrwulf/curve25519-dalek-source
at the exact commit the dalek certificates pin - the build records it:
aa0f6ab...) with the serial backend pinned via RUSTFLAGS exactly as
the verified extraction pins it. Cargo.toml is committed as a template
({{SOURCE}} placeholder) so no machine path is hardcoded; the rendered
file, target/, and the built binary are gitignored.
- pacta dogfood-build --source <workspace>: renders, builds, installs
to dogfood/state/, and writes a provenance sidecar (source commit,
backend cfg, rustc, and an honest coverage note: the certificates
cover verify_sha512, the extraction-refactored image of this verify
path; SHA-512 and the wire glue remain the theorems' documented
boundary). pacta dogfood-status reports the active backend.
- signing.verify_payload_ed25519_detailed: dispatch - the dogfood
binary when present (backend "verified-dalek-serial"), OpenSSL
fallback otherwise, and the backend that ACTUALLY ran is recorded in
receipt signature statuses and attestation evidence. Fallback is
never silent.
- --require-verified-verifier (receipt-verify + agent): policy fails
closed when verification did not run on the certificate-covered
path.
- ML-DSA is deliberately unchanged: no proven implementation exists,
so the slot stays fail-closed "unavailable" - the honest hybrid-PQC
posture is one proven-classical signature plus one required-but-
unproven PQC slot, never a pretend backend.
Validated live: receipt verification through the proven verifier
(backend recorded), a corrupted signature bit rejected BY the proven
binary, tampered attestations rejected, and the policy failing closed
when the binary is absent. 49/49 tests green (incl. PEM-SPKI raw-key
cross-check against openssl, dispatch/backend recording with a stub,
and a real-binary roundtrip that skips gracefully where unbuilt).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:13:48 +00:00
|
|
|
statuses["ed25519_backend"] = backend
|
2026-07-03 12:09:34 +00:00
|
|
|
if not ok:
|
|
|
|
|
diagnostics.append(f"Signed tree head Ed25519 verification failed: {error}")
|
|
|
|
|
|
|
|
|
|
ml_dsa = signatures.get("ml_dsa") or {}
|
|
|
|
|
statuses["ml_dsa"] = str(ml_dsa.get("status") or "missing")
|
|
|
|
|
if require_signatures == "both" and statuses["ml_dsa"] != "verified":
|
|
|
|
|
diagnostics.append("ML-DSA signed tree head signature is required by policy but is not verified.")
|
|
|
|
|
elif require_signatures not in {"ed25519", "both"}:
|
|
|
|
|
diagnostics.append(f"Unsupported transparency signature policy: {require_signatures}")
|
|
|
|
|
return not diagnostics, diagnostics, statuses
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_receipt(path: str | Path) -> dict[str, Any]:
|
|
|
|
|
raw = load_data(path)
|
|
|
|
|
if not isinstance(raw, dict):
|
|
|
|
|
raise ValueError(f"Transparency receipt must be a mapping: {path}")
|
|
|
|
|
return raw
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_receipt(
|
|
|
|
|
attestation: dict[str, Any],
|
|
|
|
|
receipt: dict[str, Any],
|
|
|
|
|
log_public_key_path: str | Path,
|
|
|
|
|
require_signatures: str = "ed25519",
|
|
|
|
|
) -> ReceiptVerificationResult:
|
|
|
|
|
diagnostics: list[str] = []
|
|
|
|
|
if receipt.get("type") != RECEIPT_TYPE:
|
|
|
|
|
diagnostics.append(f"Unexpected transparency receipt type: {receipt.get('type')}")
|
|
|
|
|
if receipt.get("hash_algorithm") != HASH_ALGORITHM:
|
|
|
|
|
diagnostics.append(f"Unexpected transparency receipt hash algorithm: {receipt.get('hash_algorithm')}")
|
|
|
|
|
|
|
|
|
|
sth = receipt.get("sth") or {}
|
|
|
|
|
sth_ok, sth_diagnostics, statuses = verify_signed_tree_head(sth, log_public_key_path, require_signatures=require_signatures)
|
|
|
|
|
diagnostics.extend(sth_diagnostics)
|
|
|
|
|
try:
|
|
|
|
|
tree_size = int(receipt.get("tree_size"))
|
|
|
|
|
leaf_index = int(receipt.get("leaf_index"))
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
tree_size = -1
|
|
|
|
|
leaf_index = -1
|
|
|
|
|
diagnostics.append("Transparency receipt has invalid tree_size or leaf_index.")
|
|
|
|
|
|
|
|
|
|
if sth.get("tree_size") != tree_size:
|
|
|
|
|
diagnostics.append("Transparency receipt tree_size does not match signed tree head.")
|
|
|
|
|
if receipt.get("log_id") != sth.get("log_id"):
|
|
|
|
|
diagnostics.append("Transparency receipt log_id does not match signed tree head.")
|
|
|
|
|
|
|
|
|
|
leaf_bytes = leaf_bytes_for_attestation(attestation)
|
|
|
|
|
expected_leaf_hash = leaf_hash(leaf_bytes).hex()
|
|
|
|
|
if receipt.get("leaf_hash") != expected_leaf_hash:
|
|
|
|
|
diagnostics.append("Transparency receipt leaf hash does not match attestation.")
|
|
|
|
|
expected_attestation_digest = hashlib.sha256(canonical_json(attestation)).hexdigest()
|
|
|
|
|
if receipt.get("attestation_digest_sha256") and receipt.get("attestation_digest_sha256") != expected_attestation_digest:
|
|
|
|
|
diagnostics.append("Transparency receipt attestation digest does not match attestation.")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
root = bytes.fromhex(str(sth.get("root_hash") or ""))
|
|
|
|
|
proof = [bytes.fromhex(str(item)) for item in (receipt.get("inclusion_proof") or [])]
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
root = b""
|
|
|
|
|
proof = []
|
|
|
|
|
diagnostics.append(f"Transparency proof contains invalid hex: {exc}")
|
|
|
|
|
|
|
|
|
|
if root and tree_size >= 0:
|
|
|
|
|
if not verify_inclusion(leaf_bytes, leaf_index, tree_size, proof, root):
|
|
|
|
|
diagnostics.append("Transparency inclusion proof does not verify against the signed tree head.")
|
|
|
|
|
|
|
|
|
|
accepted = not diagnostics and sth_ok
|
|
|
|
|
return ReceiptVerificationResult(
|
|
|
|
|
accepted=accepted,
|
|
|
|
|
diagnostics=diagnostics,
|
|
|
|
|
log_id=str(receipt.get("log_id") or sth.get("log_id") or "") or None,
|
|
|
|
|
tree_size=tree_size if tree_size >= 0 else None,
|
|
|
|
|
root_hash=str(sth.get("root_hash") or "") or None,
|
|
|
|
|
leaf_hash=str(receipt.get("leaf_hash") or "") or None,
|
|
|
|
|
signatures=statuses,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def proof_to_hex(proof: list[bytes]) -> list[str]:
|
|
|
|
|
return [item.hex() for item in proof]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _inclusion_path(leaves: list[bytes], index: int) -> list[bytes]:
|
|
|
|
|
if len(leaves) == 1:
|
|
|
|
|
return []
|
|
|
|
|
split = _largest_power_of_two_less_than(len(leaves))
|
|
|
|
|
if index < split:
|
|
|
|
|
return _inclusion_path(leaves[:split], index) + [merkle_root(leaves[split:])]
|
|
|
|
|
return _inclusion_path(leaves[split:], index - split) + [merkle_root(leaves[:split])]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _calculate_inclusion_root(
|
|
|
|
|
current_hash: bytes,
|
|
|
|
|
index: int,
|
|
|
|
|
tree_size: int,
|
|
|
|
|
proof: list[bytes],
|
|
|
|
|
proof_index: int,
|
|
|
|
|
) -> tuple[bytes, int]:
|
|
|
|
|
if tree_size == 1:
|
|
|
|
|
return current_hash, proof_index
|
|
|
|
|
split = _largest_power_of_two_less_than(tree_size)
|
|
|
|
|
if index < split:
|
|
|
|
|
left, consumed = _calculate_inclusion_root(current_hash, index, split, proof, proof_index)
|
|
|
|
|
if consumed >= len(proof):
|
|
|
|
|
raise ValueError("proof exhausted")
|
|
|
|
|
return node_hash(left, proof[consumed]), consumed + 1
|
|
|
|
|
right, consumed = _calculate_inclusion_root(current_hash, index - split, tree_size - split, proof, proof_index)
|
|
|
|
|
if consumed >= len(proof):
|
|
|
|
|
raise ValueError("proof exhausted")
|
|
|
|
|
return node_hash(proof[consumed], right), consumed + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _consistency_path(old_tree_size: int, leaves: list[bytes], complete: bool) -> list[bytes]:
|
|
|
|
|
if old_tree_size == len(leaves):
|
|
|
|
|
return [] if complete else [merkle_root(leaves)]
|
|
|
|
|
split = _largest_power_of_two_less_than(len(leaves))
|
|
|
|
|
if old_tree_size <= split:
|
|
|
|
|
return _consistency_path(old_tree_size, leaves[:split], complete) + [merkle_root(leaves[split:])]
|
|
|
|
|
return _consistency_path(old_tree_size - split, leaves[split:], complete=False) + [merkle_root(leaves[:split])]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _largest_power_of_two_less_than(value: int) -> int:
|
|
|
|
|
if value <= 1:
|
|
|
|
|
raise ValueError("value must be greater than one")
|
|
|
|
|
return 1 << ((value - 1).bit_length() - 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_power_of_two(value: int) -> bool:
|
|
|
|
|
return value > 0 and value & (value - 1) == 0
|