diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b85ab36..3b89ba1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -33,6 +33,11 @@ jobs: - name: Test network targets run: cargo test --all-targets --features net --locked + - name: Reproduce sparse conformance vectors + run: | + cargo run --example conformance_vectors + git diff --exit-code -- conformance/v1 + - name: Large-domain examples run: | cargo run --release --example sextillion_verify @@ -40,9 +45,19 @@ jobs: cargo run --release --example sparse_record cargo run --release --example committed_workload - - name: Independent sparse verification + - name: Cross-language sparse verification run: | python3 scripts/verify_sparse_certificate.py target/power_house_sparse_record.phsp python3 scripts/verify_sparse_certificate.py \ target/external_interaction_model.phcp \ --polynomial target/external_interaction_model.phsm + + - name: Sparse verifier conformance and mutation tests + run: python3 scripts/test_sparse_verifier.py + + - name: Soundness budget checks + run: | + python3 scripts/soundness_budget.py + python3 scripts/soundness_budget.py \ + --field 18446744073709551557 \ + --repetitions 3 diff --git a/.gitignore b/.gitignore index c021a25..d57a5fa 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ boot1.anchor boot2.anchor *.ed25519 *.key +__pycache__/ +*.py[cod] diff --git a/Cargo.toml b/Cargo.toml index 5129270..df8a80c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,14 @@ include = [ "JULIAN_PROTOCOL.md", "docs/*.md", "artifacts/*.json", + "conformance/**", + "scripts/benchmark_sparse.py", + "scripts/soundness_budget.py", + "scripts/test_sparse_verifier.py", "scripts/verify_sparse_certificate.py", "src/**", "examples/**", + "tests/**", ] [badges] diff --git a/JULIAN_PROTOCOL.md b/JULIAN_PROTOCOL.md index d75972b..bf05ecb 100644 --- a/JULIAN_PROTOCOL.md +++ b/JULIAN_PROTOCOL.md @@ -176,7 +176,8 @@ verified without a polynomial commitment, oracle, or compact algebraic descripti For the public sparse computation artifact, run `cargo run --release --example sparse_record`. The default polynomial has one million variables, 8,192 nonzero monomials, and maximum degree 12. -Its `PHSPv1` certificate is then replayed by an independent standard-library implementation with: +Its `PHSPv1` certificate is then replayed by a separate standard-library Python +implementation with: ```bash python3 scripts/verify_sparse_certificate.py target/power_house_sparse_record.phsp diff --git a/README.md b/README.md index f3ecfd2..424d44c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ not establish that arbitrary sextillion-step computations can be verified, do not replace a quantum computer, and are not currently claimed as a world first. See [Research Claim Standard](docs/research_claim.md). +The v1 sparse workflow is specifically a deterministic conformance artifact: +the verifier reads the public sparse polynomial and recomputes the expected +transcript. Because this polynomial family also has a closed-form Boolean sum, +v1 is not a succinct delegated-computation result. The formal boundary and v2 +research target are documented in [Security Model](docs/security_model.md) and +[Research Protocol](docs/research_protocol.md). + ## Install ```bash @@ -59,11 +66,17 @@ python3 scripts/verify_sparse_certificate.py \ python3 scripts/verify_sparse_certificate.py \ target/external_interaction_model.phcp \ --polynomial target/external_interaction_model.phsm + +python3 scripts/test_sparse_verifier.py +python3 scripts/soundness_budget.py ``` The full procedure, formats, expected outputs, and failure tests are in [Verification Guide](docs/verification_guide.md). +Small canonical files in `conformance/v1` are checked by both languages. Every +single-byte XOR mutation of each vector must reject. + ## Library ```rust @@ -119,6 +132,9 @@ Operations and migration procedures are documented in - [Million-Round Sparse Certificate](docs/sparse_record.md) - [Hyperscale Seeded-Affine Proof](docs/hyperscale_proof.md) - [Research Claim Standard](docs/research_claim.md) +- [Prior-Art Review](docs/prior_art_review.md) +- [Sparse Certificate Security Model](docs/security_model.md) +- [Research Protocol](docs/research_protocol.md) - [Orbital Observatory](docs/orbital_observatory.md) - [Operations](docs/ops.md) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index aa018cc..5fb54a3 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,20 @@ # Release Notes +## Unreleased - v0.3 research foundation + +### Added +- Canonical small `PHSPv1`/`PHSMv1`/`PHCPv1` conformance vectors and manifest. +- Property-based dense-equivalence tests and full single-byte mutation rejection. +- Reproducible soundness-budget and benchmark-report tools. +- Security model, falsifiable research protocol, and primary-source prior-art review. + +### Security +- Enforced deterministic primality validation for every Rust `Field`. +- Fixed near-`u64::MAX` field addition overflow. +- Added matching Python primality validation. +- Added decoder limits for variables, terms, degree, seeds, and total incidences. +- Rejected oversized polynomial degrees before allocation. + ## v0.2.1 - 2026-06-05 ### Added @@ -7,7 +22,7 @@ - Seeded-affine sum-check over configurable domains, demonstrated at `2^4096`. - Stable `PHSPv1` million-round seeded sparse certificates. - Stable `PHSMv1` external sparse workloads and commitment-bound `PHCPv1` proofs. -- Independent standard-library Python verifier for both sparse formats. +- Separately implemented standard-library Python verifier for both sparse formats. - Unified verification guide and reproducible reference artifacts. ### Changed diff --git a/conformance/v1/committed-valid.phcp b/conformance/v1/committed-valid.phcp new file mode 100644 index 0000000..0118cd9 Binary files /dev/null and b/conformance/v1/committed-valid.phcp differ diff --git a/conformance/v1/committed-valid.phsm b/conformance/v1/committed-valid.phsm new file mode 100644 index 0000000..91685fa Binary files /dev/null and b/conformance/v1/committed-valid.phsm differ diff --git a/conformance/v1/manifest.json b/conformance/v1/manifest.json new file mode 100644 index 0000000..4a7e6fc --- /dev/null +++ b/conformance/v1/manifest.json @@ -0,0 +1,29 @@ +{ + "committed": { + "claimed_sum": 890880, + "final_evaluation": 13487675, + "maximum_degree": 5, + "polynomial_commitment": "88efabb63c2c7280be47386afb8b6f862972a01e58d90d3d2a29f31bce0554b8", + "polynomial_file": "committed-valid.phsm", + "polynomial_sha256": "254a0ae17d9cd2477770dc091e450a42df5a03e97c9569f561a845331cb47754", + "proof_file": "committed-valid.phcp", + "proof_sha256": "f097fb37298e51370516b41957e2e2a689eb5f40443ef1863017de9f3444642f", + "terms": 4, + "transcript_digest": "871a0763797cfe8f2e3376f62afad37b2ecc38ea95051530399a86e25ba4d5ca", + "variables": 16 + }, + "field_modulus": 1000000007, + "mutation_rule": "xor each byte with 0x01; every single-byte mutation must reject", + "schema": "power-house-sparse-conformance-v1", + "seeded": { + "claimed_sum": 173856083, + "file": "seeded-valid.phsp", + "final_evaluation": 130873495, + "maximum_degree": 6, + "polynomial_digest": "8fcda483c138a9511b62f924af5dc7daf813cfdc16847790e8a96f3dddcdc8db", + "sha256": "6717cab7f034df3a6f4ee13c2cc68f802139a58dff928d60dc6e2f52fd0a9853", + "terms": 20, + "transcript_digest": "a0cae3dca2136090b9e03dc162a429f19f7fd7b41a77a4a695eda829a2c9cd02", + "variables": 32 + } +} diff --git a/conformance/v1/seeded-valid.phsp b/conformance/v1/seeded-valid.phsp new file mode 100644 index 0000000..963e3c9 Binary files /dev/null and b/conformance/v1/seeded-valid.phsp differ diff --git a/docs/prior_art_review.md b/docs/prior_art_review.md new file mode 100644 index 0000000..a65c3e4 --- /dev/null +++ b/docs/prior_art_review.md @@ -0,0 +1,85 @@ +# Prior-Art Review + +Status: initial technical review, not an exhaustive novelty opinion. + +## Question Under Review + +The narrow candidate question is: + +> Does Power-House introduce a novel proof protocol, or does it publish a +> distinctive reproducibility artifact built from established sum-check +> techniques? + +The evidence currently supports the second description. + +## Primary Sources + +| Work | Relevant result | Consequence for Power-House | +| --- | --- | --- | +| Lund, Fortnow, Karloff, and Nisan, 1992, [Algebraic Methods for Interactive Proof Systems](https://doi.org/10.1145/146585.146605) | Introduced the sum-check protocol used to verify sums of low-degree multivariate polynomials over exponentially large domains. | An enormous implicit Boolean domain is established prior art. | +| Goldwasser, Kalai, and Rothblum, [Delegating Computation: Interactive Proofs for Muggles](https://dl.acm.org/doi/10.1145/2699436) | Applies layered arithmetization and sum-check to delegated computation. | Efficient verification of structured computation is established prior art. | +| Setty, [Spartan](https://eprint.iacr.org/2019/550) | Builds transparent arguments for R1CS using multilinear extensions and sum-check techniques. | General-purpose transparent arguments already use multilinear sum-check machinery. | +| Ben-Sasson et al., [Scalable, Transparent, and Post-Quantum Secure Computational Integrity](https://eprint.iacr.org/2018/046) | Introduces the STARK framework for transparent scalable computational integrity. | Large-scale classical verification does not inherently require quantum hardware. | +| Setty, Thaler, and Wahby, [Unlocking the Lookup Singularity with Lasso](https://eprint.iacr.org/2023/1216) | Uses sum-check-oriented techniques for efficient lookup arguments and includes sparse-structure optimizations. | Sparse structure and sum-check optimization are active established areas. | +| Arun et al., [Jolt](https://eprint.iacr.org/2023/1217) | Constructs a virtual-machine argument primarily from lookups and sum-check. | Modern systems already verify rich computations over implicit structures. | +| Chiesa, Fedele, Fenzi, and Zitek-Estrada, [A Time-Space Tradeoff for the Sumcheck Prover](https://eprint.iacr.org/2024/524) | Studies prover memory and running-time tradeoffs for multilinear sum-check. | Prover scaling and streaming constraints are not new research questions. | +| Baweja et al., [Scribe: Low-memory SNARKs via Read-Write Streaming](https://www.usenix.org/conference/usenixsecurity26/presentation/baweja) | Builds and evaluates a low-memory SNARK using a read-write streaming model and disk-backed prover state. | A low-memory claim must compare against modern streaming proof systems. | + +## Feature Comparison + +| Property | Power-House v1 | Established systems | +| --- | --- | --- | +| Exponential implicit domain | Yes | Core sum-check property since LFKN | +| Sparse multilinear representation | Yes | Common in modern multilinear protocols | +| Fiat-Shamir transcript | Yes | Standard non-interactive compilation technique | +| Public-data hash binding | Yes | Standard collision-resistant commitment pattern | +| Succinct verifier in workload size | No | Provided by several argument/PCS systems | +| Hidden witness | No | Supported by zero-knowledge argument systems | +| General computation arithmetization | No | GKR, Spartan, STARKs, Jolt, and others | +| Stable million-round public artifact | Yes | Potential benchmark distinction; novelty unverified | +| Cross-language deterministic replay | Rust and Python | Engineering evidence, not protocol novelty | + +## Current Conclusion + +No protocol-level historical claim is justified by the current evidence. +Specifically: + +- `2^1,000,000` is a description of an implicit domain, not executed work. +- the sparse monomial sum is available in closed form, +- the v1 verifier reads the entire public workload, +- exact transcript replay is deterministic conformance checking, +- BLAKE2b binding is not a multilinear polynomial commitment. + +The strongest supportable statement is: + +> Power-House publishes a stable, cross-language reproducible million-round +> deterministic sum-check transcript for a separately stored, hash-bound sparse +> multilinear polynomial over a one-million-variable Boolean domain. + +Whether that artifact is the largest or first public artifact of its exact kind +requires a broader artifact search and independent review. + +## Novelty Path + +A potentially publishable contribution needs at least one property not supplied +by the v1 artifact: + +1. a genuinely lower-memory or faster prover with a proved complexity + improvement over current sparse/streaming sum-check methods, +2. a commitment/opening construction that avoids full public-workload replay, +3. a useful computation arithmetization whose verification advantage survives + comparison with GKR, Spartan, STARK, Lasso/Jolt, and current sum-check work, +4. independently measured engineering results that establish a reproducible + record without presenting that record as a new protocol. + +## Review Procedure + +Before any novelty claim: + +1. search IACR ePrint, DBLP, ACM, IEEE, USENIX, and artifact repositories using + protocol properties rather than only the phrase "million-round"; +2. record inclusion criteria and negative search results; +3. send the exact claim and protocol specification to at least two unaffiliated + specialists; +4. publish reviewer conflicts and requested corrections; +5. phrase any surviving claim narrowly enough to be falsifiable. diff --git a/docs/research_claim.md b/docs/research_claim.md index 7242eb7..f3815ff 100644 --- a/docs/research_claim.md +++ b/docs/research_claim.md @@ -20,18 +20,20 @@ Relevant prior art includes: - Recent sub-linear GKR work: https://eprint.iacr.org/2025/717 -## Current Candidate Result +## Current Established Result -Power-House has a candidate public engineering record: +Power-House has a reproducible public engineering artifact: -> A cross-language reproducible, one-million-round sum-check certificate for a -> separately stored, commitment-bound sparse multilinear polynomial over -> `2^1,000,000` Boolean points, with a stable 16 MB certificate and no -> hypercube allocation. +> A Rust/Python reproducible, one-million-round deterministic sum-check +> transcript for a separately stored, hash-bound sparse multilinear polynomial +> over a one-million-variable Boolean domain, with a stable 16 MB certificate +> and no hypercube allocation. -This wording is a candidate, not an established world-first claim. An exhaustive -literature and artifact search has not yet been completed, and no independent -external party has reproduced the result. +This is an engineering result, not an established world-first claim. The v1 +verifier reads the complete workload and recomputes the exact expected +transcript. The represented sum also has a closed form. These facts prevent a +succinct-verification or novel-protocol claim. See +[Security Model](security_model.md) and [Prior-Art Review](prior_art_review.md). ## Historical Claim Gates @@ -47,9 +49,9 @@ complete: details, and exact commands in a timestamped release or archival DOI. 3. **Independent implementation** - Obtain verification by an implementation not authored from the Rust code. - The bundled Python verifier is useful cross-language evidence but is not an - independent external audit. + Obtain verification by an implementation authored by an unaffiliated team. + The bundled Python verifier is useful cross-language conformance evidence + but is not an independent external audit. 4. **Prior-art comparison** Compare the result directly against sum-check, sparse-dense sum-check, GKR, @@ -61,19 +63,21 @@ complete: publish machine details and timings. 6. **Cryptographic scope** - The `PHSMv1`/`PHCPv1` workflow now binds public external data with - BLAKE2b-256. For a general or succinct verifiable-computation claim, replace - full workload replay with a proven multilinear polynomial commitment and an - opening proof for an externally supplied computation or witness. + The `PHSMv1`/`PHCPv1` workflow binds public external data with BLAKE2b-256. + For a general or succinct verifiable-computation claim, replace full + workload replay with a reviewed multilinear polynomial commitment and an + opening proof for an externally supplied computation or witness. A standard + one-repetition sum-check at the published million-round field parameters has + only about 9.97 bits under the classical `n/|F|` bound. 7. **Public review** Publish a technical preprint and obtain specialist review or a formal audit. ## Claim Levels -- **Allowed now:** "Power-House verifies a separately stored, - commitment-bound sparse polynomial over `2^1,000,000` points through a - million-round reproducible certificate." +- **Allowed now:** "Power-House deterministically replays a separately stored, + hash-bound sparse polynomial transcript over a one-million-variable Boolean + domain through a million-round reproducible certificate." - **Allowed after external reproduction:** "Power-House publishes an independently reproduced million-round sparse sum-check artifact." - **Allowed after novelty review:** A narrowly worded "first" claim matching diff --git a/docs/research_protocol.md b/docs/research_protocol.md new file mode 100644 index 0000000..ffecced --- /dev/null +++ b/docs/research_protocol.md @@ -0,0 +1,108 @@ +# Research Protocol + +This document turns Power-House development into a falsifiable research +program. Scale demonstrations remain useful, but exponent size alone is not a +research contribution. + +## Baseline: v1 Deterministic Replay + +The released `PHSMv1`/`PHCPv1` pair is the baseline: + +- one million variables, +- 8,192 sparse monomials, +- 57,546 variable incidences, +- 16,000,128 certificate bytes, +- Rust and Python deterministic replay, +- no Boolean-hypercube allocation. + +The baseline is a conformance benchmark. It is not the proposed novel result. + +## Falsifiable v2 Claim + +The working claim for investigation is: + +> For a public or committed multilinear workload with one million logical +> variables, Power-House v2 provides at least 128 bits of stated algebraic +> soundness while reducing verifier access to the workload below full replay, +> with independently reproducible prover memory, proof size, and verification +> time. + +This claim fails if any of the following is true: + +- the verifier must read all workload terms, +- the final evaluation is not bound by a reviewed commitment/opening method, +- the soundness calculation is below 128 bits, +- the comparison excludes a materially faster or smaller established system, +- an unaffiliated implementation cannot reproduce acceptance and rejection + vectors. + +## Work Packages + +### WP1: Specification + +- Freeze canonical byte encodings and transcript domains. +- Specify field arithmetic, challenge sampling, and repetition separation. +- State completeness and soundness propositions. +- Publish parser resource limits. + +### WP2: Differential Verification + +- Maintain structurally separate Rust and Python verifiers. +- Publish valid and invalid conformance vectors. +- Run property tests against dense enumeration for small domains. +- Mutate every byte of small canonical artifacts and require rejection. +- Fuzz all untrusted decoders with allocation and timeout limits. + +### WP3: Soundness Upgrade + +- Evaluate the 64-bit prime `18446744073709551557`. +- Use at least three independently domain-separated repetitions for a + one-million-round multilinear protocol if relying on the classical + `n/|F|` bound. +- Prefer an extension field or reviewed PCS when it reduces proof size or gives + a cleaner security argument. +- Treat Fiat-Shamir security separately from the interactive algebraic bound. + +### WP4: Workload and Commitment + +- Replace the closed-form sparse-monomial sum with a useful computation or + streaming relation. +- Select a reviewed multilinear polynomial commitment. +- Measure verifier workload access, not only elapsed time. +- Document trusted setup, hiding, and post-quantum assumptions. + +### WP5: Reproduction + +- Produce machine-readable benchmark reports. +- Archive source, toolchains, vectors, and checksums. +- Obtain two unaffiliated reproductions. +- Submit the specification and claim to specialist review before publicity. + +## Acceptance Matrix + +| Gate | Required evidence | +| --- | --- | +| Correctness | Dense-equivalence property tests and cross-language vectors | +| Robustness | Mutation corpus, fuzzing, no decoder panic/OOM | +| Soundness | Explicit bound, field proof, repetitions, Fiat-Shamir model | +| Efficiency | Median and variance across pinned benchmark runs | +| Comparison | Same workload and security target against primary baselines | +| Independence | Unaffiliated implementation and public reproduction logs | +| Claim discipline | Prior-art review and exact falsifiable wording | + +## Reproducible Commands + +```bash +cargo run --example conformance_vectors +cargo test --test sparse_protocol +python3 scripts/test_sparse_verifier.py + +python3 scripts/soundness_budget.py +python3 scripts/soundness_budget.py \ + --field 18446744073709551557 \ + --repetitions 3 + +python3 scripts/benchmark_sparse.py \ + --repeats 3 \ + --output target/research-benchmark.json +``` diff --git a/docs/security_model.md b/docs/security_model.md new file mode 100644 index 0000000..da66790 --- /dev/null +++ b/docs/security_model.md @@ -0,0 +1,127 @@ +# Sparse Certificate Security Model + +This document defines what `PHSPv1`, `PHSMv1`, and `PHCPv1` establish and what +they do not establish. + +## Statement + +For a prime field `F_p`, the committed workflow represents a public sparse +multilinear polynomial + +```text +f(x_0, ..., x_(n-1)) = sum_t c_t * product_(j in S_t) x_j +``` + +and records a deterministic sum-check transcript for its sum over `{0,1}^n`. +`PHSMv1` contains the canonical polynomial. `PHCPv1` contains its +domain-separated BLAKE2b-256 commitment, claimed sum, round messages, final +evaluation, and transcript digest. + +## Current Verification Profile + +The v1 verifier reads the complete public polynomial and deterministically +recomputes: + +1. the polynomial commitment, +2. the Boolean-hypercube sum, +3. every expected round polynomial, +4. every Fiat-Shamir challenge, +5. the final evaluation and transcript digest. + +Acceptance therefore means that the supplied files match the deterministic v1 +derivation. It is a conformance and reproducibility check, not a succinct +delegation result. + +For this polynomial representation, the claimed sum also has the closed form + +```text +sum_t c_t * 2^(n - |S_t|) mod p +``` + +so a verifier that already reads every sparse term can calculate the statement +without the million-round certificate. The certificate is useful as a stable, +cross-language stress artifact, but it does not reduce verifier asymptotic work. + +## Security Properties + +- **Canonical binding:** changing canonical `PHSMv1` bytes changes the + BLAKE2b-256 commitment except in the event of a hash collision. +- **Deterministic transcript integrity:** changing certificate metadata, + rounds, final evaluation, or transcript state is rejected by full replay. +- **Prime-field enforcement:** Rust and Python now reject composite moduli + using deterministic primality checks over the complete `u64` range. +- **Memory safety objective:** length fields are checked against available + input before encoded collections are allocated. Decoders cap inputs at 16 + million variables, one million terms, one million variables per monomial, 1 + MiB seeds, and a 64 million-incidence work budget. +- **Cross-language conformance:** separate Rust and Python implementations parse and + replay the stable formats. + +## Non-Goals + +The v1 formats do not provide: + +- succinct verification, +- a polynomial commitment opening proof, +- zero knowledge or witness hiding, +- proof of arbitrary computation, +- protection from a malicious local verifier implementation, +- post-quantum security certification, +- an established novelty or world-first claim. + +## Probabilistic Sum-Check Boundary + +A conventional multilinear sum-check verifier checks round consistency and one +final polynomial evaluation instead of recomputing each expected round. Its +classical interactive soundness error is bounded by approximately `n / |F|` +for one repetition. + +For the published parameters: + +```text +n = 1,000,000 +|F| = 1,000,000,007 +error <= approximately 9.99999993e-4 +security >= approximately 9.97 bits +``` + +That is not an acceptable cryptographic soundness target. BLAKE2b-derived +Fiat-Shamir challenges do not enlarge the field or remove this algebraic bound. +The estimate can be reproduced with: + +```bash +python3 scripts/soundness_budget.py +``` + +Using the 64-bit prime `18446744073709551557` raises one-repetition security to +approximately 44.07 bits at one million rounds. Three independently +domain-separated repetitions would exceed a 128-bit classical union-bound +target, before accounting for the Fiat-Shamir transform's random-oracle model. + +## Threats and Mitigations + +| Threat | v1 treatment | Remaining limitation | +| --- | --- | --- | +| Workload substitution | Domain-separated hash commitment | Not a polynomial commitment | +| Certificate bit corruption | Exact replay and transcript digest | Depends on verifier correctness | +| Composite modulus | Deterministic primality rejection | Construction still panics on invalid Rust input | +| Length-based allocation abuse | Input-size checks before encoded allocations | Applications should still impose file-size limits | +| Common-mode implementation error | Rust/Python conformance and property tests | Both implementations remain project-authored | +| Malicious prover in standard sum-check | Not the v1 verification profile | Requires a v2 protocol and soundness budget | + +## v2 Security Target + +A research-grade successor must: + +1. use a field/repetition plan with at least 128 bits of stated algebraic + soundness, +2. separate prover logic from verifier logic, +3. check standard sum-check identities rather than exact prover replay, +4. bind final evaluation through a reviewed polynomial commitment or a clearly + stated public-data oracle model, +5. domain-separate every repetition and protocol version, +6. publish malformed-input, differential, and external-reproduction results. + +The project conformance corpus is in `conformance/v1`. Its manifest fixes +digests and expected outputs. Rust and Python require every single-byte XOR +mutation of the small canonical vectors to reject. diff --git a/docs/sparse_record.md b/docs/sparse_record.md index 7f9f187..05c253b 100644 --- a/docs/sparse_record.md +++ b/docs/sparse_record.md @@ -60,7 +60,7 @@ python3 scripts/verify_sparse_certificate.py \ target/power_house_sparse_record.phsp ``` -The Python verifier independently: +The separately implemented Python verifier: 1. decodes the stable `PHSPv1` binary format, 2. derives the sparse polynomial from the public seed, diff --git a/docs/verification_guide.md b/docs/verification_guide.md index b842be2..95f7740 100644 --- a/docs/verification_guide.md +++ b/docs/verification_guide.md @@ -5,7 +5,7 @@ This guide reproduces every large-domain claim in `power_house` v0.2.1. ## Requirements - Rust stable with Cargo -- Python 3.10 or newer for the independent verifier +- Python 3.10 or newer for the cross-language verifier - approximately 100 MB of free disk space for generated certificates Run all commands from the repository root. @@ -17,6 +17,7 @@ cargo fmt --check cargo test --all-targets --locked cargo test --all-targets --features net --locked cargo clippy --all-targets --all-features -- -D warnings +python3 -m py_compile scripts/*.py ``` ## 2. Verify more than one sextillion points @@ -65,6 +66,16 @@ python3 scripts/verify_sparse_certificate.py \ The `PHCPv1` proof commits to the separate `PHSMv1` workload. The Rust and Python verifiers both require the exact workload bytes. +Run the differential mutation suite after generating both million-round +artifacts: + +```bash +python3 scripts/test_sparse_verifier.py +``` + +The test consumes the canonical `conformance/v1` files, validates their +manifest, and requires rejection after XOR-mutating every individual byte. + ## 6. Confirm tamper rejection ```bash @@ -93,6 +104,16 @@ The current external-data commitment is public and non-hiding. Verification reads the complete sparse workload. It is not a succinct polynomial opening, general virtual-machine proof, or hidden-witness argument. +The v1 verifier also recomputes every expected round from the public sparse +polynomial. This is deterministic conformance replay, not a conventional +probabilistic sum-check verifier. The current field and one-million-round count +would provide only approximately 9.97 bits under the classical one-repetition +`n/|F|` soundness bound: + +```bash +python3 scripts/soundness_budget.py +``` + ## Reporting results Record: diff --git a/examples/conformance_vectors.rs b/examples/conformance_vectors.rs new file mode 100644 index 0000000..f3a8258 --- /dev/null +++ b/examples/conformance_vectors.rs @@ -0,0 +1,83 @@ +use power_house::{ + transcript_digest_to_hex, CommittedSparsePolynomial, CommittedSparseProof, Field, + SeededSparseProof, SeededSparseSpec, SparseMonomial, +}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + let output = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("conformance/v1")); + fs::create_dir_all(&output).expect("conformance directory must be creatable"); + + let field = Field::new(1_000_000_007); + let seeded = SeededSparseProof::prove( + SeededSparseSpec::new(32, 20, 6, b"power-house-conformance-v1".to_vec()), + &field, + ); + let polynomial = CommittedSparsePolynomial::new( + 16, + vec![ + SparseMonomial::new(17, vec![0, 3, 9]).unwrap(), + SparseMonomial::new(29, vec![1, 4]).unwrap(), + SparseMonomial::new(41, vec![2, 5, 8, 13]).unwrap(), + SparseMonomial::new(53, vec![6, 7, 10, 11, 15]).unwrap(), + ], + ) + .unwrap(); + let committed = CommittedSparseProof::prove(&polynomial, &field).unwrap(); + + let seeded_bytes = seeded.to_bytes(); + let polynomial_bytes = polynomial.to_bytes(); + let committed_bytes = committed.to_bytes(); + write(&output.join("seeded-valid.phsp"), &seeded_bytes); + write(&output.join("committed-valid.phsm"), &polynomial_bytes); + write(&output.join("committed-valid.phcp"), &committed_bytes); + + let manifest = json!({ + "schema": "power-house-sparse-conformance-v1", + "field_modulus": field.modulus(), + "mutation_rule": "xor each byte with 0x01; every single-byte mutation must reject", + "seeded": { + "file": "seeded-valid.phsp", + "sha256": sha256(&seeded_bytes), + "variables": seeded.spec.num_vars(), + "terms": seeded.spec.num_terms(), + "maximum_degree": seeded.spec.max_degree(), + "claimed_sum": seeded.claimed_sum, + "final_evaluation": seeded.final_evaluation, + "polynomial_digest": transcript_digest_to_hex(&seeded.polynomial_digest), + "transcript_digest": transcript_digest_to_hex(&seeded.transcript_digest), + }, + "committed": { + "polynomial_file": "committed-valid.phsm", + "polynomial_sha256": sha256(&polynomial_bytes), + "proof_file": "committed-valid.phcp", + "proof_sha256": sha256(&committed_bytes), + "variables": polynomial.num_vars(), + "terms": polynomial.num_terms(), + "maximum_degree": polynomial.max_degree(), + "claimed_sum": committed.claimed_sum, + "final_evaluation": committed.final_evaluation, + "polynomial_commitment": transcript_digest_to_hex(&committed.polynomial_commitment), + "transcript_digest": transcript_digest_to_hex(&committed.transcript_digest), + } + }); + let mut manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + manifest_bytes.push(b'\n'); + write(&output.join("manifest.json"), &manifest_bytes); +} + +fn sha256(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn write(path: &Path, bytes: &[u8]) { + fs::write(path, bytes) + .unwrap_or_else(|error| panic!("failed to write {}: {error}", path.display())); + println!("wrote {} ({} bytes)", path.display(), bytes.len()); +} diff --git a/scripts/benchmark_sparse.py b/scripts/benchmark_sparse.py new file mode 100644 index 0000000..3e1d958 --- /dev/null +++ b/scripts/benchmark_sparse.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Run reproducible Rust and Python sparse-verifier benchmarks.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import statistics +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def cpu_model() -> str: + cpuinfo = Path("/proc/cpuinfo") + if cpuinfo.exists(): + for line in cpuinfo.read_text(encoding="utf-8", errors="replace").splitlines(): + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + return platform.processor() or "unknown" + + +def memory_bytes() -> int | None: + try: + return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") + except (ValueError, OSError, AttributeError): + return None + + +def version(command: list[str]) -> str: + return subprocess.check_output(command, cwd=ROOT, text=True).strip() + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def timed(command: list[str], repeats: int) -> dict[str, object]: + durations = [] + last_stdout = "" + for _ in range(repeats): + start = time.perf_counter() + process = subprocess.run( + command, + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + durations.append(time.perf_counter() - start) + last_stdout = process.stdout + return { + "command": command, + "repeats": repeats, + "seconds": durations, + "median_seconds": statistics.median(durations), + "minimum_seconds": min(durations), + "last_stdout": last_stdout, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--polynomial", + type=Path, + default=ROOT / "target" / "external_interaction_model.phsm", + ) + parser.add_argument( + "--proof", + type=Path, + default=ROOT / "target" / "external_interaction_model.phcp", + ) + parser.add_argument("--repeats", type=int, default=3) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if args.repeats < 1: + parser.error("--repeats must be positive") + for path in (args.polynomial, args.proof): + if not path.is_file(): + parser.error(f"missing artifact: {path}") + + subprocess.run( + ["cargo", "build", "--release", "--example", "committed_workload"], + cwd=ROOT, + check=True, + ) + rust_binary = ROOT / "target" / "release" / "examples" / "committed_workload" + report = { + "schema": "power-house-research-benchmark-v1", + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "git_commit": subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True + ).strip(), + "system": { + "platform": platform.platform(), + "machine": platform.machine(), + "processor": cpu_model(), + "python": platform.python_version(), + "rustc": version(["rustc", "--version"]), + "cargo": version(["cargo", "--version"]), + "cpu_count": os.cpu_count(), + "memory_bytes": memory_bytes(), + }, + "artifacts": { + "polynomial": { + "path": str(args.polynomial), + "bytes": args.polynomial.stat().st_size, + "sha256": sha256(args.polynomial), + }, + "proof": { + "path": str(args.proof), + "bytes": args.proof.stat().st_size, + "sha256": sha256(args.proof), + }, + }, + "rust": timed( + [ + str(rust_binary), + "verify", + str(args.polynomial), + str(args.proof), + ], + args.repeats, + ), + "python": timed( + [ + "python3", + "scripts/verify_sparse_certificate.py", + str(args.proof), + "--polynomial", + str(args.polynomial), + ], + args.repeats, + ), + } + + encoded = json.dumps(report, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(encoded, encoding="utf-8") + print(encoded, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/soundness_budget.py b/scripts/soundness_budget.py new file mode 100644 index 0000000..0dd1533 --- /dev/null +++ b/scripts/soundness_budget.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Estimate the classical interactive soundness budget for sum-check.""" + +from __future__ import annotations + +import argparse +import json +import math + + +def estimate( + rounds: int, + field_size: int, + degree: int, + repetitions: int, + challenge_bits: int, +) -> dict[str, float | int | str]: + if min(rounds, field_size, degree, repetitions, challenge_bits) <= 0: + raise ValueError("all parameters must be positive") + + challenge_space = 2**challenge_bits + max_preimages = (challenge_space + field_size - 1) // field_size + max_challenge_probability = max_preimages / challenge_space + single_error = min(1.0, rounds * degree * max_challenge_probability) + repeated_error = single_error**repetitions + bits = math.inf if repeated_error == 0 else -math.log2(repeated_error) + return { + "model": "classical interactive sum-check union bound", + "rounds": rounds, + "field_size": field_size, + "individual_degree": degree, + "repetitions": repetitions, + "challenge_digest_bits": challenge_bits, + "single_repetition_error_upper_bound": single_error, + "combined_error_upper_bound": repeated_error, + "soundness_bits_lower_bound": bits, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--rounds", type=int, default=1_000_000) + parser.add_argument("--field", type=int, default=1_000_000_007) + parser.add_argument("--degree", type=int, default=1) + parser.add_argument("--repetitions", type=int, default=1) + parser.add_argument("--challenge-bits", type=int, default=256) + args = parser.parse_args() + try: + report = estimate( + args.rounds, + args.field, + args.degree, + args.repetitions, + args.challenge_bits, + ) + except ValueError as error: + parser.error(str(error)) + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_sparse_verifier.py b/scripts/test_sparse_verifier.py new file mode 100644 index 0000000..f206718 --- /dev/null +++ b/scripts/test_sparse_verifier.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Conformance and mutation tests for the Python sparse verifier.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERIFIER_PATH = ROOT / "scripts" / "verify_sparse_certificate.py" +SPEC = importlib.util.spec_from_file_location("verify_sparse_certificate", VERIFIER_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError("could not load sparse verifier") +verifier = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = verifier +SPEC.loader.exec_module(verifier) + + +class SparseVerifierTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.vector_dir = ROOT / "conformance" / "v1" + cls.seeded_path = cls.vector_dir / "seeded-valid.phsp" + cls.polynomial_path = cls.vector_dir / "committed-valid.phsm" + cls.committed_path = cls.vector_dir / "committed-valid.phcp" + cls.manifest_path = cls.vector_dir / "manifest.json" + for path in (cls.seeded_path, cls.polynomial_path, cls.committed_path): + if not path.exists(): + raise RuntimeError(f"missing conformance vector: {path}") + + cls.seeded = cls.seeded_path.read_bytes() + cls.polynomial = cls.polynomial_path.read_bytes() + cls.committed = cls.committed_path.read_bytes() + cls.manifest = json.loads(cls.manifest_path.read_text(encoding="utf-8")) + + def assert_rejected(self, operation) -> None: + with self.assertRaises(verifier.CertificateError): + operation() + + def test_conformance_vectors_verify(self) -> None: + seeded_report = verifier.verify_seeded(self.seeded) + committed_report = verifier.verify_committed( + self.committed, self.polynomial_path + ) + self.assertEqual( + seeded_report["rounds_verified"], + self.manifest["seeded"]["variables"], + ) + self.assertEqual( + committed_report["rounds_verified"], + self.manifest["committed"]["variables"], + ) + self.assertEqual( + committed_report["polynomial_digest"], + self.manifest["committed"]["polynomial_commitment"], + ) + + def test_seeded_certificate_mutations_are_rejected(self) -> None: + for offset in range(len(self.seeded)): + with self.subTest(offset=offset): + mutated = bytearray(self.seeded) + mutated[offset] ^= 1 + self.assert_rejected(lambda: verifier.verify_seeded(bytes(mutated))) + + def test_committed_certificate_mutations_are_rejected(self) -> None: + for offset in range(len(self.committed)): + with self.subTest(offset=offset): + mutated = bytearray(self.committed) + mutated[offset] ^= 1 + self.assert_rejected( + lambda: verifier.verify_committed( + bytes(mutated), self.polynomial_path + ) + ) + + def test_committed_polynomial_mutations_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "mutated.phsm" + for offset in range(len(self.polynomial)): + with self.subTest(offset=offset): + mutated = bytearray(self.polynomial) + mutated[offset] ^= 1 + path.write_bytes(mutated) + self.assert_rejected( + lambda: verifier.verify_committed(self.committed, path) + ) + + def test_published_million_round_artifacts_verify_when_present(self) -> None: + seeded_path = ROOT / "target" / "power_house_sparse_record.phsp" + polynomial_path = ROOT / "target" / "external_interaction_model.phsm" + committed_path = ROOT / "target" / "external_interaction_model.phcp" + if not all(path.exists() for path in (seeded_path, polynomial_path, committed_path)): + self.skipTest("published-scale artifacts have not been generated") + + seeded_report = verifier.verify_seeded(seeded_path.read_bytes()) + committed_report = verifier.verify_committed( + committed_path.read_bytes(), polynomial_path + ) + self.assertEqual(seeded_report["rounds_verified"], 1_000_000) + self.assertEqual(committed_report["rounds_verified"], 1_000_000) + + def test_truncation_is_rejected(self) -> None: + for data, operation in ( + (self.seeded, verifier.verify_seeded), + ( + self.committed, + lambda value: verifier.verify_committed(value, self.polynomial_path), + ), + ): + for amount in (1, 8, 32, len(data) // 2): + with self.subTest(size=len(data), amount=amount): + self.assert_rejected(lambda: operation(data[:-amount])) + + def test_primality_gate_rejects_pseudoprimes(self) -> None: + for composite in (9, 341, 561, 1_105, 1_729, 3_215_031_751): + with self.subTest(composite=composite): + self.assertFalse(verifier.is_prime_u64(composite)) + for prime in (3, 101, 1_000_000_007, 18_446_744_073_709_551_557): + with self.subTest(prime=prime): + self.assertTrue(verifier.is_prime_u64(prime)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/verify_sparse_certificate.py b/scripts/verify_sparse_certificate.py index fc2d61d..631ca33 100755 --- a/scripts/verify_sparse_certificate.py +++ b/scripts/verify_sparse_certificate.py @@ -20,12 +20,48 @@ CHALLENGE_DOMAIN = b"power_house:v1:sparse-sumcheck-challenge" RESPONSE_DOMAIN = b"power_house:v1:sparse-sumcheck-response" PRNG_DOMAIN = b"JROC_PRNG" +MAX_DECODED_VARIABLES = 16_000_000 +MAX_DECODED_TERMS = 1_000_000 +MAX_DECODED_DEGREE = 1_000_000 +MAX_DECODED_SEED_BYTES = 1_048_576 +MAX_DECODED_INCIDENCES = 64_000_000 class CertificateError(ValueError): pass +def is_prime_u64(value: int) -> bool: + if value < 2: + return False + for prime in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37): + if value == prime: + return True + if value % prime == 0: + return False + + odd_part = value - 1 + shifts = 0 + while odd_part % 2 == 0: + shifts += 1 + odd_part //= 2 + + for base in (2, 325, 9_375, 28_178, 450_775, 9_780_504, 1_795_265_022): + base %= value + if base == 0: + continue + witness = pow(base, odd_part, value) + if witness in (1, value - 1): + continue + for _ in range(1, shifts): + witness = witness * witness % value + if witness == value - 1: + break + else: + return False + return True + + class Reader: def __init__(self, data: bytes) -> None: self.data = data @@ -188,16 +224,32 @@ def committed_polynomial(path: Path) -> tuple[int, list[Term], bytes]: raise CertificateError("bad polynomial magic") num_vars = reader.u64() num_terms = reader.u64() - if num_vars == 0 or num_terms == 0: - raise CertificateError("invalid polynomial parameters") + if ( + num_vars == 0 + or num_vars > MAX_DECODED_VARIABLES + or num_terms == 0 + or num_terms > MAX_DECODED_TERMS + ): + raise CertificateError("polynomial dimensions exceed decoder limits") if num_terms > (len(data) - reader.offset) // 24: raise CertificateError("polynomial term count exceeds input size") terms: list[Term] = [] + total_incidences = 0 for _ in range(num_terms): coefficient = reader.u64() degree = reader.u64() + if ( + degree == 0 + or degree > num_vars + or degree > MAX_DECODED_DEGREE + or degree > (len(data) - reader.offset) // 8 + ): + raise CertificateError("polynomial degree exceeds input or domain size") + total_incidences += degree + if total_incidences > MAX_DECODED_INCIDENCES: + raise CertificateError("polynomial incidence count exceeds decoder limit") variables = [reader.u64() for _ in range(degree)] - if coefficient == 0 or degree == 0: + if coefficient == 0: raise CertificateError("invalid polynomial term") if variables != sorted(set(variables)): raise CertificateError("polynomial variables are not canonical") @@ -231,6 +283,7 @@ def replay( if ( p < 3 or p % 2 == 0 + or not is_prime_u64(p) or num_vars == 0 or num_terms == 0 or max_degree == 0 @@ -337,11 +390,25 @@ def verify_seeded(data: bytes) -> dict[str, int | str]: num_vars = reader.u64() num_terms = reader.u64() max_degree = reader.u64() - seed = reader.take(reader.u64()) + seed_length = reader.u64() + if seed_length > MAX_DECODED_SEED_BYTES: + raise CertificateError("seed length exceeds decoder limit") + seed = reader.take(seed_length) stored_claimed_sum = reader.u64() stored_polynomial_digest = reader.take(32) round_count = reader.u64() - if round_count != num_vars or round_count > (len(data) - reader.offset - 40) // 16: + if ( + num_vars == 0 + or num_vars > MAX_DECODED_VARIABLES + or num_terms == 0 + or num_terms > MAX_DECODED_TERMS + or max_degree == 0 + or max_degree > num_vars + or max_degree > MAX_DECODED_DEGREE + or num_terms * max_degree > MAX_DECODED_INCIDENCES + or round_count != num_vars + or round_count > (len(data) - reader.offset - 40) // 16 + ): raise CertificateError("round count exceeds input size") terms = derive_terms(p, num_vars, num_terms, max_degree, seed) @@ -375,7 +442,18 @@ def verify_committed(data: bytes, polynomial_path: Path) -> dict[str, int | str] stored_commitment = reader.take(32) stored_claimed_sum = reader.u64() round_count = reader.u64() - if round_count != num_vars or round_count > (len(data) - reader.offset - 40) // 16: + if ( + num_vars == 0 + or num_vars > MAX_DECODED_VARIABLES + or num_terms == 0 + or num_terms > MAX_DECODED_TERMS + or max_degree == 0 + or max_degree > num_vars + or max_degree > MAX_DECODED_DEGREE + or num_terms * max_degree > MAX_DECODED_INCIDENCES + or round_count != num_vars + or round_count > (len(data) - reader.offset - 40) // 16 + ): raise CertificateError("committed round count exceeds input size") polynomial_num_vars, terms, polynomial_bytes = committed_polynomial( diff --git a/src/field.rs b/src/field.rs index 1c5c574..9780b23 100644 --- a/src/field.rs +++ b/src/field.rs @@ -15,10 +15,8 @@ /// A finite field defined by an odd prime modulus. /// /// The `Field` type stores the modulus `p` and provides elementary -/// arithmetic operations over the integers modulo `p`. It does not -/// perform primality testing; it is the user's responsibility to -/// supply an odd prime. If `p` is not prime, the multiplicative -/// inverse operation will panic when called with a non-unit element. +/// arithmetic operations over the integers modulo `p`. Construction performs +/// deterministic Miller-Rabin primality testing for the full `u64` range. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Field { p: u64, @@ -29,10 +27,12 @@ impl Field { /// /// # Panics /// - /// Panics if the modulus is less than 3 or even. Only odd primes are - /// supported. + /// Panics if the modulus is not an odd prime. pub fn new(p: u64) -> Self { - assert!(p >= 3 && p % 2 == 1, "p must be an odd prime >= 3"); + assert!( + p >= 3 && p % 2 == 1 && is_prime_u64(p), + "p must be an odd prime >= 3" + ); Field { p } } @@ -45,11 +45,7 @@ impl Field { /// Adds two field elements. #[inline] pub fn add(&self, a: u64, b: u64) -> u64 { - let mut s = (a % self.p) + (b % self.p); - if s >= self.p { - s -= self.p; - } - s + (((a % self.p) as u128 + (b % self.p) as u128) % self.p as u128) as u64 } /// Subtracts `b` from `a`. @@ -107,3 +103,82 @@ impl Field { result } } + +fn is_prime_u64(value: u64) -> bool { + if value < 2 { + return false; + } + for prime in [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37] { + if value == prime { + return true; + } + if value.is_multiple_of(prime) { + return false; + } + } + + let exponent = value - 1; + let shifts = exponent.trailing_zeros(); + let odd_part = exponent >> shifts; + const BASES: [u64; 7] = [2, 325, 9_375, 28_178, 450_775, 9_780_504, 1_795_265_022]; + + BASES.into_iter().all(|base| { + let base = base % value; + if base == 0 { + return true; + } + let mut witness = mod_pow(base, odd_part, value); + if witness == 1 || witness == value - 1 { + return true; + } + for _ in 1..shifts { + witness = mod_mul(witness, witness, value); + if witness == value - 1 { + return true; + } + } + false + }) +} + +fn mod_mul(left: u64, right: u64, modulus: u64) -> u64 { + ((left as u128 * right as u128) % modulus as u128) as u64 +} + +fn mod_pow(mut base: u64, mut exponent: u64, modulus: u64) -> u64 { + let mut result = 1u64; + while exponent > 0 { + if exponent & 1 == 1 { + result = mod_mul(result, base, modulus); + } + base = mod_mul(base, base, modulus); + exponent >>= 1; + } + result +} + +#[cfg(test)] +mod tests { + use super::Field; + + #[test] + fn rejects_composites_and_carmichael_numbers() { + for composite in [0, 1, 2, 4, 9, 15, 21, 341, 561, 1_105, 1_729, 3_215_031_751] { + assert!( + std::panic::catch_unwind(|| Field::new(composite)).is_err(), + "{composite} must be rejected" + ); + } + } + + #[test] + fn supports_arithmetic_near_the_u64_limit() { + let field = Field::new(18_446_744_073_709_551_557); + assert_eq!( + field.add(field.modulus() - 1, field.modulus() - 1), + field.modulus() - 2 + ); + assert_eq!(field.mul(field.modulus() - 1, field.modulus() - 1), 1); + assert_eq!(field.mul(7, field.inv(7)), 1); + } +} diff --git a/src/lib.rs b/src/lib.rs index 745f197..1ca588d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,8 +20,8 @@ //! * **Finite field arithmetic** via the [`Field`](field/struct.Field.html) type. //! * **Sum-check demonstration**: the [`sumcheck`](sumcheck/index.html) module //! contains functions to compute the true sum of a small bivariate polynomial -//! over the Boolean hypercube, build a one-shot claim, and verify it with -//! negligible soundness error. +//! over the Boolean hypercube, build a one-shot claim, and verify it. Security +//! depends on the selected field, round count, transcript model, and protocol. //! * **Pseudorandom number generator (PRNG)**: the [`prng`](prng/index.html) //! module exposes a compact BLAKE2b-256 expander that derives deterministic //! Fiat–Shamir challenges from transcripts. It serves as a stand-in for a @@ -33,9 +33,8 @@ //! * **JULIAN protocol blueprint**: the [`julian`](julian/index.html) module //! outlines, through documentation and type stubs, how one could combine //! interactive proofs, VRF randomness, consensus and provability logic -//! into a globally verifiable proof ledger. This module is meant to -//! illustrate a production-ready ledger blueprint, but it does not -//! implement a full ledger. +//! into a globally verifiable proof ledger. It is a protocol blueprint and +//! does not by itself implement a complete production ledger. //! //! ## Usage //! diff --git a/src/sparse_sumcheck.rs b/src/sparse_sumcheck.rs index 631576d..922adff 100644 --- a/src/sparse_sumcheck.rs +++ b/src/sparse_sumcheck.rs @@ -29,6 +29,11 @@ const SPARSE_PRNG_DOMAIN: &[u8] = b"JROC_PRNG"; const CERTIFICATE_MAGIC: &[u8; 8] = b"PHSPv1\0\0"; const POLYNOMIAL_MAGIC: &[u8; 8] = b"PHSMv1\0\0"; const COMMITTED_CERTIFICATE_MAGIC: &[u8; 8] = b"PHCPv1\0\0"; +const MAX_DECODED_VARIABLES: usize = 16_000_000; +const MAX_DECODED_TERMS: usize = 1_000_000; +const MAX_DECODED_DEGREE: usize = 1_000_000; +const MAX_DECODED_SEED_BYTES: usize = 1_048_576; +const MAX_DECODED_INCIDENCES: usize = 64_000_000; /// Public description of a deterministic sparse multilinear polynomial. #[derive(Debug, Clone, PartialEq, Eq)] @@ -212,15 +217,45 @@ impl CommittedSparsePolynomial { } let num_vars = reader.usize()?; let num_terms = reader.usize()?; + if num_vars == 0 + || num_vars > MAX_DECODED_VARIABLES + || num_terms == 0 + || num_terms > MAX_DECODED_TERMS + { + return Err(SparseProofError::InvalidEncoding( + "polynomial dimensions exceed decoder limits", + )); + } if num_terms > reader.remaining() / 24 { return Err(SparseProofError::InvalidEncoding( "polynomial term count exceeds input size", )); } let mut terms = Vec::with_capacity(num_terms); + let mut total_incidences = 0usize; for _ in 0..num_terms { let coefficient = reader.u64()?; let degree = reader.usize()?; + if degree == 0 + || degree > num_vars + || degree > MAX_DECODED_DEGREE + || degree > reader.remaining() / 8 + { + return Err(SparseProofError::InvalidEncoding( + "polynomial degree exceeds input or domain size", + )); + } + total_incidences = + total_incidences + .checked_add(degree) + .ok_or(SparseProofError::InvalidEncoding( + "polynomial incidence count overflow", + ))?; + if total_incidences > MAX_DECODED_INCIDENCES { + return Err(SparseProofError::InvalidEncoding( + "polynomial incidence count exceeds decoder limit", + )); + } let mut variables = Vec::with_capacity(degree); for _ in 0..degree { variables.push(reader.usize()?); @@ -529,13 +564,21 @@ impl SeededSparseProof { let num_vars = reader.usize()?; let num_terms = reader.usize()?; let max_degree = reader.usize()?; - if num_vars == 0 || num_terms == 0 || max_degree == 0 || max_degree > num_vars { + if num_vars == 0 + || num_vars > MAX_DECODED_VARIABLES + || num_terms == 0 + || num_terms > MAX_DECODED_TERMS + || max_degree == 0 + || max_degree > num_vars + || max_degree > MAX_DECODED_DEGREE + || !decoded_work_within_limits(num_terms, max_degree) + { return Err(SparseProofError::InvalidEncoding( "invalid sparse specification", )); } let seed_len = reader.usize()?; - if seed_len > reader.remaining() { + if seed_len > MAX_DECODED_SEED_BYTES || seed_len > reader.remaining() { return Err(SparseProofError::InvalidEncoding( "seed length exceeds input size", )); @@ -666,7 +709,15 @@ impl CommittedSparseProof { let num_vars = reader.usize()?; let num_terms = reader.usize()?; let max_degree = reader.usize()?; - if num_vars == 0 || num_terms == 0 || max_degree == 0 || max_degree > num_vars { + if num_vars == 0 + || num_vars > MAX_DECODED_VARIABLES + || num_terms == 0 + || num_terms > MAX_DECODED_TERMS + || max_degree == 0 + || max_degree > num_vars + || max_degree > MAX_DECODED_DEGREE + || !decoded_work_within_limits(num_terms, max_degree) + { return Err(SparseProofError::InvalidEncoding( "invalid committed certificate metadata", )); @@ -931,6 +982,13 @@ fn push_u64(out: &mut Vec, value: u64) { out.extend_from_slice(&value.to_be_bytes()); } +fn decoded_work_within_limits(num_terms: usize, max_degree: usize) -> bool { + num_terms + .checked_mul(max_degree) + .map(|incidences| incidences <= MAX_DECODED_INCIDENCES) + .unwrap_or(false) +} + struct CertificateReader<'a> { bytes: &'a [u8], offset: usize, diff --git a/tests/sparse_protocol.rs b/tests/sparse_protocol.rs new file mode 100644 index 0000000..7caab00 --- /dev/null +++ b/tests/sparse_protocol.rs @@ -0,0 +1,161 @@ +use power_house::{ + CommittedSparsePolynomial, CommittedSparseProof, Field, SeededSparseProof, SparseMonomial, +}; +use proptest::prelude::*; +use sha2::{Digest, Sha256}; +use std::fs; + +fn dense_sum(polynomial: &CommittedSparsePolynomial, field: &Field) -> u64 { + let mut total = 0; + for assignment in 0..(1usize << polynomial.num_vars()) { + for term in polynomial.terms() { + if term + .variables() + .iter() + .all(|&variable| assignment & (1usize << variable) != 0) + { + total = field.add(total, term.coefficient()); + } + } + } + total +} + +proptest! { + #[test] + fn committed_sparse_matches_dense_enumeration( + num_vars in 1usize..9, + raw_terms in prop::collection::vec( + (1u64..1_000_000, prop::collection::vec(0usize..8, 1..7)), + 1..16, + ), + ) { + let field = Field::new(1_000_000_007); + let terms = raw_terms + .into_iter() + .filter_map(|(coefficient, variables)| { + let mut variables: Vec<_> = variables + .into_iter() + .map(|variable| variable % num_vars) + .collect(); + variables.sort_unstable(); + variables.dedup(); + SparseMonomial::new(coefficient, variables).ok() + }) + .collect::>(); + prop_assume!(!terms.is_empty()); + + let polynomial = CommittedSparsePolynomial::new(num_vars, terms).unwrap(); + let proof = CommittedSparseProof::prove(&polynomial, &field).unwrap(); + prop_assert_eq!(proof.claimed_sum, dense_sum(&polynomial, &field)); + prop_assert!(proof.verify(&polynomial, &field).is_ok()); + + let decoded_polynomial = + CommittedSparsePolynomial::from_bytes(&polynomial.to_bytes()).unwrap(); + let decoded_proof = CommittedSparseProof::from_bytes(&proof.to_bytes()).unwrap(); + prop_assert!(decoded_proof.verify(&decoded_polynomial, &field).is_ok()); + } + + #[test] + fn sparse_decoders_never_panic_on_arbitrary_bytes(bytes in prop::collection::vec(any::(), 0..2048)) { + let committed_polynomial = std::panic::catch_unwind(|| { + CommittedSparsePolynomial::from_bytes(&bytes) + }); + let committed_proof = std::panic::catch_unwind(|| { + CommittedSparseProof::from_bytes(&bytes) + }); + let seeded_proof = std::panic::catch_unwind(|| SeededSparseProof::from_bytes(&bytes)); + + prop_assert!(committed_polynomial.is_ok()); + prop_assert!(committed_proof.is_ok()); + prop_assert!(seeded_proof.is_ok()); + } +} + +#[test] +fn every_single_byte_committed_mutation_is_rejected() { + let field = Field::new(1_000_000_007); + let polynomial_bytes = fs::read("conformance/v1/committed-valid.phsm").unwrap(); + let proof_bytes = fs::read("conformance/v1/committed-valid.phcp").unwrap(); + let polynomial = CommittedSparsePolynomial::from_bytes(&polynomial_bytes).unwrap(); + let proof = CommittedSparseProof::from_bytes(&proof_bytes).unwrap(); + + for index in 0..proof_bytes.len() { + let mut mutated = proof_bytes.clone(); + mutated[index] ^= 1; + let accepted = CommittedSparseProof::from_bytes(&mutated) + .and_then(|candidate| candidate.verify(&polynomial, &field)) + .is_ok(); + assert!(!accepted, "proof mutation at byte {index} was accepted"); + } + + for index in 0..polynomial_bytes.len() { + let mut mutated = polynomial_bytes.clone(); + mutated[index] ^= 1; + let accepted = CommittedSparsePolynomial::from_bytes(&mutated) + .and_then(|candidate| proof.verify(&candidate, &field)) + .is_ok(); + assert!( + !accepted, + "polynomial mutation at byte {index} was accepted" + ); + } +} + +#[test] +fn every_single_byte_seeded_mutation_is_rejected() { + let field = Field::new(1_000_000_007); + let bytes = fs::read("conformance/v1/seeded-valid.phsp").unwrap(); + + for index in 0..bytes.len() { + let mut mutated = bytes.clone(); + mutated[index] ^= 1; + let accepted = SeededSparseProof::from_bytes(&mutated) + .and_then(|candidate| candidate.verify(&field)) + .is_ok(); + assert!(!accepted, "seeded mutation at byte {index} was accepted"); + } +} + +#[test] +fn oversized_polynomial_degree_is_rejected_before_allocation() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"PHSMv1\0\0"); + bytes.extend_from_slice(&8u64.to_be_bytes()); + bytes.extend_from_slice(&1u64.to_be_bytes()); + bytes.extend_from_slice(&1u64.to_be_bytes()); + bytes.extend_from_slice(&u64::MAX.to_be_bytes()); + + assert!(CommittedSparsePolynomial::from_bytes(&bytes).is_err()); +} + +#[test] +fn committed_conformance_vectors_match_manifest() { + let field = Field::new(1_000_000_007); + let manifest: serde_json::Value = serde_json::from_slice( + &fs::read("conformance/v1/manifest.json").expect("conformance manifest"), + ) + .unwrap(); + let seeded_bytes = fs::read("conformance/v1/seeded-valid.phsp").unwrap(); + let polynomial_bytes = fs::read("conformance/v1/committed-valid.phsm").unwrap(); + let proof_bytes = fs::read("conformance/v1/committed-valid.phcp").unwrap(); + + assert_eq!( + hex::encode(Sha256::digest(&seeded_bytes)), + manifest["seeded"]["sha256"].as_str().unwrap() + ); + assert_eq!( + hex::encode(Sha256::digest(&polynomial_bytes)), + manifest["committed"]["polynomial_sha256"].as_str().unwrap() + ); + assert_eq!( + hex::encode(Sha256::digest(&proof_bytes)), + manifest["committed"]["proof_sha256"].as_str().unwrap() + ); + + let seeded = SeededSparseProof::from_bytes(&seeded_bytes).unwrap(); + assert!(seeded.verify(&field).is_ok()); + let polynomial = CommittedSparsePolynomial::from_bytes(&polynomial_bytes).unwrap(); + let proof = CommittedSparseProof::from_bytes(&proof_bytes).unwrap(); + assert!(proof.verify(&polynomial, &field).is_ok()); +}