diff --git a/dogfood/pacta-verified-verify/src/main.rs b/dogfood/pacta-verified-verify/src/main.rs index d491af9..0b1c084 100644 --- a/dogfood/pacta-verified-verify/src/main.rs +++ b/dogfood/pacta-verified-verify/src/main.rs @@ -13,7 +13,8 @@ //! Usage: pacta-verified-verify //! Exit 0 = signature valid; exit 1 = invalid; exit 2 = usage/format error. -use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use std::io::Read; use std::process::ExitCode; fn hex_decode(s: &str) -> Result, String> { @@ -25,10 +26,53 @@ fn hex_decode(s: &str) -> Result, String> { .collect() } +fn sign_mode(payload_path: &str) -> ExitCode { + // Signing mode: the 32-byte seed arrives on STDIN as hex (never argv - + // argv is world-readable in /proc). The signature is written to stdout + // as hex. NOTE the honesty ledger: this library's SOURCE is merkleized + // and attested in the transparency log, and its VERIFY path is + // certificate-covered; the signing path itself is NOT proven - it is + // declared trusted base, chosen because it is at least the attested + // artifact rather than an un-attested third implementation. + let mut seed_hex = String::new(); + if std::io::stdin().read_to_string(&mut seed_hex).is_err() { + eprintln!("error: cannot read seed from stdin"); + return ExitCode::from(2); + } + let seed = match hex_decode(seed_hex.trim()) { + Ok(b) if b.len() == 32 => b, + _ => { + eprintln!("error: seed must be 32 bytes of hex on stdin"); + return ExitCode::from(2); + } + }; + let payload = match std::fs::read(payload_path) { + Ok(p) => p, + Err(e) => { + eprintln!("error: cannot read payload: {e}"); + return ExitCode::from(2); + } + }; + let mut seed_array = [0u8; 32]; + seed_array.copy_from_slice(&seed); + let signing_key = SigningKey::from_bytes(&seed_array); + let signature = signing_key.sign(&payload); + println!("{}", hex_encode(&signature.to_bytes())); + ExitCode::SUCCESS +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + fn main() -> ExitCode { let args: Vec = std::env::args().collect(); + if args.len() == 3 && args[1] == "sign" { + return sign_mode(&args[2]); + } if args.len() != 4 { eprintln!("usage: pacta-verified-verify "); + eprintln!(" pacta-verified-verify sign (32-byte seed hex on stdin)"); return ExitCode::from(2); } let pk_bytes = match hex_decode(&args[1]) { diff --git a/evidence/anza-ed25519.receipt.yaml b/evidence/anza-ed25519.receipt.yaml index 5f06f9d..eceb5b8 100644 --- a/evidence/anza-ed25519.receipt.yaml +++ b/evidence/anza-ed25519.receipt.yaml @@ -21,16 +21,24 @@ sth: type: pacta.transparency.signed_tree_head.v1 log_id: 205e4c389cb143e08f0d2d58bdc8e425e47e3cbe7f2108cc58bbe835d2cc41d7 tree_size: 8 - timestamp: '2026-07-06T12:53:08Z' + timestamp: '2026-07-06T13:20:39Z' root_hash: 9a15b9a1379edc07ae43d3fc61b52dc4446b56770bff6538e88ed98746ac2283 hash_algorithm: RFC9162_SHA256 signatures: ed25519: scheme: openssl-ed25519 + signing_backend: verified-dalek-serial status: signed - payload_digest_sha256: d783b70d6124bd2206bed6671ce83e9b292dec41761527326695d5e23b12eb8b - signature_base64: 0Si0Mb46p5xmPu8EJp9wm/Cle3xClHYYRLBFksjnygVci8U+RE4uBNydU0dSQT7FrnJB+xdwCn5NBRnUnjaECg== + payload_digest_sha256: d915e3fd22b755768bc91e088259f42e6011e8fb98910aaa6ae4b93ce793255b + signature_base64: 30Dz9i50AsM9L44fQCGQJcnk84fzpIUuJznbJiumQuREZ2QdAfsY9EpnXiC+uHcTnN8yJ928WIeBcarsKebXBw== public_key_fingerprint_sha256: 874c8a008a607021528b2493fa1caf059f9d5c123d29193dfabc09a6d1e7a56a + signing_provenance: + signing_backend: verified-dalek-serial + signing_library_component: dalek-ed25519-verified + signing_library_source_commit: aa0f6abc327ba2a54a534b21608ca8996cf73682 + self_inclusion: verified + signing_library_leaf_index: 4 + signing_library_certificates_proven: 16/16 ml_dsa: scheme: ML-DSA-65 standard: FIPS 204 diff --git a/evidence/betrusted-ed25519.receipt.yaml b/evidence/betrusted-ed25519.receipt.yaml index 3def7df..d1446ed 100644 --- a/evidence/betrusted-ed25519.receipt.yaml +++ b/evidence/betrusted-ed25519.receipt.yaml @@ -21,16 +21,24 @@ sth: type: pacta.transparency.signed_tree_head.v1 log_id: 205e4c389cb143e08f0d2d58bdc8e425e47e3cbe7f2108cc58bbe835d2cc41d7 tree_size: 8 - timestamp: '2026-07-06T12:53:09Z' + timestamp: '2026-07-06T13:20:40Z' root_hash: 9a15b9a1379edc07ae43d3fc61b52dc4446b56770bff6538e88ed98746ac2283 hash_algorithm: RFC9162_SHA256 signatures: ed25519: scheme: openssl-ed25519 + signing_backend: verified-dalek-serial status: signed - payload_digest_sha256: d5d25db2c90f0246cc10d10f912085b81c60d2a324354b5abecfdbeb8bd17ee2 - signature_base64: sI4rWnWMrX0zDaN8jofBwSuExKg1e0cmZV3OPaulbXDV97yz95aSo0Gnrfd1En2vyyV1qoEXdTAY9NDgvA8xBA== + payload_digest_sha256: 72d9abbc5720f1c6f03ad6f6363a6c5587a2bfd46f67248282cdcfa8eeb8e1dd + signature_base64: Yz9T8akHXeiMJBKLgpoKTInfdV5WeuZgYzgWYEMkdaYS9NVKLpDjp1AcapJxUZ9Ft+2O2+n010yN7flODKqBBw== public_key_fingerprint_sha256: 874c8a008a607021528b2493fa1caf059f9d5c123d29193dfabc09a6d1e7a56a + signing_provenance: + signing_backend: verified-dalek-serial + signing_library_component: dalek-ed25519-verified + signing_library_source_commit: aa0f6abc327ba2a54a534b21608ca8996cf73682 + self_inclusion: verified + signing_library_leaf_index: 4 + signing_library_certificates_proven: 16/16 ml_dsa: scheme: ML-DSA-65 standard: FIPS 204 diff --git a/evidence/dalek-ed25519.receipt.yaml b/evidence/dalek-ed25519.receipt.yaml index 5906aa9..8a166b6 100644 --- a/evidence/dalek-ed25519.receipt.yaml +++ b/evidence/dalek-ed25519.receipt.yaml @@ -21,16 +21,24 @@ sth: type: pacta.transparency.signed_tree_head.v1 log_id: 205e4c389cb143e08f0d2d58bdc8e425e47e3cbe7f2108cc58bbe835d2cc41d7 tree_size: 8 - timestamp: '2026-07-06T12:53:08Z' + timestamp: '2026-07-06T13:20:39Z' root_hash: 9a15b9a1379edc07ae43d3fc61b52dc4446b56770bff6538e88ed98746ac2283 hash_algorithm: RFC9162_SHA256 signatures: ed25519: scheme: openssl-ed25519 + signing_backend: verified-dalek-serial status: signed - payload_digest_sha256: d783b70d6124bd2206bed6671ce83e9b292dec41761527326695d5e23b12eb8b - signature_base64: 0Si0Mb46p5xmPu8EJp9wm/Cle3xClHYYRLBFksjnygVci8U+RE4uBNydU0dSQT7FrnJB+xdwCn5NBRnUnjaECg== + payload_digest_sha256: d915e3fd22b755768bc91e088259f42e6011e8fb98910aaa6ae4b93ce793255b + signature_base64: 30Dz9i50AsM9L44fQCGQJcnk84fzpIUuJznbJiumQuREZ2QdAfsY9EpnXiC+uHcTnN8yJ928WIeBcarsKebXBw== public_key_fingerprint_sha256: 874c8a008a607021528b2493fa1caf059f9d5c123d29193dfabc09a6d1e7a56a + signing_provenance: + signing_backend: verified-dalek-serial + signing_library_component: dalek-ed25519-verified + signing_library_source_commit: aa0f6abc327ba2a54a534b21608ca8996cf73682 + self_inclusion: verified + signing_library_leaf_index: 4 + signing_library_certificates_proven: 16/16 ml_dsa: scheme: ML-DSA-65 standard: FIPS 204 diff --git a/evidence/latest-sth.yaml b/evidence/latest-sth.yaml index af83287..56dcc83 100644 --- a/evidence/latest-sth.yaml +++ b/evidence/latest-sth.yaml @@ -2,16 +2,24 @@ schema_version: 1 type: pacta.transparency.signed_tree_head.v1 log_id: 205e4c389cb143e08f0d2d58bdc8e425e47e3cbe7f2108cc58bbe835d2cc41d7 tree_size: 8 -timestamp: '2026-07-06T12:53:09Z' +timestamp: '2026-07-06T13:20:40Z' root_hash: 9a15b9a1379edc07ae43d3fc61b52dc4446b56770bff6538e88ed98746ac2283 hash_algorithm: RFC9162_SHA256 signatures: ed25519: scheme: openssl-ed25519 + signing_backend: verified-dalek-serial status: signed - payload_digest_sha256: d5d25db2c90f0246cc10d10f912085b81c60d2a324354b5abecfdbeb8bd17ee2 - signature_base64: sI4rWnWMrX0zDaN8jofBwSuExKg1e0cmZV3OPaulbXDV97yz95aSo0Gnrfd1En2vyyV1qoEXdTAY9NDgvA8xBA== + payload_digest_sha256: 72d9abbc5720f1c6f03ad6f6363a6c5587a2bfd46f67248282cdcfa8eeb8e1dd + signature_base64: Yz9T8akHXeiMJBKLgpoKTInfdV5WeuZgYzgWYEMkdaYS9NVKLpDjp1AcapJxUZ9Ft+2O2+n010yN7flODKqBBw== public_key_fingerprint_sha256: 874c8a008a607021528b2493fa1caf059f9d5c123d29193dfabc09a6d1e7a56a + signing_provenance: + signing_backend: verified-dalek-serial + signing_library_component: dalek-ed25519-verified + signing_library_source_commit: aa0f6abc327ba2a54a534b21608ca8996cf73682 + self_inclusion: verified + signing_library_leaf_index: 4 + signing_library_certificates_proven: 16/16 ml_dsa: scheme: ML-DSA-65 standard: FIPS 204 diff --git a/evidence/risc0-ed25519.receipt.yaml b/evidence/risc0-ed25519.receipt.yaml index e8f742f..9dcb84a 100644 --- a/evidence/risc0-ed25519.receipt.yaml +++ b/evidence/risc0-ed25519.receipt.yaml @@ -21,16 +21,24 @@ sth: type: pacta.transparency.signed_tree_head.v1 log_id: 205e4c389cb143e08f0d2d58bdc8e425e47e3cbe7f2108cc58bbe835d2cc41d7 tree_size: 8 - timestamp: '2026-07-06T12:53:09Z' + timestamp: '2026-07-06T13:20:39Z' root_hash: 9a15b9a1379edc07ae43d3fc61b52dc4446b56770bff6538e88ed98746ac2283 hash_algorithm: RFC9162_SHA256 signatures: ed25519: scheme: openssl-ed25519 + signing_backend: verified-dalek-serial status: signed - payload_digest_sha256: d5d25db2c90f0246cc10d10f912085b81c60d2a324354b5abecfdbeb8bd17ee2 - signature_base64: sI4rWnWMrX0zDaN8jofBwSuExKg1e0cmZV3OPaulbXDV97yz95aSo0Gnrfd1En2vyyV1qoEXdTAY9NDgvA8xBA== + payload_digest_sha256: d915e3fd22b755768bc91e088259f42e6011e8fb98910aaa6ae4b93ce793255b + signature_base64: 30Dz9i50AsM9L44fQCGQJcnk84fzpIUuJznbJiumQuREZ2QdAfsY9EpnXiC+uHcTnN8yJ928WIeBcarsKebXBw== public_key_fingerprint_sha256: 874c8a008a607021528b2493fa1caf059f9d5c123d29193dfabc09a6d1e7a56a + signing_provenance: + signing_backend: verified-dalek-serial + signing_library_component: dalek-ed25519-verified + signing_library_source_commit: aa0f6abc327ba2a54a534b21608ca8996cf73682 + self_inclusion: verified + signing_library_leaf_index: 4 + signing_library_certificates_proven: 16/16 ml_dsa: scheme: ML-DSA-65 standard: FIPS 204 diff --git a/provider/src/pacta_provider/transparency_log.py b/provider/src/pacta_provider/transparency_log.py index a6c50c2..f9932ab 100644 --- a/provider/src/pacta_provider/transparency_log.py +++ b/provider/src/pacta_provider/transparency_log.py @@ -7,6 +7,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any +from pacta.dogfood import BACKEND_OPENSSL, BACKEND_VERIFIED, load_provenance, locate_verifier from pacta.signing import canonical_json from pacta.transparency import ( HASH_ALGORITHM, @@ -19,6 +20,7 @@ from pacta.transparency import ( make_signed_tree_head, merkle_root, proof_to_hex, + verify_inclusion, ) from pacta.yamlio import dump_data, load_data @@ -95,7 +97,8 @@ class TransparencyLog: timestamp: str | None = None, ) -> dict[str, Any]: metadata = self.metadata() - leaves = [entry.leaf_bytes() for entry in self.entries()] + entries = self.entries() + leaves = [entry.leaf_bytes() for entry in entries] sth = make_signed_tree_head( metadata["log_id"], len(leaves), @@ -103,6 +106,7 @@ class TransparencyLog: timestamp or _now(), private_key_path, public_key_path, + signing_provenance=self.signing_provenance(entries), ) dump_data(sth, self.sth_path) return sth @@ -144,6 +148,7 @@ class TransparencyLog: _now(), private_key_path, public_key_path, + signing_provenance=self.signing_provenance(entries), ) dump_data(sth, self.sth_path) consistency = [] @@ -174,6 +179,45 @@ class TransparencyLog: return receipt + def signing_provenance(self, entries: list[LogEntry], signing_library_component: str = "dalek-ed25519-verified") -> dict[str, Any]: + """THE PROVIDER EATS ITS OWN DOGFOOD: before signing a tree head, + verify that the attestation of the SIGNING LIBRARY ITSELF (the + merkleized dalek source whose build produced the signing binary) is + included in the very tree about to be signed - by running the same + Merkle inclusion verification an agent runs. The result is recorded + inside the signature block: a root signature that names the leaf + vouching for the code that produced it.""" + binary = locate_verifier() + backend = BACKEND_VERIFIED if binary is not None else BACKEND_OPENSSL + provenance: dict[str, Any] = { + "signing_backend": backend, + "signing_library_component": signing_library_component, + } + if binary is not None: + build = load_provenance(binary) + if build.get("source_commit"): + provenance["signing_library_source_commit"] = build["source_commit"] + matches = [ + entry + for entry in entries + if ((entry.leaf.get("attestation") or {}).get("subject") or {}).get("component") == signing_library_component + ] + if not matches: + provenance["self_inclusion"] = "library_not_in_log" + return provenance + newest = matches[-1] + leaves = [entry.leaf_bytes() for entry in entries] + proof = inclusion_proof(leaves, newest.index) + verified = verify_inclusion( + newest.leaf_bytes(), newest.index, len(leaves), proof, merkle_root(leaves) + ) + provenance["self_inclusion"] = "verified" if verified else "FAILED" + provenance["signing_library_leaf_index"] = newest.index + attested = (newest.leaf.get("attestation") or {}).get("certificates") or [] + clean = sum(1 for c in attested if c.get("status") == "proven" and c.get("axiom_status") == "clean") + provenance["signing_library_certificates_proven"] = f"{clean}/{len(attested)}" + return provenance + def consistency_from(self, old_tree_size: int) -> dict[str, Any]: """Consistency proof from an arbitrary earlier tree size - what a pinning agent requests when its pin is older than the receipt's diff --git a/src/pacta/dogfood.py b/src/pacta/dogfood.py index 429703d..6c0d2c5 100644 --- a/src/pacta/dogfood.py +++ b/src/pacta/dogfood.py @@ -20,6 +20,9 @@ BACKEND_OPENSSL = "openssl" # the raw 32-byte key. Parsing by prefix is exact for this OID, not a # heuristic. _ED25519_SPKI_PREFIX = bytes.fromhex("302a300506032b6570032100") +# Ed25519 PKCS#8 private key (RFC 8410): fixed 16-byte DER prefix then the +# 32-byte seed wrapped as an OCTET STRING. +_ED25519_PKCS8_PREFIX = bytes.fromhex("302e020100300506032b657004220420") @dataclass(slots=True) @@ -50,6 +53,49 @@ def pem_public_key_to_raw(public_key_path: str | Path) -> bytes: return der[len(_ED25519_SPKI_PREFIX):] +def pem_private_key_to_seed(private_key_path: str | Path) -> bytes: + """Extract the raw 32-byte Ed25519 seed from an OpenSSL PKCS#8 PEM.""" + text = Path(private_key_path).read_text(encoding="utf-8") + body = "".join( + line.strip() + for line in text.splitlines() + if line.strip() and not line.startswith("-----") + ) + try: + der = base64.b64decode(body, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError(f"Not a PEM private key: {private_key_path}: {exc}") from exc + if not der.startswith(_ED25519_PKCS8_PREFIX) or len(der) != len(_ED25519_PKCS8_PREFIX) + 32: + raise ValueError(f"Not an Ed25519 PKCS#8 key: {private_key_path} ({len(der)} DER bytes)") + return der[len(_ED25519_PKCS8_PREFIX):] + + +def sign_payload_dogfood( + payload: bytes, + private_key_path: str | Path, + binary: str | Path, + timeout: int = 30, +) -> bytes: + """Sign via the dogfood binary (the merkleized, attested library). The + seed travels over STDIN, never argv. Honesty: the library's verify path + is certificate-covered; its signing path is declared trusted base - but + it is the ATTESTED artifact, not an un-attested third implementation.""" + seed = pem_private_key_to_seed(private_key_path) + with tempfile.TemporaryDirectory(prefix="pacta-dogfood-sign-") as tmp: + payload_path = Path(tmp) / "payload.bin" + payload_path.write_bytes(payload) + completed = subprocess.run( + [str(binary), "sign", str(payload_path)], + input=seed.hex(), + capture_output=True, + text=True, + timeout=timeout, + ) + if completed.returncode != 0: + raise RuntimeError((completed.stderr or "dogfood signing failed").strip()) + return bytes.fromhex(completed.stdout.strip()) + + def default_binary_path(state_dir: str | Path | None = None) -> Path: return Path(state_dir or DEFAULT_STATE_DIR) / "pacta-verified-verify" diff --git a/src/pacta/signing.py b/src/pacta/signing.py index 7fb8bb2..fc46ae4 100644 --- a/src/pacta/signing.py +++ b/src/pacta/signing.py @@ -44,10 +44,11 @@ def generate_ed25519_keypair(private_key_path: str | Path, public_key_path: str def sign_attestation(attestation: dict[str, Any], private_key_path: str | Path, public_key_path: str | Path | None = None) -> dict[str, Any]: payload = canonical_attestation_payload(attestation) - signature_base64 = sign_payload_ed25519(payload, private_key_path) + signature_base64, signing_backend = sign_payload_ed25519_detailed(payload, private_key_path) signed = dict(attestation) signed["signature"] = { "scheme": "openssl-ed25519", + "signing_backend": signing_backend, "status": "signed", "payload_digest_sha256": hashlib.sha256(payload).hexdigest(), "signature_base64": signature_base64, @@ -82,6 +83,25 @@ def verify_attestation_signature_detailed(attestation: dict[str, Any], public_ke def sign_payload_ed25519(payload: bytes, private_key_path: str | Path) -> str: + signature, _backend = sign_payload_ed25519_detailed(payload, private_key_path) + return signature + + +def sign_payload_ed25519_detailed(payload: bytes, private_key_path: str | Path) -> tuple[str, str]: + """Sign, preferring the dogfood binary (the merkleized, attested dalek + library) and falling back to OpenSSL. Returns (base64 signature, the + backend that actually signed) - the backend is recorded next to every + signature so the provenance is never silent.""" + from .dogfood import BACKEND_OPENSSL, BACKEND_VERIFIED, locate_verifier, sign_payload_dogfood + + binary = locate_verifier() + if binary is not None: + signature_bytes = sign_payload_dogfood(payload, private_key_path, binary) + return base64.b64encode(signature_bytes).decode("ascii"), BACKEND_VERIFIED + return _sign_payload_openssl(payload, private_key_path), BACKEND_OPENSSL + + +def _sign_payload_openssl(payload: bytes, private_key_path: str | Path) -> str: openssl = _openssl() with tempfile.TemporaryDirectory(prefix="pacta-sign-") as tmp: payload_path = Path(tmp) / "payload.bin" diff --git a/src/pacta/transparency.py b/src/pacta/transparency.py index f729bba..65ede14 100644 --- a/src/pacta/transparency.py +++ b/src/pacta/transparency.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any from .postquantum import detect_ml_dsa -from .signing import canonical_json, public_key_fingerprint, sign_payload_ed25519, verify_payload_ed25519_detailed +from .signing import canonical_json, public_key_fingerprint, sign_payload_ed25519_detailed, verify_payload_ed25519_detailed from .yamlio import load_data HASH_ALGORITHM = "RFC9162_SHA256" @@ -152,6 +152,7 @@ def make_signed_tree_head( timestamp: str, private_key_path: str | Path, public_key_path: str | Path, + signing_provenance: dict[str, Any] | None = None, ) -> dict[str, Any]: sth: dict[str, Any] = { "schema_version": 1, @@ -163,13 +164,16 @@ def make_signed_tree_head( "hash_algorithm": HASH_ALGORITHM, } payload = signed_tree_head_payload(sth) + signature_base64, signing_backend = sign_payload_ed25519_detailed(payload, private_key_path) sth["signatures"] = { "ed25519": { "scheme": "openssl-ed25519", + "signing_backend": signing_backend, "status": "signed", "payload_digest_sha256": hashlib.sha256(payload).hexdigest(), - "signature_base64": sign_payload_ed25519(payload, private_key_path), + "signature_base64": signature_base64, "public_key_fingerprint_sha256": public_key_fingerprint(public_key_path), + **({"signing_provenance": signing_provenance} if signing_provenance else {}), }, "ml_dsa": detect_ml_dsa().to_signature_slot(), } diff --git a/tests/test_dogfood.py b/tests/test_dogfood.py index 08ce493..b5db452 100644 --- a/tests/test_dogfood.py +++ b/tests/test_dogfood.py @@ -64,3 +64,27 @@ def test_real_dogfood_binary_if_built(tmp_path): # flip one payload byte: the proven path must reject ok, error, backend = verify_payload_ed25519_detailed(payload + b"x", signature, public_key) assert not ok and backend == BACKEND_VERIFIED + + +def test_dogfood_signing_roundtrip_if_built(tmp_path): + import pytest + + from pacta.dogfood import locate_verifier, pem_private_key_to_seed, sign_payload_dogfood + from pacta.signing import verify_payload_ed25519_detailed + import base64 + + binary = locate_verifier() + if binary is None or "fake" in str(binary): + pytest.skip("dogfood verifier not built on this host") + private_key = tmp_path / "k.key" + public_key = tmp_path / "k.pub" + from pacta.signing import generate_ed25519_keypair + + generate_ed25519_keypair(private_key, public_key) + assert len(pem_private_key_to_seed(private_key)) == 32 + payload = b"signed by the merkleized library" + signature = sign_payload_dogfood(payload, private_key, binary) + ok, error, backend = verify_payload_ed25519_detailed( + payload, base64.b64encode(signature).decode(), public_key + ) + assert ok and backend == "verified-dalek-serial"