From 8e052208d2e2023b05ef8e522c01395c3f289002 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:38:45 +0000 Subject: [PATCH 1/6] feat(threshold): add context-bound derivation and FullIdent wrapping Details: TC-2219 --- .github/workflows/rust.yml | 38 + Cargo.lock | 17 + Cargo.toml | 1 + release-plz.toml | 5 + tinycloud-threshold/Cargo.toml | 23 + tinycloud-threshold/README.md | 50 ++ tinycloud-threshold/SPEC.md | 220 +++++ tinycloud-threshold/deny.toml | 31 + tinycloud-threshold/src/checkpoint.rs | 558 ++++++++++++ tinycloud-threshold/src/context.rs | 238 +++++ tinycloud-threshold/src/curve.rs | 815 ++++++++++++++++++ tinycloud-threshold/src/derive.rs | 449 ++++++++++ tinycloud-threshold/src/dst.rs | 236 +++++ tinycloud-threshold/src/encoding.rs | 198 +++++ tinycloud-threshold/src/error.rs | 61 ++ tinycloud-threshold/src/ibe.rs | 139 +++ tinycloud-threshold/src/identity.rs | 339 ++++++++ tinycloud-threshold/src/ids.rs | 275 ++++++ tinycloud-threshold/src/lib.rs | 39 + tinycloud-threshold/src/poly.rs | 25 + tinycloud-threshold/src/vectors.rs | 96 +++ .../vectors/v1/primitives.json | 281 ++++++ .../vectors/v1/rfc9380-g1.json | 67 ++ .../vectors/v1/rfc9380-g2.json | 67 ++ .../vectors/v1/rfc9380-k1.json | 107 +++ 25 files changed, 4375 insertions(+) create mode 100644 tinycloud-threshold/Cargo.toml create mode 100644 tinycloud-threshold/README.md create mode 100644 tinycloud-threshold/SPEC.md create mode 100644 tinycloud-threshold/deny.toml create mode 100644 tinycloud-threshold/src/checkpoint.rs create mode 100644 tinycloud-threshold/src/context.rs create mode 100644 tinycloud-threshold/src/curve.rs create mode 100644 tinycloud-threshold/src/derive.rs create mode 100644 tinycloud-threshold/src/dst.rs create mode 100644 tinycloud-threshold/src/encoding.rs create mode 100644 tinycloud-threshold/src/error.rs create mode 100644 tinycloud-threshold/src/ibe.rs create mode 100644 tinycloud-threshold/src/identity.rs create mode 100644 tinycloud-threshold/src/ids.rs create mode 100644 tinycloud-threshold/src/lib.rs create mode 100644 tinycloud-threshold/src/poly.rs create mode 100644 tinycloud-threshold/src/vectors.rs create mode 100644 tinycloud-threshold/vectors/v1/primitives.json create mode 100644 tinycloud-threshold/vectors/v1/rfc9380-g1.json create mode 100644 tinycloud-threshold/vectors/v1/rfc9380-g2.json create mode 100644 tinycloud-threshold/vectors/v1/rfc9380-k1.json diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9cf15e78..58fc25b3 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -27,6 +27,44 @@ env: # crates for wasm32-unknown-unknown — is now a real, unconditional step in the # `build` job. jobs: + threshold: + name: threshold (crate, policy, primitive checker) + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + CARGO_TARGET_DIR: target/threshold + PROPTEST_CASES: "1024" + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.97.1 + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + with: + shared-key: threshold-1.97.1 + workspaces: . -> target/threshold + - name: Run the complete threshold crate suite + run: cargo test --locked -p tinycloud-threshold --release + - name: Install pinned cargo-deny + run: | + set -euo pipefail + tool_dir="${RUNNER_TEMP}/threshold-cargo-deny" + mkdir -p "${tool_dir}" + curl --fail --location --silent --show-error \ + https://github.com/EmbarkStudios/cargo-deny/releases/download/0.20.2/cargo-deny-0.20.2-x86_64-unknown-linux-musl.tar.gz \ + --output "${tool_dir}/cargo-deny.tar.gz" + echo "9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f ${tool_dir}/cargo-deny.tar.gz" | sha256sum --check - + tar -xzf "${tool_dir}/cargo-deny.tar.gz" --strip-components=1 -C "${tool_dir}" + echo "${tool_dir}" >> "${GITHUB_PATH}" + - name: Check threshold dependency policy + run: cargo deny --manifest-path tinycloud-threshold/Cargo.toml --locked --config tinycloud-threshold/deny.toml check licenses bans advisories sources + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.4" + - name: Run the independent primitive checker + working-directory: tinycloud-threshold/vectors/check + run: bun install --frozen-lockfile && bun run check + build: name: build (workspace) runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index df93b7cc..06eb5e08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10089,6 +10089,23 @@ dependencies = [ "x25519-dalek", ] +[[package]] +name = "tinycloud-threshold" +version = "0.1.0" +dependencies = [ + "blst", + "hex", + "proptest", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "tinycloud-verifier-wasm" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 6db1b214..4d2f8c67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "tinycloud-verifier-wasm", "tinycloud-sdk-rs", "tinycloud-core", + "tinycloud-threshold", "dependencies/siwe", "dependencies/siwe-recap", "dependencies/cacao", diff --git a/release-plz.toml b/release-plz.toml index d0a52710..e78db348 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -95,3 +95,8 @@ publish = false name = "cacaos" release = false publish = false + +[[package]] +name = "tinycloud-threshold" +release = false +publish = false diff --git a/tinycloud-threshold/Cargo.toml b/tinycloud-threshold/Cargo.toml new file mode 100644 index 00000000..9281a1be --- /dev/null +++ b/tinycloud-threshold/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "tinycloud-threshold" +version = "0.1.0" +edition = "2021" +rust-version = "1.97.1" +license-file = "../LICENSE.md" +publish = false +description = "Context-bound threshold key derivation and identity-based key wrapping" + +[dependencies] +blst = "=0.3.16" +sha2 = "0.10" +zeroize = { version = "1.8", features = ["derive"] } +subtle = "2.6" +rand_core = "0.6" +thiserror = "2" + +[dev-dependencies] +proptest = "1.6" +rand_chacha = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +hex = "0.4" diff --git a/tinycloud-threshold/README.md b/tinycloud-threshold/README.md new file mode 100644 index 00000000..dca83576 --- /dev/null +++ b/tinycloud-threshold/README.md @@ -0,0 +1,50 @@ +# tinycloud-threshold + +BLS12-381 identity-based wrapping of 32-byte data keys and context-bound encrypted threshold key derivation. + +The crate is a workspace member with `publish = false`. No other workspace crate depends on it; the node server remains the default member. Release-plz neither releases nor publishes it. + +## S0.1 surface + +- Bounded, canonical scalar, point, identity, ciphertext, request and share encodings. +- Boneh–Franklin FullIdent wrapping and unwrapping with the TinyCloud domains in [SPEC.md](SPEC.md). +- Capability-asserted derivation requests, individual member-share verification, lowest-index threshold interpolation and reader unmasking. +- Keys retain their identity and certificate/generation context. Shares carry both the key-context digest and the request digest. +- The blst FFI boundary is confined to `src/curve.rs`; its header inventories the entry points and buffer invariants. + +`KeyContext`, `PublicKeySet` and `KeyShare` have no public S0.1 constructor. The dealer exists only under `#[cfg(test)]`. Certificate, ceremony, storage and node-runtime paths are outside this slice. Their protocol contracts are recorded in SPEC.md §§3.7–3.12. + +The §3.6 request-digest amendment is explicit in SPEC.md. It makes an identity or transport-key transplant a context mismatch before share equations are evaluated. + +## Checks + +Use the workspace's pinned Rust 1.97.1 toolchain: + +```sh +cargo test --locked -p tinycloud-threshold +cargo test -p tinycloud-threshold checkpoint_2of3 -- --nocapture +cargo clippy -p tinycloud-threshold --all-targets -- -D warnings +cargo fmt --all -- --check +PROPTEST_CASES=1024 cargo test --locked -p tinycloud-threshold --release +cargo deny --manifest-path tinycloud-threshold/Cargo.toml --locked --config tinycloud-threshold/deny.toml check licenses bans advisories sources +``` + +The `threshold` job in `.github/workflows/rust.yml` runs the full release suite, the explicitly selected cargo-deny policy and the independent primitive checker. Workspace test, build, formatting and clippy jobs also include this member. + +The checkpoint prints nine executed outcomes. Required RFC tests are separately named `rfc9380_appendix_j_g1_sswu_ro`, `rfc9380_appendix_j_g2_sswu_ro` and `rfc9380_appendix_k1_expand_xmd_sha256`. + +## Vectors + +`vectors/v1/primitives.json` records fixed test seeds and test scalars, frozen domains, hash/group/pairing bytes, FullIdent inputs and outputs, a 2-of-3 derivation and named error cases. These are public test values, not deployment keys. `primitive_vectors_regenerate_byte_for_byte` regenerates the file in memory and compares its complete bytes. To update it deliberately: + +```sh +TC_THRESHOLD_REGEN_VECTORS=1 cargo test -p tinycloud-threshold primitive_vectors_regenerate_byte_for_byte +``` + +`rfc9380-{g1,g2,k1}.json` contains the published Appendix J.9.1, J.10.1 and K.1 cases. The separate-session checker consumes SPEC.md and the JSON vector format, without consulting the Rust implementation. Its CI entry point is `bun install --frozen-lockfile && bun run check` in `vectors/check/`. + +## Dependencies and licence + +This crate uses the workspace's [TinyCloud EGPL v1.5](../LICENSE.md) through `license-file`. Production dependencies: `blst` (Apache-2.0), `sha2` (MIT OR Apache-2.0), `zeroize` (Apache-2.0 OR MIT), `subtle` (BSD-3-Clause), `rand_core` (MIT OR Apache-2.0), and `thiserror` (MIT OR Apache-2.0). Development dependencies: `proptest`, `rand_chacha`, `serde`, `serde_json`, and `hex` (MIT OR Apache-2.0). + +The crate-scoped `deny.toml` includes development and build dependencies. It allows Apache-2.0, MIT, BSD-3-Clause, ISC and Unicode-3.0, with a hash-pinned EGPL clarification and exception for this crate only. Registry sources are restricted to crates.io. diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md new file mode 100644 index 00000000..0ba88630 --- /dev/null +++ b/tinycloud-threshold/SPEC.md @@ -0,0 +1,220 @@ +# tinycloud-threshold protocol specification + +Section numbers track the approved TinyCloud plan. §§3.1–3.6 are frozen for S0.1 except the explicit request-digest amendment below. + +### 3.0 Protocol model, parameter budgets and authenticated contexts + +**Parties.** The owner's device authorizes ceremonies and certificates, orders each ceremony round and closes it with a signed round closure. Members are node processes with node keys (Ed25519 `did:key`). Readers hold transport keys. + +**Ceremony model.** The membership and faulty-member set are fixed before each ceremony. A member in both the old and new cohort of a reshare counts in both. Cross-epoch operation requires the lifecycle in §3.11 and the integration storage contract. + +**Owner relay.** The owner's device is trusted to authorize contexts and to close each round once, without equivocating. Owner equivocation is outside this model; divergent transcript hashes are detectable. + +**Parameter budgets** (f = faulty members in the ceremony model): + +| Operation | Allowed parameters | Required outcome within budget | Refused | +|---|---|---|---| +| Genesis DKG (GJKR New-DKG) | 2 ≤ t ≤ n ≤ 16 **and n ≥ 2t−1**; f = t−1 | Every participating conforming member and the public verifier finish with the same QUAL, commitments and transcript outcome | n < 2t−1 → `Error::UnsupportedFaultBudget`. Example: 3-of-4 genesis is refused. There is no separate, smaller f in slice 0. | +| Reshare / refresh | Old cohort (t, n) from the verified certificate; new 2 ≤ t′ ≤ n′ ≤ 16; the model permits ≤ t−1 faulty old **and** ≤ t′−1 faulty new members; completion needs ≥ t old members with valid dealings | mpk unchanged; honest new members' shares consistent with the public commitments | t′ < 2; fewer than t valid dealers → abort, old epoch stays in force | +| Threshold decryption | t shares of one key context | Correct key for any t honest shares; each bad share attributed | Mixed contexts refused before any algebra (§3.6) | + +Reshare pins every dealer constant B_i0 to the old public share pk_i before evaluation. For selected old indices D, the weighted constants satisfy Σ λ_i^D·pk_i = mpk. A new cohort need not satisfy the genesis inequality; it must still supply t′ valid decryption shares. The complaint/answer extension and deterministic first-t dealer selection are TinyCloud protocol choices; Wong–Wang–Wing assume conforming new receivers. + +**Raw inputs, authenticated contexts, node-asserted contexts.** Secret-releasing functions never take raw inputs alone. + +| Kind | Types | Who establishes it | +|---|---|---| +| Raw / decoded | `DecryptRequest`, `SignedCertificate`, ceremony messages, `SignedStatement` | Decoders (validation only) | +| Crate-authenticated | `VerifiedCertificate`, `AuthorizedCeremony`, `ClosedRound`, `KeyContext` | The crate, from a caller-supplied `TrustAnchor` plus owner/member signatures checked through the injected `DidSignatureVerifier` | +| Node-asserted (one narrow constructor) | `AuthorizedDerivation` | The node, after its existing UCAN capability check, by passing the URN, key path and version that check authorized; the crate rebuilds the identity from them and requires equality with the request's identity. No second authorization system. | + +`TrustAnchor { expected_owner: Did, network: NetworkUrn }` always comes from the node's authorization context (the space's owner and the authorized network URN), never from a candidate certificate. "Current certificate" (freshness, no rollback) is decided by the node from the trusted authorization-log head (gate B, TC-2225); the crate provides lineage checks against a pinned predecessor but cannot know what is newest. + +### 3.1 Notation and groups (frozen for S0.1) + +- BLS12-381: G1, G2, GT, prime order r, standard generators g1, g2, reduced (optimal Ate, final exponentiation applied) pairing e. +- Master secret s ∈ Zr; **mpk = s·g2 ∈ G2**; share s_i; public share pk_i = s_i·g2. +- Identities hash to G1: H1(id). Derived key k = s·H1(id) ∈ G1. Transport key tsk ∈ Zr*, tpk = tsk·g1. +- t-of-n with polynomials of degree t−1; member indices are the Shamir x-coordinates 1..=n, never 0. +- λ_i^S = ∏_{j∈S, j≠i} j/(j−i) mod r. +- **Key generation** = (re-key counter, epoch). Epoch increases by 1 on every reshare and on every re-key and is never reset; the re-key counter increases by 1 on each re-key. Both use checked arithmetic (overflow is an error). Requests also bind the certificate digest, so the generation alone is never the only identifier. + +### 3.2 Encodings (frozen for S0.1) + +- **Scalars:** 32-byte big-endian, value < r; non-canonical encodings rejected. Secret scalars are sampled as 64 CSPRNG bytes read big-endian and reduced mod r (bias ≤ 2^-256), redrawn if zero where non-zero is required. Never reduce only 32 random bytes. +- **G1:** 48-byte compressed; **G2:** 96-byte compressed; the ZCash compressed serialization as described in the pairing-friendly-curves draft (as produced by blst `*_compress`). Decoders reject wrong length, bad flag bits, x ≥ p, off-curve, and points outside the prime-order subgroup (subgroup check performed explicitly, separate from decompression). Identity is accepted or rejected **by field role** (table below). +- **GT** (hashed only, never decoded): 576 bytes = 12 base-field elements, each 48-byte big-endian, in tower-coefficient order c0.c0.c0, c0.c0.c1, c0.c1.c0, c0.c1.c1, c0.c2.c0, c0.c2.c1, c1.c0.c0, c1.c0.c1, c1.c1.c0, c1.c1.c1, c1.c2.c0, c1.c2.c1, for the tower Fp2 = Fp[u]/(u²+1), Fp6 = Fp2[v]/(v³−(u+1)), Fp12 = Fp6[w]/(w²−v). Always the reduced pairing value, never a raw Miller-loop output. The S0.1 independent checker pins e(g1, g2) under this encoding as a known-answer vector. +- **Structures:** 1-byte format version; fields in a fixed order; variable-length fields as u32 big-endian length + bytes; lists as u16 count + elements sorted by member index; optional fields as 0x00 (absent) or 0x01 + value. Decoders reject trailing bytes, unsorted or duplicate entries, and lengths over the caps (checked before allocating). +- **Caps:** n ≤ 16; URN and key path 1..=1024 bytes UTF-8; DID ≤ 256 bytes; signature ≤ 512 bytes; any single message ≤ 64 KiB; a whole transcript ≤ 4 MiB. +- **Tagged hash:** TH(tag, body) = SHA-256(u32be(len(tag)) ‖ tag ‖ body). + +Identity-point rules by role (N1): + +| Field | Identity allowed? | +|---|---| +| mpk; every public share pk_i in a certificate or key set; tpk; IBE U; encrypted-share c1 and c2 (r_i ≠ 0) | No | +| Encrypted-share c3; aggregates C1, C2, C3 inside `combine` | Yes (c3 must still be a valid subgroup point) | +| Individual commitment coefficients (Pedersen C_ik, Feldman A_ik, B_ik, B′_k) and internal accumulators | Yes, except the constant and top coefficients named below | +| Aggregate DKG commitments: A_0 (= mpk) and A_{t−1}; reshare B_i0 (must equal pk_i), B_i,t′−1 and aggregate B′_{t′−1} | No | +| Derived key k | No | + +### 3.3 Domain-separation tags (frozen for S0.1) + +| Use | Primitive | DST / tag | +|---|---|---| +| H1: identity → G1 | RFC 9380 hash_to_curve, BLS12381G1_XMD:SHA-256_SSWU_RO_ | `TC-THRESHOLD-V1-IBE-ID_BLS12381G1_XMD:SHA-256_SSWU_RO_` | +| Pedersen h ∈ G2 (msg `pedersen-h`) | RFC 9380 hash_to_curve, BLS12381G2_XMD:SHA-256_SSWU_RO_ | `TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_` | +| FO H2: GT bytes → 32 bytes | expand_message_xmd (SHA-256), len 32 | `TC-THRESHOLD-V1-IBE-H2` | +| FO H3: (σ ‖ M) → Zr | hash_to_field with **modulus r** (scalar field, not the base field p), L = 48, count = 1 (RFC 9380 §5) | `TC-THRESHOLD-V1-IBE-H3` | +| FO H4: σ → 32 bytes | expand_message_xmd (SHA-256), len 32 | `TC-THRESHOLD-V1-IBE-H4` | +| Identity encoding prefix | — | `TC-THRESHOLD-V1-IBE-ID-ENC` | +| Key-context digest | TH | `TC-THRESHOLD-V1-KEY-CONTEXT` | +| Decryption request digest | TH | `TC-THRESHOLD-V1-DECRYPT-REQUEST` | +| Share response signing preimage | TH | `TC-THRESHOLD-V1-DECRYPT-SHARE` | +| Commitment-vector hash | TH | `TC-THRESHOLD-V1-COMMITMENTS` | +| Ceremony authorization statement | TH | `TC-THRESHOLD-V1-CEREMONY-AUTH` | +| Ceremony message signing preimage | TH | `TC-THRESHOLD-V1-CEREMONY-MSG` | +| Round closure | TH | `TC-THRESHOLD-V1-ROUND-CLOSE` | +| Transcript hash | TH | `TC-THRESHOLD-V1-TRANSCRIPT` | +| Certificate digest | TH | `TC-THRESHOLD-V1-NETWORK-CERT` | +| Owner certificate statement | TH | `TC-THRESHOLD-V1-CERT-STATEMENT` | +| Readiness receipt | TH | `TC-THRESHOLD-V1-READY` | +| Activation statement | TH | `TC-THRESHOLD-V1-ACTIVATE` | +| HPKE `info` for dealings | RFC 9180 | `TC-THRESHOLD-V1-DEALING` ‖ authorized-ceremony digest ‖ round ‖ sender ‖ recipient | + +Tags are distinct; the context binding comes from the context fields inside each preimage (§3.6, §3.9) and is tested by context-mismatch cases (§6 S6). + +Key-use rule: s·H1(id) is a BLS signature on id under DST H1 (BLS 2001; Boldyreva 2003). s and the H1 DST are used for derivation only; the crate exposes no signing with s or with shares of s. + +### 3.4 IBE identity (frozen for S0.1) + +`IbeIdentity` bytes = `TC-THRESHOLD-V1-IBE-ID-ENC` as a length-prefixed field ‖ version 0x01 ‖ mode (0x01 record, 0x02 session) ‖ lp(network URN) ‖ lp(key path) ‖ key_version u64be ‖ (record only) 16-byte nonce. URN and path are taken as given (the node builds them from the authorized URN). One parse only; property-tested for round-trip and injectivity on adversarial splits. `IbeIdentity::network()` exposes the encoded network; `DecryptRequest` requires it to equal the request's outer network. + +### 3.5 IBE wrap and unwrap: Boneh–Franklin FullIdent with Fujisaki–Okamoto (frozen for S0.1) + +wrap(mpk, id, M ∈ {0,1}^256): +1. σ ← 32 CSPRNG bytes; ρ = H3(σ ‖ M). If ρ = 0: return `Error::DegenerateRandomness` (probability ≈ 2^-255; specified so both implementations agree). +2. U = ρ·g2; V = σ ⊕ H2(GT-encode(e(ρ·H1(id), mpk))); W = M ⊕ H4(σ). Use the scalar multiplication ρ·H1(id) in G1 (the fixed-width secret-scalar path), not an exponentiation in GT. +3. Ciphertext (U, V, W) = 96 + 32 + 32 bytes. + +unwrap(k: &DerivedKey, expected_id, (U, V, W)): +1. If k's identity ≠ expected_id: `Error::WrongIdentity` (DerivedKey carries its identity, §5). +2. Decode U (subgroup, non-identity). σ = V ⊕ H2(GT-encode(e(k, U))); M = W ⊕ H4(σ); ρ = H3(σ ‖ M). +3. Accept only if ρ ≠ 0 and ρ·g2 = U (constant-time comparison of encodings); otherwise the single error `Error::InvalidCiphertext`. + +**Q15 resolved:** keep H3(σ ‖ M) as in FullIdent. Adding U is circular (U is computed from H3's output). The identity association lives in `DerivedKey` (above) and in the envelope AAD at integration. + +### 3.6 Key context, requests and encrypted decryption shares (vetKD; frozen for S0.1) + +**KeyContext** = (network URN, re-key counter, epoch, certificate digest, threshold). Every `PublicKeySet` and `KeyShare` carries exactly one. Production key contexts come only from a `VerifiedCertificate` (S0.2); in S0.1 only the `#[cfg(test)]` dealer creates them. + +**DecryptRequest** (raw) = (key context digest, network URN, re-key counter, epoch, certificate digest, IbeIdentity, tpk). Its digest = TH(`…DECRYPT-REQUEST`, encode(request)). Validation: identity's network = outer network; tpk decodes (subgroup, non-identity). + +**AuthorizedDerivation** (node-asserted, §3.0) = a `DecryptRequest` plus the URN, path and version the node's capability check authorized; constructor rebuilds the identity from them and requires byte equality with the request's identity. + +- **Issue** (member i, `KeyShare` with context X): require the request's context fields equal X exactly, else `Error::ContextMismatch` (no share released). r_i ← Zr* (§3.2 sampling). Share = (i, context digest, request digest, c1 = r_i·g1, c2 = r_i·g2, c3 = s_i·H1(id) + r_i·tpk). The request digest is the approved S0.1 amendment recorded below. +- **Response signing preimage** = TH(`…DECRYPT-SHARE`, request digest ‖ certificate digest ‖ u16be(i) ‖ encode(share)). The node signs it with its node key. +- **Verify** against the `PublicKeySet` of context X: first the context check (share's and request's context = X; share's request digest = request digest; member i in X); a mismatch is `ContextMismatch`, never `InvalidShare`. Then e(c1, g2) = e(g1, c2) and e(c3, g2) = e(H1(id), pk_i)·e(tpk, c2), each checked as one multi-pairing; failure is `ShareError::Invalid { member }`. These equations follow the published aggregatable vetBLS scheme. +- **Combine:** context check on every share first (mixed contexts → `CombineError::MixedContexts`; this check is unconditional, independently of the final pairing result). Each share verified individually; duplicates by member counted once; needs ≥ t; uses the t lowest indices. C_j = Σ λ_i^S·c_{j,i}. +- **Decrypt key:** k = C3 − tsk·C1. Accept only if k ≠ identity and e(k, g2) = e(H1(id), mpk). Returns `DerivedKey { identity, key context digest, k }`. This final check checks the key against id and mpk; context and authorization checks run separately first. + +### 3.7 Genesis DKG: GJKR New-DKG (normative; implemented in S0.3) + +Preconditions: an `AuthorizedCeremony` of kind `genesis` (§3.8 step 0 applies to genesis too) whose parameters satisfy §3.0's genesis budget (n ≥ 2t−1); each member's ceremony-scoped HPKE key from that context. Generators g2 and h in G2. + +**Rounds.** Every round is a `ClosedRound`: the owner's signed closure over (authorized-ceremony digest, round number, TH of the ordered message list, previous transcript hash). Members process only closed rounds and only messages signed by authorized members for that ceremony and round. + +1. **R1 deal.** Dealer i picks f_i, f′_i of degree t−1 (§3.2 sampling); publishes C_ik = a_ik·g2 + b_ik·h (k = 0..t−1); seals (s_ij, s′_ij) to each member j (§3.10), AAD = TH(`…COMMITMENTS`, C_i). +2. **R2 complain.** Member j complains against i if the sealed share does not open or s_ij·g2 + s′_ij·h ≠ Σ_k j^k·C_ik. +3. **R3 answer.** Dealer i publishes (s_ij, s′_ij) for each complaint against it. **A valid answer replaces j's missing or bad private value.** +4. **QUAL** (fixed here, never changed later). i is disqualified if: it is missing from closed R1; its R1 is malformed; it sent two different R1 messages; it has complaints from ≥ t distinct authorized complainers; it fails to answer a complaint in closed R3; or an answer fails the Pedersen check. Abort if |QUAL| < t. +5. **R4 extract.** Each i ∈ QUAL publishes A_ik = a_ik·g2 (k = 0..t−1). +6. **R5 extraction complaints: global barrier.** **Every** member sends an R5 message, possibly with an empty complaint list, and **no member finalizes before consuming the same closed R5 round.** Member j's R5 lists each i ∈ QUAL with s_ij·g2 ≠ Σ_k j^k·A_ik, attaching (s_ij, s′_ij), which anyone checks against C_i (Pedersen-valid) and A_i (Feldman-invalid). Also, by the public rules, every i ∈ QUAL whose R4 is missing, malformed (wrong length, invalid point, identity where forbidden) or equivocated is an **extraction fault**. Members keep all received (s_ij, s′_ij) until the barrier and any R6 complete. +7. **R6 reconstruct.** If any QUAL dealer has an extraction fault or a valid R5 complaint, every member publishes its Pedersen-valid (s_ij, s′_ij) for each such dealer. Anyone interpolates f_i from t Pedersen-valid values and recomputes A_ik. The dealer **stays in QUAL**; its fixed contribution is recovered, never removed (the fixed contribution is retained). Under §3.0's budget there are always ≥ t honest values, so missing R6 messages from corrupt members can't stall it. +8. **Output.** s_j = Σ_{i∈QUAL} s_ij (with R3 replacements); A_k = Σ_{i∈QUAL} A_ik (with R6 recoveries); mpk = A_0; pk_j = Σ_k j^k·A_k. Reject if mpk, A_{t−1} or any pk_j is the identity. Transcript hash = TH(`…TRANSCRIPT`, the ordered closed rounds). + +**Determinism rules** (members and `dkg_verify_transcript` apply the same ones): +- A message counts only if it is in the closed round, signed by an authorized member of this ceremony, and decodes. +- Byte-identical retransmissions are deduplicated, no fault. +- Two different messages from one sender in one round: in R1–R3 the sender is disqualified (QUAL is not fixed yet); in R4 it is an extraction fault (reconstruction, not removal); in R5/R6 that sender's messages for the round are ignored. +- Complaints are counted per distinct authorized complainer; self-complaints are ignored. +- Missing R3 answer → disqualified; missing R4 → extraction fault; missing R5 → treated as an empty complaint list; missing R6 → that member contributes no values. +- If any round closes without the messages needed under the budget (for example fewer than t Pedersen-valid R6 values), the ceremony aborts with a typed error; it never returns a partial key. + +### 3.8 Resharing (Desmedt–Jajodia redistribution, Feldman verification after Wong–Wang–Wing, refresh per Herzberg et al.; normative; implemented in S0.4) + +0. **Authorization before any secret leaves a dealer (also applies to genesis).** An old member deals only under an `AuthorizedCeremony`: an owner-signed ceremony statement, checked through the verifier against the `TrustAnchor`, over: kind (genesis / reshare / refresh / re-key), a fresh 32-byte ceremony id, network, owner, the **current certificate digest** (reshare, refresh, re-key), the new threshold t′ and n′, the new key generation, and the recipient directory: index → (node DID, **ceremony-scoped HPKE public key**). The current certificate must be the `VerifiedCertificate` the node treats as current, and the statement's certificate digest must equal it. Recipient keys come only from this statement, never from the old certificate. The owner's device signs it under the existing membership approval (no extra prompt; the carrier is Q10). + - A dealer deals at most once per ceremony id. A retry is a new ceremony id with fresh randomness; a dealing is bound to its context and is never re-sent to a changed recipient or index map. Persisting a "dealt for ceremony X" marker across crashes is an integration duty (§12). + - Dealers release sub-shares only to the authorized directory. The holder of t shares can reconstruct the scalar. +1. **R1 deal.** Old member i picks g_i of degree t′−1 with g_i(0) = s_i; publishes B_ik = b_ik·g2 with exactly t′ entries; seals g_i(j) to each new member j. Public checks: B_i0 = pk_i of the current key context; length = t′; B_i,t′−1 ≠ identity. +2. **R2 complain / R3 answer**, as §3.7 steps 2–3 (Feldman check g_i(j)·g2 = Σ_k j^k·B_ik); answers are secret-bearing and also require the `AuthorizedCeremony`. +3. **Dealers D** = the first t old members in closed-round order whose R1 passed the public checks and that have no upheld complaint. Abort if fewer than t; the old epoch stays active. +4. **Output (a candidate).** s′_j = Σ_{i∈D} λ_i^D·g_i(j); B′_k = Σ_{i∈D} λ_i^D·B_ik; require B′_0 = mpk and B′_{t′−1} ≠ identity; pk′_j = Σ_k j^k·B′_k. The new shares belong to a **candidate** key context (epoch + 1) and are not usable until activation (§3.12). +5. An epoch tag doesn't erase anything: old shares keep working with the old certificate until their secrets are actually unrecoverable (§3.11). +6. A fresh genesis-style DKG under kind `re-key` is the only way to change mpk. + +### 3.9 Network certificate, trust anchor and fault proofs (normative; implemented in S0.2) + +**Body:** format version; network URN; owner DID; re-key counter; epoch; genesis t (t of the DKG that created this mpk); t; n; members [(index, node DID)] (no long-term HPKE keys); mpk; commitment vector (exactly t entries); transcript hash; previous certificate digest (absent only at genesis); previous mpk (present only on a re-key). Certificate digest = TH(`…NETWORK-CERT`, body). The owner signs TH(`…CERT-STATEMENT`, certificate digest). + +**`verify_certificate(anchor, signed, verifier)` → `VerifiedCertificate`:** +- owner DID = `anchor.expected_owner` and network = `anchor.network` (an otherwise valid self-signed certificate naming another owner is rejected); +- the owner signature verifies through the injected verifier (principle 2's allow-list lives behind it, in tinycloud-auth); +- validation of the **decoded** object, not just of constructors: 2 ≤ t ≤ n ≤ 16; genesis t ≥ 2; indices 1..=n, distinct, sorted; member DIDs distinct (no weighted membership); commitments length t; commitment[0] = mpk; commitment[t−1] ≠ identity; every evaluated public share valid and non-identity; field combinations: genesis ⇒ no previous digest, no previous mpk, epoch 0, re-key counter 0; reshare successor ⇒ previous digest, no previous mpk; re-key ⇒ previous digest and previous mpk. + +**`verify_successor(pinned: &VerifiedCertificate, next, verifier)`:** same owner and network (re-checked against the anchor); next.previous digest = pinned digest; epoch = pinned + 1 (checked); and either (reshare/refresh) the same mpk, re-key counter and genesis t, or (re-key) re-key counter + 1, previous mpk = pinned mpk, new mpk, genesis t = new t. Rollback and alternate lineages are rejected relative to the pinned certificate; which certificate is pinned as current comes from the trusted log head (gate B), not from the crate. + +`VerifiedCertificate::key_context()` yields the `KeyContext`; `KeyShare::bind(candidate, &VerifiedCertificate)` checks s_i·g2 equals the certificate's pk_i and the transcript hash matches. + +**Fault proof** = (request, share, response signature, the share's certificate). Order of checks: (1) the certificate is a `VerifiedCertificate` under the anchor; (2) request and share context = that certificate's context, and the member is in it; (3) the member's signature over the §3.6 preimage verifies; (4) §3.6 Verify fails. Only then is it a fault proof naming the member. Any failure in (1)–(3) returns `NotAFault(reason)`. A genuine old response replayed against a re-keyed or different certificate therefore never accuses its signer. + +### 3.10 Sealed dealings and ceremony-scoped recipient keys (implemented in S0.3) + +- HPKE (RFC 9180) base mode, DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305, composed from rust-hpke (no hand-written HPKE). One-shot contexts; fresh encapsulation randomness per dealing; invalid or all-zero X25519 results rejected as RFC 9180 requires. +- `info` = §3.3 tag ‖ authorized-ceremony digest ‖ round ‖ sender index ‖ recipient index; AAD = commitment-vector hash. Base mode has no sender authentication; the member signature on the closed-round message authenticates the envelope. +- **Recipient keys are ceremony-scoped.** Each recipient generates a fresh X25519 key pair for each ceremony, independently random (never derived from a node root or any retained secret), and its public key is bound into the `AuthorizedCeremony` directory. Long-lived node encryption keys are never used for dealings. + +### 3.11 Erasure inventory and lifecycle (contract fixed now; durable storage is integration) + +Secrets that must become unrecoverable, and when: + +| Secret | Erase when | +|---|---| +| Ceremony HPKE recipient secret key | After the member's new share is durably installed and the ceremony is closed (after R6, or after the reshare candidate is installed), or on abort | +| Dealer polynomial coefficients a_ik, blinding coefficients b_ik, reshare g_i | After the last round in which the dealer may need to answer (closed R3; for genesis, after the R5 barrier and any R6), or on abort | +| Received private values s_ij, s′_ij, g_i(j), and decrypted plaintext buffers | After the member's share is computed and the R5 barrier and any R6 are complete | +| RNG seed or state from which any of the above can be regenerated | Same time as the secrets it generated | +| Crash-recovery copies of ceremony state | Same time as the in-memory originals; they live only on the gate-A volume | +| Old-epoch share s_i | At retirement (§3.12), never earlier | +| σ, ρ, candidate plaintext, tsk, k, combine intermediates | Before the function returns, on success and error paths | + +Public ciphertext transcripts may be kept once the matching recipient keys are unrecoverable. In memory the crate zeroizes on drop and avoids hidden copies (blst `Copy` types are wiped explicitly where they hold secrets); `ZeroizeOnDrop` is an in-memory operation; durable storage is handled by integration. Durable erasure, snapshots, swap and core dumps are gate A's acceptance (§12). + +### 3.12 Activation boundary (contract fixed now; durable state record is integration) + +An owner signature on a new certificate is **not** an erasure trigger. Transition states: + +1. **Prepared.** New holders validate their shares, bind them to the candidate certificate (`KeyShare::bind`), install them durably, and each sends a **readiness receipt**: node-signed TH(`…READY`, candidate certificate digest ‖ transcript hash ‖ u16be(index) ‖ pk′_index). A receipt is the node's signed statement of durable installation. +2. **Activated.** The owner signs an activation statement TH(`…ACTIVATE`, candidate certificate digest ‖ sorted receipt hashes) only when receipts reach the **ready quorum**: by default **every** new holder (n′). A lower quorum t′ + o is allowed only for an explicitly declared total unavailable-ready-holder budget o (including dishonest or unavailable signers); t′ alone is never enough. For the 2-of-3 any-one-node-failure claim, all three must be ready. The node records activation in a durable, monotonic state record. +3. **Retired.** Only after activation do old holders erase old-epoch shares and their ceremony secrets. + +Abort at any point before activation leaves the old epoch fully usable. A restart must not restore a retired epoch or reuse an old ceremony's randomness. The crate provides the receipt and activation preimages and `verify_activation(candidate, receipts, quorum, verifier)`; persistence, the state record and fault injection belong to gate A and TC-2226. + + +## S0.1 byte layouts and approved §3.6 amendment + +**Deliberate amendment to §3.6:** encrypted shares carry the request digest, also included explicitly in the response signing preimage, so identity/tpk transplants return `ContextMismatch` before algebra; required by S6 test `request_transplants_fail_before_algebra`. + +All integer fields below are big-endian. `lp` is u32 length followed by bytes. + +- Key-context digest body: `01 || lp(network) || rekey:u64 || epoch:u64 || certificate:32 || t:u16 || n:u16`. +- Request: `01 || context_digest:32 || lp(network) || rekey:u64 || epoch:u64 || certificate:32 || lp(identity) || tpk:48`. +- Encrypted share: `01 || member:u16 || context_digest:32 || request_digest:32 || c1:48 || c2:96 || c3:48`. +- Share signing preimage: `TH(DECRYPT_SHARE, request_digest || certificate_digest || member:u16 || encode(share))`. The share request digest must equal the supplied request digest. +- IBE ciphertext: the fixed-width `U:96 || V:32 || W:32` FullIdent tuple (160 bytes); no additional structure header. +- No S0.1 public decoder consumes a member list; lists introduced in later slices use the §3.2 sorted/count rules. +- An availability budget counts all unavailable ready signers, including dishonest claims: `q - b >= t′`. All four ready holders in 3-of-4 support one unavailable holder, not two. `WithOutageBudget(0)` is not accepted. + +## Implemented surface + +S0.1 implements §§3.1–3.6 and the typed parameters in §3.0. §§3.7–3.12 specify later slices and integration contracts; no ceremony, certificate, activation, storage or node-runtime implementation is included here. `KeyContext` and `KeyShare` have no public constructor in S0.1. The dealer is test-only. diff --git a/tinycloud-threshold/deny.toml b/tinycloud-threshold/deny.toml new file mode 100644 index 00000000..5509c304 --- /dev/null +++ b/tinycloud-threshold/deny.toml @@ -0,0 +1,31 @@ +# Selected explicitly by the threshold CI job; only this crate is a graph root. +[graph] +all-features = true + +[licenses] +allow = ["Apache-2.0", "MIT", "BSD-3-Clause", "ISC", "Unicode-3.0"] +include-dev = true +include-build = true +confidence-threshold = 0.95 +unused-allowed-license = "allow" + + +[[licenses.clarify]] +name = "tinycloud-threshold" +expression = "LicenseRef-TinyCloud-EGPL-1.5" +license-files = [{ path = "../LICENSE.md", hash = 0x2a86e8e4 }] + +[[licenses.exceptions]] +name = "tinycloud-threshold" +allow = ["LicenseRef-TinyCloud-EGPL-1.5"] +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] + +[advisories] +version = 2 diff --git a/tinycloud-threshold/src/checkpoint.rs b/tinycloud-threshold/src/checkpoint.rs new file mode 100644 index 00000000..f01788f8 --- /dev/null +++ b/tinycloud-threshold/src/checkpoint.rs @@ -0,0 +1,558 @@ +use super::*; +use crate::curve::{hash_g1, Scalar, G1, G2}; +use proptest::prelude::*; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +pub(crate) fn dealer(seed: u8, context: KeyContext) -> (PublicKeySet, Vec, Scalar) { + let mut rng = ChaCha20Rng::from_seed([seed; 32]); + let coefficients: Vec<_> = (0..context.threshold().t()) + .map(|_| Scalar::random(&mut rng, true)) + .collect(); + let public_shares = (1..=context.threshold().n()) + .map(|i| { + let index = MemberIndex::new(i).unwrap(); + ( + index, + G2::generator().mul(&crate::poly::evaluate( + &coefficients, + &Scalar::from_u64(u64::from(i)), + )), + ) + }) + .collect(); + let shares = (1..=context.threshold().n()) + .map(|i| KeyShare { + context: context.clone(), + index: MemberIndex::new(i).unwrap(), + scalar: crate::poly::evaluate(&coefficients, &Scalar::from_u64(u64::from(i))), + }) + .collect(); + let public = PublicKeySet { + context, + mpk: MasterPublicKey(G2::generator().mul(&coefficients[0])), + public_shares, + }; + ( + public, + shares, + Scalar::from_bytes(&coefficients[0].to_bytes()).unwrap(), + ) +} + +pub(crate) fn context() -> KeyContext { + KeyContext::test( + NetworkUrn::new("tinycloud:test:network").unwrap(), + RekeyCounter::new(0), + Epoch::new(0), + CertDigest::new([7; 32]), + Threshold::new(2, 3).unwrap(), + ) +} +pub(crate) fn identity(ctx: &KeyContext, path: &str) -> IbeIdentity { + IbeIdentity::record( + ctx.network(), + &KeyPath::new(path).unwrap(), + KeyVersion::new(4), + &WriteNonce::new([9; 16]), + ) + .unwrap() +} +pub(crate) fn authorize(req: DecryptRequest) -> AuthorizedDerivation { + let id = req.identity().clone(); + AuthorizedDerivation::from_capability_check(req, id.network(), id.key_path(), id.key_version()) + .unwrap() +} +fn setup( + seed: u8, +) -> ( + PublicKeySet, + Vec, + TransportSecretKey, + DecryptRequest, + Vec, +) { + let (public, shares, _) = dealer(seed, context()); + let mut rng = ChaCha20Rng::from_seed([seed.wrapping_add(1); 32]); + let tsk = TransportSecretKey::generate(&mut rng); + let req = DecryptRequest::new( + public.context(), + identity(public.context(), "records/key"), + tsk.public_key(), + ) + .unwrap(); + let auth = authorize(req.clone()); + let issued = shares + .iter() + .map(|s| issue_share(s, &auth, &mut rng).unwrap()) + .collect(); + (public, shares, tsk, req, issued) +} + +#[test] +fn checkpoint_2of3() { + let (public, shares, tsk, req, issued) = setup(11); + assert_eq!(public.context().threshold(), Threshold::new(2, 3).unwrap()); + assert_eq!(public.context().epoch(), Epoch::new(0)); + assert_eq!(public.context().rekey(), RekeyCounter::new(0)); + println!( + "1. PASS: 2-of-3 test-dealer key; network, rekey=0, epoch=0 and certificate context bound" + ); + let data = DataKey::new([0x42; 32]); + let ct = wrap_key( + public.master_public_key(), + req.identity(), + &data, + &mut ChaCha20Rng::from_seed([12; 32]), + ) + .unwrap(); + assert_eq!(ct.encode().len(), 160); + println!("2. PASS: 32-byte data key wrapped to record identity under mpk"); + assert_eq!(issued.len(), 3); + assert!(shares + .iter() + .zip(&issued) + .all(|(s, i)| s.index() == i.index())); + println!( + "3. PASS: capability-authorized derivation; all three members issued encrypted shares" + ); + for share in &issued { + verify_share(&public, &req, share).unwrap(); + } + println!("4. PASS: reader verified each encrypted share"); + let mut keys = Vec::new(); + for (a, b) in [(0, 1), (0, 2), (1, 2)] { + let encrypted = combine(&public, &req, &[issued[a].clone(), issued[b].clone()]).unwrap(); + let key = decrypt_derived_key(&encrypted, &tsk, &public, &req).unwrap(); + assert_eq!( + unwrap_key(&key, req.identity(), &ct).unwrap().as_bytes(), + data.as_bytes() + ); + keys.push(key.key.encode()); + } + assert!(keys.windows(2).all(|p| p[0] == p[1])); + println!("5. PASS: pairs {{1,2}}, {{1,3}}, {{2,3}} derived the same key and unwrapped the original data key"); + let mut bad = issued[1].clone(); + bad.c3 = bad.c3.add(&G1::generator()); + assert_eq!( + verify_share(&public, &req, &bad), + Err(ShareError::Invalid { + member: bad.index() + }) + ); + println!("6. PASS: mutated c3 rejected as Invalid {{ member: 2 }}"); + assert!(matches!( + combine(&public, &req, &issued[..1]), + Err(CombineError::NotEnoughShares) + )); + println!("7. PASS: one valid share returned NotEnoughShares"); + for field in 0..4 { + let mut changed = public.context().clone(); + match field { + 0 => changed.certificate = CertDigest::new([8; 32]), + 1 => changed.network = NetworkUrn::new("tinycloud:other:network").unwrap(), + 2 => changed.epoch = Epoch::new(1), + _ => changed.rekey = RekeyCounter::new(1), + } + let other_id = identity(&changed, "records/key"); + let other_req = DecryptRequest::new(&changed, other_id, tsk.public_key()).unwrap(); + assert_eq!( + verify_share(&public, &other_req, &issued[0]), + Err(ShareError::ContextMismatch) + ); + assert!(matches!( + issue_share( + &shares[0], + &authorize(other_req.clone()), + &mut ChaCha20Rng::from_seed([0; 32]) + ), + Err(Error::ContextMismatch) + )); + let (other_public, _, _) = dealer(11, changed); + assert_eq!( + verify_share(&other_public, &other_req, &issued[0]), + Err(ShareError::ContextMismatch) + ); + assert!(matches!( + combine(&other_public, &other_req, &issued), + Err(CombineError::MixedContexts) + )); + } + println!("8. PASS: certificate, network, epoch and rekey transplants returned ContextMismatch before algebra"); + let other_req = DecryptRequest::new( + public.context(), + identity(public.context(), "records/other"), + tsk.public_key(), + ) + .unwrap(); + let auth = authorize(other_req.clone()); + let mut rng = ChaCha20Rng::from_seed([15; 32]); + let other_shares: Vec<_> = shares + .iter() + .map(|s| issue_share(s, &auth, &mut rng).unwrap()) + .collect(); + let ek = combine(&public, &other_req, &other_shares).unwrap(); + let other_key = decrypt_derived_key(&ek, &tsk, &public, &other_req).unwrap(); + assert!(matches!( + unwrap_key(&other_key, req.identity(), &ct), + Err(Error::WrongIdentity) + )); + println!("9. PASS: key for another identity returned WrongIdentity"); +} + +#[test] +fn request_transplants_fail_before_algebra() { + let (public, shares, tsk, req, issued) = setup(21); + let mut mutations = Vec::new(); + let mut r = req.clone(); + r.identity = identity(public.context(), "other/path"); + mutations.push(r); + let mut r = req.clone(); + r.tpk = TransportSecretKey::generate(&mut ChaCha20Rng::from_seed([99; 32])).public_key(); + mutations.push(r); + let mut r = req.clone(); + r.context_digest[0] ^= 1; + mutations.push(r); + let mut r = req.clone(); + r.certificate = CertDigest::new([1; 32]); + mutations.push(r); + let mut r = req.clone(); + r.network = NetworkUrn::new("other:network").unwrap(); + mutations.push(r); + let mut r = req.clone(); + r.epoch = Epoch::new(1); + mutations.push(r); + let mut r = req.clone(); + r.rekey = RekeyCounter::new(1); + mutations.push(r); + for r in mutations { + assert_eq!( + verify_share(&public, &r, &issued[0]), + Err(ShareError::ContextMismatch) + ); + assert!(matches!( + combine(&public, &r, &issued), + Err(CombineError::ContextMismatch | CombineError::MixedContexts) + )); + if r.identity == *req.identity() { + if let Ok(auth) = AuthorizedDerivation::from_capability_check( + r, + req.identity().network(), + req.identity().key_path(), + req.identity().key_version(), + ) { + if auth.request.context_digest != req.context_digest + || auth.request.certificate != req.certificate + || auth.request.epoch != req.epoch + || auth.request.rekey != req.rekey + || auth.request.network != req.network + { + assert!(matches!( + issue_share(&shares[0], &auth, &mut ChaCha20Rng::from_seed([1; 32])), + Err(Error::ContextMismatch) + )); + } + } + } + } + let mut mixed = issued.clone(); + mixed[2].context_digest = [0; 32]; + assert!(matches!( + combine(&public, &req, &mixed), + Err(CombineError::MixedContexts) + )); + let ek = combine(&public, &req, &issued).unwrap(); + let wrong_tsk = TransportSecretKey::generate(&mut ChaCha20Rng::from_seed([88; 32])); + assert!(matches!( + decrypt_derived_key(&ek, &wrong_tsk, &public, &req), + Err(Error::ContextMismatch) + )); + assert!(decrypt_derived_key(&ek, &tsk, &public, &req).is_ok()); +} + +#[test] +fn both_share_equations_and_deduplication() { + let (public, _, _, req, issued) = setup(31); + for field in 0..3 { + let mut bad = issued[0].clone(); + match field { + 0 => bad.c1 = bad.c1.add(&G1::generator()), + 1 => bad.c2 = bad.c2.add(&G2::generator()), + _ => bad.c3 = bad.c3.add(&G1::generator()), + } + assert_eq!( + verify_share(&public, &req, &bad), + Err(ShareError::Invalid { + member: bad.index() + }) + ); + assert!(matches!( + combine(&public, &req, &[issued[1].clone(), issued[2].clone(), bad]), + Err(CombineError::Invalid { .. }) + )); + } + assert!(matches!( + combine(&public, &req, &[issued[0].clone(), issued[0].clone()]), + Err(CombineError::NotEnoughShares) + )); + let a = combine(&public, &req, &issued).unwrap(); + let b = combine( + &public, + &req, + &[issued[1].clone(), issued[0].clone(), issued[0].clone()], + ) + .unwrap(); + assert_eq!(a.c1, b.c1); + assert_eq!(a.c2, b.c2); + assert_eq!(a.c3, b.c3); + let mut outside = issued[0].clone(); + outside.member = MemberIndex::new(4).unwrap(); + assert_eq!( + verify_share(&public, &req, &outside), + Err(ShareError::ContextMismatch) + ); + assert_ne!( + issued[0].signing_preimage(&req), + issued[1].signing_preimage(&req) + ); +} + +#[test] +fn fullident_integrity_degenerate_randomness_and_redacted_secrets() { + let (public, shares, tsk, req, issued) = setup(41); + let ek = combine(&public, &req, &issued).unwrap(); + let key = decrypt_derived_key(&ek, &tsk, &public, &req).unwrap(); + let data = DataKey::new([0x5a; 32]); + let ct = wrap_key( + public.master_public_key(), + req.identity(), + &data, + &mut ChaCha20Rng::from_seed([42; 32]), + ) + .unwrap(); + for field in 0..3 { + let mut bad = ct.clone(); + match field { + 0 => bad.u = bad.u.add(&G2::generator()), + 1 => bad.v[0] ^= 1, + _ => bad.w[0] ^= 1, + } + assert!(matches!( + unwrap_key(&key, req.identity(), &bad), + Err(Error::InvalidCiphertext) + )); + } + assert!(matches!( + crate::ibe::wrap_with_rho( + public.master_public_key(), + req.identity(), + &data, + zeroize::Zeroizing::new([0; 32]), + Scalar::from_u64(0) + ), + Err(Error::DegenerateRandomness) + )); + assert_eq!(format!("{data:?}"), "DataKey([REDACTED])"); + assert_eq!(format!("{tsk:?}"), "TransportSecretKey([REDACTED])"); + assert_eq!(format!("{key:?}"), "DerivedKey([REDACTED])"); + assert_eq!(format!("{:?}", shares[0]), "KeyShare([REDACTED])"); +} + +#[test] +fn decoded_structures_enforce_roles_lengths_and_network() { + let (public, _, tsk, req, issued) = setup(51); + assert_eq!( + DecryptRequest::decode(&req.encode()).unwrap().encode(), + req.encode() + ); + let encoded = issued[0].encode(); + assert_eq!( + EncryptedKeyShare::decode(&encoded).unwrap().encode(), + encoded + ); + let ct = wrap_key( + public.master_public_key(), + req.identity(), + &DataKey::new([1; 32]), + &mut ChaCha20Rng::from_seed([52; 32]), + ) + .unwrap(); + for bytes in [&[][..], &ct.encode()[..159], &[0; 161][..]] { + assert!(IbeCiphertext::decode(bytes).is_err()); + } + for bytes in [req.encode(), encoded.clone()] { + for cut in 0..bytes.len() { + if bytes.len() == encoded.len() { + assert!(EncryptedKeyShare::decode(&bytes[..cut]).is_err()); + } else { + assert!(DecryptRequest::decode(&bytes[..cut]).is_err()); + } + } + } + let mut r = req.encode(); + r.push(0); + assert!(DecryptRequest::decode(&r).is_err()); + let mut s = encoded.clone(); + s.push(0); + assert!(EncryptedKeyShare::decode(&s).is_err()); + for index in [0u16, 17] { + let mut s = encoded.clone(); + s[1..3].copy_from_slice(&index.to_be_bytes()); + assert!(EncryptedKeyShare::decode(&s).is_err()); + } + let mut s = issued[0].clone(); + s.c3 = G1::identity(); + assert_eq!( + EncryptedKeyShare::decode(&s.encode()).unwrap().c3, + G1::identity() + ); + for field in 0..2 { + let mut s = issued[0].clone(); + if field == 0 { + s.c1 = G1::identity(); + } else { + s.c2 = G2::identity(); + } + assert!(EncryptedKeyShare::decode(&s.encode()).is_err()); + } + let mut r = req.clone(); + r.network = NetworkUrn::new("other:network").unwrap(); + assert!(matches!( + DecryptRequest::decode(&r.encode()), + Err(Error::ContextMismatch) + )); + assert!(matches!( + DecryptRequest::new( + public.context(), + identity( + &KeyContext::test( + NetworkUrn::new("other:network").unwrap(), + RekeyCounter::new(0), + Epoch::new(0), + CertDigest::new([7; 32]), + Threshold::new(2, 3).unwrap() + ), + "key" + ), + tsk.public_key() + ), + Err(Error::ContextMismatch) + )); + assert!(TransportPublicKey::decode(&G1::identity().encode()).is_err()); + assert!(MasterPublicKey::decode(&G2::identity().encode()).is_err()); + let mut r = req.encode(); + r[33..37].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(matches!( + DecryptRequest::decode(&r), + Err(Error::LimitExceeded) + )); +} + +#[test] +fn all_threshold_subsets_and_zero_aggregate_masks() { + for t in 2..=4u16 { + let ctx = KeyContext::test( + NetworkUrn::new("subsets").unwrap(), + RekeyCounter::new(0), + Epoch::new(0), + CertDigest::new([3; 32]), + Threshold::new(t, t + 2).unwrap(), + ); + let (public, shares, master) = dealer(81, ctx); + let mut rng = ChaCha20Rng::from_seed([82; 32]); + let tsk = TransportSecretKey::generate(&mut rng); + let req = DecryptRequest::new( + public.context(), + identity(public.context(), "key"), + tsk.public_key(), + ) + .unwrap(); + let auth = authorize(req.clone()); + let issued: Vec<_> = shares + .iter() + .map(|s| issue_share(s, &auth, &mut rng).unwrap()) + .collect(); + for mask in 0u32..(1u32 << issued.len()) { + if mask.count_ones() != u32::from(t) { + continue; + } + let selected: Vec<_> = issued + .iter() + .enumerate() + .filter(|(i, _)| mask & (1 << i) != 0) + .map(|(_, s)| s.clone()) + .collect(); + let aggregate = combine(&public, &req, &selected).unwrap(); + let key = decrypt_derived_key(&aggregate, &tsk, &public, &req).unwrap(); + assert_eq!( + key.key.encode(), + hash_g1(req.identity().as_bytes(), dst::H1) + .mul(&master) + .encode() + ); + } + } + let (public, shares, tsk, req, mut issued) = setup(83); + let h = hash_g1(req.identity().as_bytes(), dst::H1); + // For indices 1,2 the weights are 2,-1: r1=1,r2=2 cancel both masks. + for (i, share) in issued.iter_mut().take(2).enumerate() { + let r = Scalar::from_u64((i + 1) as u64); + share.c1 = G1::generator().mul(&r); + share.c2 = G2::generator().mul(&r); + share.c3 = h.mul(&shares[i].scalar).add(&req.tpk.0.mul(&r)); + } + let aggregate = combine(&public, &req, &issued[..2]).unwrap(); + assert!(aggregate.c1.is_identity()); + assert!(aggregate.c2.is_identity()); + let key = decrypt_derived_key(&aggregate, &tsk, &public, &req).unwrap(); + let data = DataKey::new([0x66; 32]); + let ct = wrap_key( + public.master_public_key(), + req.identity(), + &data, + &mut ChaCha20Rng::from_seed([84; 32]), + ) + .unwrap(); + assert_eq!( + unwrap_key(&key, req.identity(), &ct).unwrap().as_bytes(), + data.as_bytes() + ); +} + +proptest! { + #[test] + fn threshold_roundtrip_property(seed in any::(), plaintext in any::<[u8;32]>(), nonce in any::<[u8;16]>()) { + let (public, shares, master) = dealer(seed, context()); + let mut rng = ChaCha20Rng::from_seed([seed.wrapping_add(2);32]); + let tsk = TransportSecretKey::generate(&mut rng); + let id = IbeIdentity::record(public.context().network(), &KeyPath::new("a/b").unwrap(), KeyVersion::new(1), &WriteNonce::new(nonce)).unwrap(); + let req = DecryptRequest::new(public.context(), id, tsk.public_key()).unwrap(); + let auth = authorize(req.clone()); + let issued: Vec<_> = shares.iter().map(|s| issue_share(s, &auth, &mut rng).unwrap()).collect(); + let ct = wrap_key(public.master_public_key(), req.identity(), &DataKey::new(plaintext), &mut rng).unwrap(); + for (a,b) in [(0,1),(0,2),(1,2)] { + let ek = combine(&public,&req,&[issued[a].clone(),issued[b].clone()]).unwrap(); + let dk = decrypt_derived_key(&ek,&tsk,&public,&req).unwrap(); + prop_assert_eq!(dk.key.encode(), hash_g1(req.identity().as_bytes(),dst::H1).mul(&master).encode()); + let unwrapped = unwrap_key(&dk,req.identity(),&ct).unwrap(); + prop_assert_eq!(unwrapped.as_bytes(), &plaintext); + } + } + #[test] + fn derivation_decoders_reject_or_canonical(bytes in proptest::collection::vec(any::(),0..1200)) { + if let Ok(v) = DecryptRequest::decode(&bytes) { prop_assert_eq!(v.encode(), bytes.clone()); } + if let Ok(v) = EncryptedKeyShare::decode(&bytes) { prop_assert_eq!(v.encode(), bytes.clone()); } + if let Ok(v) = IbeCiphertext::decode(&bytes) { let encoded = v.encode(); prop_assert_eq!(encoded.as_slice(), bytes.as_slice()); } + } + #[test] + fn structured_derivation_mutations_reject_or_canonical(offset in 0usize..1024, mask in 1u8..=255) { + let (public, _, _, req, issued) = setup(61); + let ct = wrap_key(public.master_public_key(),req.identity(),&DataKey::new([3;32]),&mut ChaCha20Rng::from_seed([62;32])).unwrap(); + let mut r=req.encode(); let n=r.len(); r[offset % n]^=mask; + if let Ok(v)=DecryptRequest::decode(&r) { prop_assert_eq!(v.encode(),r); } + let mut s=issued[0].encode(); let n=s.len(); s[offset % n]^=mask; + if let Ok(v)=EncryptedKeyShare::decode(&s) { prop_assert_eq!(v.encode(),s); } + let mut c=ct.encode(); let n=c.len(); c[offset % n]^=mask; + if let Ok(v)=IbeCiphertext::decode(&c) { prop_assert_eq!(v.encode(),c); } + } +} diff --git a/tinycloud-threshold/src/context.rs b/tinycloud-threshold/src/context.rs new file mode 100644 index 00000000..c92d6828 --- /dev/null +++ b/tinycloud-threshold/src/context.rs @@ -0,0 +1,238 @@ +//! Key-context commitments and the node's capability-check boundary. + +use crate::derive::DecryptRequest; +use crate::dst::{tagged_hash, KEY_CONTEXT}; +use crate::encoding::Encoder; +use crate::error::Error; +use crate::identity::IbeIdentity; +use crate::ids::{CertDigest, Epoch, KeyPath, KeyVersion, NetworkUrn, RekeyCounter, Threshold}; + +/// A key context established by the crate's certificate path. +/// +/// There is no public production constructor. Certificate verification supplies +/// these fields; the test-only dealer uses a constructor absent from production. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KeyContext { + pub(crate) network: NetworkUrn, + pub(crate) rekey: RekeyCounter, + pub(crate) epoch: Epoch, + pub(crate) certificate: CertDigest, + pub(crate) threshold: Threshold, +} + +impl KeyContext { + /// Hash the frozen version-one context body under its dedicated tag. + pub fn digest(&self) -> [u8; 32] { + tagged_hash(KEY_CONTEXT, &self.encode()) + } + + /// Encode version, network, generation, certificate, and threshold in order. + pub fn encode(&self) -> Vec { + let mut encoder = Encoder::new(); + encoder.u8(1); + encoder.lp(self.network.as_bytes()); + encoder.u64(self.rekey.get()); + encoder.u64(self.epoch.get()); + encoder.fixed(self.certificate.as_bytes()); + encoder.u16(self.threshold.t()); + encoder.u16(self.threshold.n()); + encoder.finish() + } + + /// Borrow the context's network. + pub fn network(&self) -> &NetworkUrn { + &self.network + } + + /// Return the counter incremented when the master key changes. + pub fn rekey(&self) -> RekeyCounter { + self.rekey + } + + /// Return the monotonically increasing epoch. + pub fn epoch(&self) -> Epoch { + self.epoch + } + + /// Borrow the certificate digest bound to this context. + pub fn certificate(&self) -> &CertDigest { + &self.certificate + } + + /// Return the threshold and cohort size. + pub fn threshold(&self) -> Threshold { + self.threshold + } + + #[cfg(test)] + pub(crate) fn test( + network: NetworkUrn, + rekey: RekeyCounter, + epoch: Epoch, + certificate: CertDigest, + threshold: Threshold, + ) -> Self { + Self { + network, + rekey, + epoch, + certificate, + threshold, + } + } +} + +/// A request paired with the identity tuple authorized by the node. +/// +/// This is a node assertion, not a certificate or an independent authorization +/// check. Its only public constructor checks the supplied authorized tuple +/// against the request's complete, rebuilt identity bytes. +pub struct AuthorizedDerivation { + pub(crate) request: DecryptRequest, +} + +impl AuthorizedDerivation { + /// Construct only after the node's capability check authorized exactly + /// `(authorized_urn, authorized_path, version)` for this caller. + /// + /// The request's record/session mode and record nonce are preserved when + /// rebuilding the identity. Any mismatch returns `UnauthorizedIdentity`. + pub fn from_capability_check( + request: DecryptRequest, + authorized_urn: &NetworkUrn, + authorized_path: &KeyPath, + version: KeyVersion, + ) -> Result { + let rebuilt = match request.identity.nonce() { + Some(nonce) => IbeIdentity::record(authorized_urn, authorized_path, version, nonce)?, + None => IbeIdentity::session(authorized_urn, authorized_path, version)?, + }; + if rebuilt.as_bytes() != request.identity.as_bytes() { + return Err(Error::UnauthorizedIdentity); + } + Ok(Self { request }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive::{DecryptRequest, TransportSecretKey}; + use crate::identity::IbeIdentity; + use crate::ids::WriteNonce; + use rand_chacha::{rand_core::SeedableRng, ChaCha20Rng}; + use sha2::{Digest, Sha256}; + + fn context() -> KeyContext { + KeyContext::test( + NetworkUrn::new("net").unwrap(), + RekeyCounter::new(4), + Epoch::new(9), + CertDigest::new([3; 32]), + Threshold::new(2, 3).unwrap(), + ) + } + + fn request(record: bool) -> DecryptRequest { + let ctx = context(); + let path = KeyPath::new("path").unwrap(); + let id = if record { + IbeIdentity::record( + ctx.network(), + &path, + KeyVersion::new(7), + &WriteNonce::new([9; 16]), + ) + .unwrap() + } else { + IbeIdentity::session(ctx.network(), &path, KeyVersion::new(7)).unwrap() + }; + let mut rng = ChaCha20Rng::from_seed([11; 32]); + DecryptRequest::new( + &ctx, + id, + TransportSecretKey::generate(&mut rng).public_key(), + ) + .unwrap() + } + + #[test] + fn context_frozen_body_digest_and_getters() { + let ctx = context(); + let mut expected = vec![1, 0, 0, 0, 3, b'n', b'e', b't']; + expected.extend_from_slice(&4u64.to_be_bytes()); + expected.extend_from_slice(&9u64.to_be_bytes()); + expected.extend_from_slice(&[3; 32]); + expected.extend_from_slice(&[0, 2, 0, 3]); + assert_eq!(ctx.encode(), expected); + let tag = b"TC-THRESHOLD-V1-KEY-CONTEXT"; + let mut hash = Sha256::new(); + hash.update((tag.len() as u32).to_be_bytes()); + hash.update(tag); + hash.update(&expected); + assert_eq!(ctx.digest(), <[u8; 32]>::from(hash.finalize())); + assert_eq!(ctx.network().as_str(), "net"); + assert_eq!(ctx.rekey(), RekeyCounter::new(4)); + assert_eq!(ctx.epoch(), Epoch::new(9)); + assert_eq!(ctx.certificate(), &CertDigest::new([3; 32])); + assert_eq!(ctx.threshold(), Threshold::new(2, 3).unwrap()); + } + + #[test] + fn context_digest_binds_every_field() { + let ctx = context(); + let mut mutations = Vec::new(); + let mut changed = ctx.clone(); + changed.network = NetworkUrn::new("other").unwrap(); + mutations.push(changed); + let mut changed = ctx.clone(); + changed.rekey = RekeyCounter::new(5); + mutations.push(changed); + let mut changed = ctx.clone(); + changed.epoch = Epoch::new(10); + mutations.push(changed); + let mut changed = ctx.clone(); + changed.certificate = CertDigest::new([4; 32]); + mutations.push(changed); + let mut changed = ctx.clone(); + changed.threshold = Threshold::new(3, 3).unwrap(); + mutations.push(changed); + let mut changed = ctx.clone(); + changed.threshold = Threshold::new(2, 4).unwrap(); + mutations.push(changed); + for changed in mutations { + assert_ne!(ctx.digest(), changed.digest()); + } + } + + #[test] + fn capability_check_binds_exact_authorized_tuple_preserving_mode_and_nonce() { + for record in [false, true] { + let req = request(record); + let expected_identity = req.identity().clone(); + let auth = AuthorizedDerivation::from_capability_check( + req, + &NetworkUrn::new("net").unwrap(), + &KeyPath::new("path").unwrap(), + KeyVersion::new(7), + ) + .unwrap(); + assert_eq!(auth.request.identity(), &expected_identity); + for (network, path, version) in [ + ("other", "path", 7), + ("net", "other", 7), + ("net", "path", 8), + ] { + assert!(matches!( + AuthorizedDerivation::from_capability_check( + request(record), + &NetworkUrn::new(network).unwrap(), + &KeyPath::new(path).unwrap(), + KeyVersion::new(version) + ), + Err(Error::UnauthorizedIdentity) + )); + } + } + } +} diff --git a/tinycloud-threshold/src/curve.rs b/tinycloud-threshold/src/curve.rs new file mode 100644 index 00000000..0c35e726 --- /dev/null +++ b/tinycloud-threshold/src/curve.rs @@ -0,0 +1,815 @@ +//! BLS12-381 boundary. Sources: RFC 9380 §§5,8.8,J; pairing-friendly-curves +//! draft 11 §§2.5,4.2.1; blst 0.3.16 API documentation and README only. +//! +//! FFI inventory and invariants (also covering the inline fixture tests): +//! - blst_scalar_from_bendian / blst_bendian_from_scalar: exactly 32 bytes. +//! - blst_scalar_fr_check: canonical scalar, including zero; not sk_check. +//! - blst_scalar_from_be_bytes: BE modular reduction, slice length passed exactly. +//! - blst_scalar_from_uint64: four initialized native u64 limbs. +//! - blst_fr_from_scalar / blst_scalar_from_fr: canonical scalar ↔ Montgomery Fr. +//! - blst_fr_add / blst_fr_sub / blst_fr_mul: initialized Fr operands/results. +//! - blst_fr_inverse: constant-time inversion path, NEVER fr_eucl_inverse; +//! zero is refused before inversion. All Fr temporaries have wiping guards. +//! - blst_p1_generator / blst_p2_generator: read-only static, public generator. +//! - blst_p1_uncompress / blst_p2_uncompress: exactly 48/96 input bytes; output +//! is initialized and used only after SUCCESS. +//! - blst_p1_affine_on_curve / blst_p2_affine_on_curve and +//! blst_p1_affine_in_g1 / blst_p2_affine_in_g2: explicit curve AND subgroup +//! validation, separate from decompression (also exercised on RFC Q0 points). +//! - blst_p1_from_affine / blst_p2_from_affine: validated affine inputs only. +//! - blst_p1_compress / blst_p2_compress: exactly 48/96 writable output bytes. +//! - blst_p1_is_inf / blst_p2_is_inf: initialized group points; role controls +//! whether a decoded infinity is accepted. +//! - blst_p1_is_equal / blst_p2_is_equal: initialized subgroup points. +//! - blst_p1_add_or_double / blst_p2_add_or_double: handles equal/zero operands. +//! - blst_p1_cneg / blst_p2_cneg: initialized, exclusively owned point. +//! - blst_p1_mult / blst_p2_mult: canonical scalar's 32-byte LE representation, +//! fixed 255 bits, checked subgroup point, never unchecked or Pippenger APIs. +//! - blst_hash_to_g1 / blst_hash_to_g2: valid message/DST pointer-length pairs, +//! empty augmentation; RFC 9380 random-oracle suites with cofactor clearing. +//! - blst_p1_to_affine / blst_p2_to_affine: initialized subgroup inputs; +//! secret-affine outputs are guarded and wiped. +//! - blst_miller_loop / blst_miller_loop_n: initialized checked affine points; +//! n pointer entries backed by stable arrays, no aliased mutable arguments. +//! - blst_fp12_one: read-only static public multiplicative identity. +//! - blst_fp12_mul: initialized Fp12 inputs, distinct initialized output. +//! - blst_final_exp: every exposed pairing is reduced, never a Miller output. +//! - blst_fp12_is_one: reduced initialized product. +//! - blst_bendian_from_fp: exactly 48 writable bytes per tower coefficient. +//! +//! All outputs are initialized before C writes and distinct from inputs. +//! Raw Copy field/point/Fp12 types never escape; owned wrappers wipe every limb +//! on drop. Explicit Clone of points creates another independently wiped owner. +//! Wiping is an in-memory lifecycle measure, not a durable-erasure guarantee. + +use blst::*; +use rand_core::CryptoRngCore; +use std::fmt; +use subtle::ConstantTimeEq; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +use crate::Error; + +pub(crate) struct Scalar(blst_scalar); + +impl fmt::Debug for Scalar { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Scalar([REDACTED])") + } +} + +impl Zeroize for Scalar { + fn zeroize(&mut self) { + self.0.zeroize(); + } +} +impl Drop for Scalar { + fn drop(&mut self) { + self.zeroize(); + } +} +impl ZeroizeOnDrop for Scalar {} + +#[derive(Default)] +struct SecretFr(blst_fr); +impl Drop for SecretFr { + fn drop(&mut self) { + self.0.l.zeroize(); + } +} + +macro_rules! scalar_binary { + ($name:ident, $op:ident) => { + pub(crate) fn $name(&self, rhs: &Self) -> Self { + let mut a = SecretFr::default(); + let mut b = SecretFr::default(); + let mut c = SecretFr::default(); + let mut out = Self(blst_scalar::default()); + // SAFETY: canonical scalars; distinct, initialized Fr/output buffers. + unsafe { + blst_fr_from_scalar(&mut a.0, &self.0); + blst_fr_from_scalar(&mut b.0, &rhs.0); + $op(&mut c.0, &a.0, &b.0); + blst_scalar_from_fr(&mut out.0, &c.0); + } + out + } + }; +} + +impl Scalar { + #[cfg(test)] + pub(crate) fn from_bytes(bytes: &[u8]) -> Result { + let bytes: &[u8; 32] = bytes.try_into().map_err(|_| Error::InvalidLength)?; + let mut out = Self(blst_scalar::default()); + // SAFETY: exactly 32 readable bytes and initialized, distinct output. + unsafe { + blst_scalar_from_bendian(&mut out.0, bytes.as_ptr()); + if !blst_scalar_fr_check(&out.0) { + return Err(Error::InvalidScalar); + } + } + Ok(out) + } + + /// The returned secret bytes must be guarded by their caller. + #[cfg(test)] + pub(crate) fn to_bytes(&self) -> [u8; 32] { + let mut out = [0; 32]; + // SAFETY: exactly 32 writable bytes; canonical initialized scalar. + unsafe { + blst_bendian_from_scalar(out.as_mut_ptr(), &self.0); + } + out + } + + /// Big-endian reduction; sampling uses 64 bytes and H3 uses 48 bytes. + pub(crate) fn reduce(bytes: &[u8]) -> Self { + let mut out = Self(blst_scalar::default()); + if bytes.is_empty() { + return out; + } + // SAFETY: readable slice of the stated length, initialized output. + // The boolean reports nonzero; zero is a valid field element here. + unsafe { + blst_scalar_from_be_bytes(&mut out.0, bytes.as_ptr(), bytes.len()); + } + out + } + + pub(crate) fn from_u64(value: u64) -> Self { + let limbs = Zeroizing::new([value, 0, 0, 0]); + let mut out = Self(blst_scalar::default()); + // SAFETY: four readable u64 limbs; every u64 is less than r. + unsafe { + blst_scalar_from_uint64(&mut out.0, limbs.as_ptr()); + } + out + } + + pub(crate) fn random(rng: &mut impl CryptoRngCore, nonzero: bool) -> Self { + let mut bytes = Zeroizing::new([0; 64]); + loop { + rng.fill_bytes(bytes.as_mut()); + let scalar = Self::reduce(bytes.as_ref()); + if !nonzero || !scalar.is_zero() { + return scalar; + } + } + } + + pub(crate) fn is_zero(&self) -> bool { + bool::from(self.0.b.ct_eq(&[0; 32])) + } + + #[cfg(test)] + scalar_binary!(add, blst_fr_add); + scalar_binary!(sub, blst_fr_sub); + scalar_binary!(mul, blst_fr_mul); + + pub(crate) fn inverse(&self) -> Result { + if self.is_zero() { + return Err(Error::InvalidScalar); + } + let mut a = SecretFr::default(); + let mut inverse = SecretFr::default(); + let mut out = Self(blst_scalar::default()); + // SAFETY: nonzero canonical scalar; constant-time (not Euclidean) + // inverse entry point, initialized and distinct buffers. + unsafe { + blst_fr_from_scalar(&mut a.0, &self.0); + blst_fr_inverse(&mut inverse.0, &a.0); + blst_scalar_from_fr(&mut out.0, &inverse.0); + } + Ok(out) + } +} + +fn wipe_fp2(value: &mut blst_fp2) { + for coefficient in &mut value.fp { + coefficient.l.zeroize(); + } +} +fn wipe_p1(value: &mut blst_p1) { + value.x.l.zeroize(); + value.y.l.zeroize(); + value.z.l.zeroize(); +} +fn wipe_p2(value: &mut blst_p2) { + wipe_fp2(&mut value.x); + wipe_fp2(&mut value.y); + wipe_fp2(&mut value.z); +} + +#[derive(Default)] +struct Affine1(blst_p1_affine); +impl Drop for Affine1 { + fn drop(&mut self) { + self.0.x.l.zeroize(); + self.0.y.l.zeroize(); + } +} +#[derive(Default)] +struct Affine2(blst_p2_affine); +impl Drop for Affine2 { + fn drop(&mut self) { + wipe_fp2(&mut self.0.x); + wipe_fp2(&mut self.0.y); + } +} + +macro_rules! point { + ($name:ident, $raw:ident, $affine:ident, $size:literal, + $generator:ident, $uncompress:ident, $on_curve:ident, $in_group:ident, + $from_affine:ident, $compress:ident, $inf:ident, $equal:ident, + $add:ident, $negate:ident, $mult:ident, $wipe:ident) => { + #[derive(Clone)] + pub(crate) struct $name($raw); + impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(concat!(stringify!($name), "([REDACTED])")) + } + } + impl Zeroize for $name { + fn zeroize(&mut self) { + $wipe(&mut self.0); + } + } + impl Drop for $name { + fn drop(&mut self) { + self.zeroize(); + } + } + impl ZeroizeOnDrop for $name {} + impl PartialEq for $name { + fn eq(&self, other: &Self) -> bool { + // SAFETY: both points are initialized subgroup elements. + unsafe { $equal(&self.0, &other.0) } + } + } + impl Eq for $name {} + impl $name { + pub(crate) fn identity() -> Self { + Self($raw::default()) + } + pub(crate) fn generator() -> Self { + // SAFETY: library's static, read-only public generator. + Self(unsafe { *$generator() }) + } + pub(crate) fn decode(bytes: &[u8], allow_identity: bool) -> Result { + let bytes: &[u8; $size] = bytes.try_into().map_err(|_| Error::InvalidLength)?; + let mut affine = $affine::default(); + let mut out = Self::identity(); + // SAFETY: exact input size, initialized disjoint outputs. + // Decompression is NOT relied upon for subgroup membership. + unsafe { + if $uncompress(&mut affine.0, bytes.as_ptr()) != BLST_ERROR::BLST_SUCCESS { + return Err(Error::InvalidPoint); + } + if !$on_curve(&affine.0) { + return Err(Error::InvalidPoint); + } + if !$in_group(&affine.0) { + return Err(Error::InvalidPoint); + } + $from_affine(&mut out.0, &affine.0); + } + if out.encode().as_slice() != bytes.as_slice() { + return Err(Error::InvalidPoint); + } + if !allow_identity && out.is_identity() { + return Err(Error::IdentityPoint); + } + Ok(out) + } + /// Callers must guard encodings when the point is secret. + pub(crate) fn encode(&self) -> [u8; $size] { + let mut out = [0; $size]; + // SAFETY: exact writable output size and initialized point. + unsafe { + $compress(out.as_mut_ptr(), &self.0); + } + out + } + pub(crate) fn is_identity(&self) -> bool { + // SAFETY: initialized subgroup point. + unsafe { $inf(&self.0) } + } + pub(crate) fn add(&self, rhs: &Self) -> Self { + let mut out = Self::identity(); + // SAFETY: disjoint initialized subgroup operands/output. + unsafe { + $add(&mut out.0, &self.0, &rhs.0); + } + out + } + // The complete group interface is exercised by S0.1 primitive tests. + #[allow(dead_code)] + pub(crate) fn sub(&self, rhs: &Self) -> Self { + let mut negative = rhs.clone(); + // SAFETY: initialized, exclusively owned subgroup point. + unsafe { + $negate(&mut negative.0, true); + } + self.add(&negative) + } + pub(crate) fn mul(&self, scalar: &Scalar) -> Self { + let mut out = Self::identity(); + // SAFETY: canonical scalar below r has at most 255 bits; + // 32 LE bytes readable, fixed-width checked point path. + unsafe { + $mult(&mut out.0, &self.0, scalar.0.b.as_ptr(), 255); + } + out + } + } + }; +} + +point!( + G1, + blst_p1, + Affine1, + 48, + blst_p1_generator, + blst_p1_uncompress, + blst_p1_affine_on_curve, + blst_p1_affine_in_g1, + blst_p1_from_affine, + blst_p1_compress, + blst_p1_is_inf, + blst_p1_is_equal, + blst_p1_add_or_double, + blst_p1_cneg, + blst_p1_mult, + wipe_p1 +); +point!( + G2, + blst_p2, + Affine2, + 96, + blst_p2_generator, + blst_p2_uncompress, + blst_p2_affine_on_curve, + blst_p2_affine_in_g2, + blst_p2_from_affine, + blst_p2_compress, + blst_p2_is_inf, + blst_p2_is_equal, + blst_p2_add_or_double, + blst_p2_cneg, + blst_p2_mult, + wipe_p2 +); + +pub(crate) fn hash_g1(msg: &[u8], dst: &[u8]) -> G1 { + let mut out = G1::identity(); + // SAFETY: readable pointer-length pairs, no augmentation, initialized output. + unsafe { + blst_hash_to_g1( + &mut out.0, + msg.as_ptr(), + msg.len(), + dst.as_ptr(), + dst.len(), + core::ptr::null(), + 0, + ); + } + out +} +#[cfg(test)] +pub(crate) fn hash_g2(msg: &[u8], dst: &[u8]) -> G2 { + let mut out = G2::identity(); + // SAFETY: readable pointer-length pairs, no augmentation, initialized output. + unsafe { + blst_hash_to_g2( + &mut out.0, + msg.as_ptr(), + msg.len(), + dst.as_ptr(), + dst.len(), + core::ptr::null(), + 0, + ); + } + out +} + +// Also guards unreduced, internal Miller-loop temporaries. Only pairing() +// exposes this type outside this module, after final exponentiation. +pub(crate) struct Gt(blst_fp12); +impl fmt::Debug for Gt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Gt([REDACTED])") + } +} +impl Zeroize for Gt { + fn zeroize(&mut self) { + for fp6 in &mut self.0.fp6 { + for fp2 in &mut fp6.fp2 { + wipe_fp2(fp2); + } + } + } +} +impl Drop for Gt { + fn drop(&mut self) { + self.zeroize(); + } +} +impl ZeroizeOnDrop for Gt {} +impl Gt { + /// Frozen tower order, NOT ZCash's reverse Fp2 coordinate order. + /// The caller owns and must guard the returned secret bytes. + pub(crate) fn encode(&self) -> [u8; 576] { + let mut out = [0; 576]; + let coefficients = self + .0 + .fp6 + .iter() + .flat_map(|fp6| fp6.fp2.iter()) + .flat_map(|fp2| fp2.fp.iter()); + for (coefficient, bytes) in coefficients.zip(out.chunks_exact_mut(48)) { + // SAFETY: each chunk is exactly 48 writable bytes, distinct input. + unsafe { + blst_bendian_from_fp(bytes.as_mut_ptr(), coefficient); + } + } + out + } +} + +pub(crate) fn pairing(p: &G1, q: &G2) -> Gt { + let mut pa = Affine1::default(); + let mut qa = Affine2::default(); + let mut miller = Gt(blst_fp12::default()); + let mut reduced = Gt(blst_fp12::default()); + // SAFETY: checked subgroup inputs, initialized distinct guarded buffers. + unsafe { + blst_p1_to_affine(&mut pa.0, &p.0); + blst_p2_to_affine(&mut qa.0, &q.0); + blst_miller_loop(&mut miller.0, &qa.0, &pa.0); + blst_final_exp(&mut reduced.0, &miller.0); + } + reduced +} + +/// Product pairing check, one final exponentiation. Protocol equations use +/// two or three terms. Larger inputs stream in bounded stack batches so they +/// require no allocation; each batch is one multi-Miller loop. +pub(crate) fn pairing_check(terms: &[(&G1, &G2)]) -> bool { + // SAFETY: read-only static multiplicative identity, public data. + let mut product = Gt(unsafe { *blst_fp12_one() }); + for batch in terms.chunks(16) { + let mut ps: [Affine1; 16] = core::array::from_fn(|_| Affine1::default()); + let mut qs: [Affine2; 16] = core::array::from_fn(|_| Affine2::default()); + let mut pp = [core::ptr::null(); 16]; + let mut qp = [core::ptr::null(); 16]; + for (((pa, qa), (pptr, qptr)), (p, q)) in ps + .iter_mut() + .zip(qs.iter_mut()) + .zip(pp.iter_mut().zip(qp.iter_mut())) + .zip(batch) + { + // SAFETY: checked points, distinct initialized stable affine owners. + unsafe { + blst_p1_to_affine(&mut pa.0, &p.0); + blst_p2_to_affine(&mut qa.0, &q.0); + } + *pptr = &pa.0; + *qptr = &qa.0; + } + let mut miller = Gt(blst_fp12::default()); + let mut next = Gt(blst_fp12::default()); + // SAFETY: batch.len() valid entries point into live stable arrays. + // All input and output objects are initialized and disjoint. + unsafe { + blst_miller_loop_n(&mut miller.0, qp.as_ptr(), pp.as_ptr(), batch.len()); + blst_fp12_mul(&mut next.0, &product.0, &miller.0); + } + product = next; + } + let mut reduced = Gt(blst_fp12::default()); + // SAFETY: initialized product; distinct reduced destination. + unsafe { + blst_final_exp(&mut reduced.0, &product.0); + blst_fp12_is_one(&reduced.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + use serde_json::Value; + + const R: &str = "73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001"; + const P: &str = "1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab"; + + fn fixture(g2: bool) -> Value { + serde_json::from_str(if g2 { + include_str!("../vectors/v1/rfc9380-g2.json") + } else { + include_str!("../vectors/v1/rfc9380-g1.json") + }) + .unwrap() + } + + // RFC coordinates use c0 + I*c1; compressed G2 puts c1 before c0. + fn compressed(case: &Value, prefix: &str, g2: bool) -> Vec { + let x = case["outputs"][format!("{prefix}.x")].as_str().unwrap(); + let y = case["outputs"][format!("{prefix}.y")].as_str().unwrap(); + let xs: Vec<_> = x.split("+ I * ").collect(); + let ys: Vec<_> = y.split("+ I * ").collect(); + let p = hex::decode(P).unwrap(); + let mut neg = Vec::new(); + for part in &ys { + let mut borrow = 0i16; + let mut n = vec![0u8; 48]; + for ((dst, a), b) in n + .iter_mut() + .rev() + .zip(p.iter().rev()) + .zip(hex::decode(part).unwrap().iter().rev()) + { + let v = i16::from(*a) - i16::from(*b) - borrow; + *dst = v.rem_euclid(256) as u8; + borrow = i16::from(v < 0); + } + neg.push(n); + } + let sign = if g2 && ys[1].bytes().any(|c| c != b'0') { + hex::decode(ys[1]).unwrap() > neg[1] + } else { + hex::decode(ys[0]).unwrap() > neg[0] + }; + let mut out = if g2 { + hex::decode(format!("{}{}", xs[1], xs[0])).unwrap() + } else { + hex::decode(xs[0]).unwrap() + }; + out[0] |= 0x80 | if sign { 0x20 } else { 0 }; + out + } + + #[test] + fn rfc9380_appendix_j_g1_sswu_ro() { + let f = fixture(false); + for case in f["cases"].as_array().unwrap() { + let p = hash_g1( + case["inputs"]["msg"].as_str().unwrap().as_bytes(), + f["dst"].as_str().unwrap().as_bytes(), + ); + assert_eq!(p.encode().as_slice(), compressed(case, "P", false)); + assert_eq!(G1::decode(&p.encode(), false).unwrap(), p); + } + } + + #[test] + fn rfc9380_appendix_j_g2_sswu_ro() { + let f = fixture(true); + for case in f["cases"].as_array().unwrap() { + let p = hash_g2( + case["inputs"]["msg"].as_str().unwrap().as_bytes(), + f["dst"].as_str().unwrap().as_bytes(), + ); + assert_eq!(p.encode().as_slice(), compressed(case, "P", true)); + assert_eq!(G2::decode(&p.encode(), false).unwrap(), p); + } + } + + #[test] + fn rfc9380_pre_cofactor_points_rejected_explicitly() { + for g2 in [false, true] { + let f = fixture(g2); + let bytes = compressed(&f["cases"][0], "Q0", g2); + if g2 { + let mut affine = blst_p2_affine::default(); + // SAFETY: fixture is exactly 96 bytes; destination initialized and distinct. + unsafe { + assert_eq!( + blst_p2_uncompress(&mut affine, bytes.as_ptr()), + BLST_ERROR::BLST_SUCCESS + ); + assert!(blst_p2_affine_on_curve(&affine)); + assert!(!blst_p2_affine_in_g2(&affine)); + } + assert_eq!(G2::decode(&bytes, true), Err(Error::InvalidPoint)); + } else { + let mut affine = blst_p1_affine::default(); + // SAFETY: fixture is exactly 48 bytes; destination initialized and distinct. + unsafe { + assert_eq!( + blst_p1_uncompress(&mut affine, bytes.as_ptr()), + BLST_ERROR::BLST_SUCCESS + ); + assert!(blst_p1_affine_on_curve(&affine)); + assert!(!blst_p1_affine_in_g1(&affine)); + } + assert_eq!(G1::decode(&bytes, true), Err(Error::InvalidPoint)); + } + } + } + + #[test] + fn scalar_canonical_boundaries_and_modular_arithmetic() { + assert!(Scalar::from_bytes(&[]).is_err()); + assert!(Scalar::from_bytes(&[0; 31]).is_err()); + assert!(Scalar::from_bytes(&[0; 33]).is_err()); + assert!(Scalar::from_bytes(&hex::decode(R).unwrap()).is_err()); + assert!(Scalar::from_bytes(&[255; 32]).is_err()); + let zero = Scalar::from_bytes(&[0; 32]).unwrap(); + assert!(zero.is_zero()); + assert_eq!(zero.inverse().unwrap_err(), Error::InvalidScalar); + let one = Scalar::from_u64(1); + let minus_one = zero.sub(&one); + assert!(minus_one.add(&one).is_zero()); + assert_eq!(minus_one.mul(&minus_one).to_bytes(), one.to_bytes()); + assert_eq!( + Scalar::reduce(&hex::decode(R).unwrap()).to_bytes(), + zero.to_bytes() + ); + assert_eq!( + hex::encode(Scalar::reduce(&[255; 48]).to_bytes()), + "2dbeaf1fd4843acb7abbe5687369510a9277efb8ac0a600dcf2ab21bf81f712c" + ); + assert_eq!( + hex::encode(Scalar::reduce(&[255; 64]).to_bytes()), + "0748d9d99f59ff1105d314967254398f2b6cedcb87925c23c999e990f3f29c6c" + ); + assert_eq!( + Scalar::from_bytes(&minus_one.to_bytes()) + .unwrap() + .to_bytes(), + minus_one.to_bytes() + ); + let three = Scalar::from_u64(3); + assert_eq!( + three.mul(&three.inverse().unwrap()).to_bytes(), + one.to_bytes() + ); + assert_eq!(format!("{three:?}"), "Scalar([REDACTED])"); + } + + #[test] + fn point_decoder_lengths_flags_coordinates_and_roles() { + assert!(G1::decode(&[0; 47], true).is_err()); + assert!(G1::decode(&[0; 49], true).is_err()); + assert!(G2::decode(&[0; 95], true).is_err()); + assert!(G2::decode(&[0; 97], true).is_err()); + for size in [48, 96] { + let decode = |b: &[u8], allow| { + if size == 48 { + G1::decode(b, allow).map(|_| ()) + } else { + G2::decode(b, allow).map(|_| ()) + } + }; + let mut inf = vec![0; size]; + inf[0] = 0xc0; + assert!(decode(&inf, true).is_ok()); + assert_eq!(decode(&inf, false), Err(Error::IdentityPoint)); + inf[0] = 0xe0; + assert!(decode(&inf, true).is_err()); + inf[0] = 0xc0; + inf[size - 1] = 1; + assert!(decode(&inf, true).is_err()); + assert!(decode(&vec![0; size], true).is_err()); + let mut x = vec![0; size]; + x[..48].copy_from_slice(&hex::decode(P).unwrap()); + x[0] |= 0x80; + assert!(decode(&x, true).is_err()); + let mut valid = if size == 48 { + G1::generator().encode().to_vec() + } else { + G2::generator().encode().to_vec() + }; + valid[0] &= 0x7f; + assert!(decode(&valid, true).is_err()); + } + // RFC 9380 §8.8 equations: 5 is nonsquare in Fp; the Fp2 + // norm of 4+4I is 32, also nonsquare (Appendix I.5). + let mut off1 = [0; 48]; + off1[0] = 0x80; + off1[47] = 1; + assert_eq!(G1::decode(&off1, true), Err(Error::InvalidPoint)); + let mut off2 = [0; 96]; + off2[0] = 0x80; + assert_eq!(G2::decode(&off2, true), Err(Error::InvalidPoint)); + } + + #[test] + fn group_laws_pairing_and_single_final_exponentiation_product() { + let a = Scalar::from_u64(7); + let b = Scalar::from_u64(11); + let p = G1::generator(); + let q = G2::generator(); + assert_eq!(p.add(&p), p.mul(&Scalar::from_u64(2))); + assert_eq!(q.add(&q), q.mul(&Scalar::from_u64(2))); + assert!(p.sub(&p).is_identity()); + assert!(q.sub(&q).is_identity()); + assert!(p.mul(&Scalar::from_u64(0)).is_identity()); + assert!(q.mul(&Scalar::from_u64(0)).is_identity()); + assert_eq!( + pairing(&p.mul(&a), &q.mul(&b)).encode(), + pairing(&p.mul(&a.mul(&b)), &q).encode() + ); + let pa = p.mul(&a); + let qa = q.mul(&a); + let neg = G1::identity().sub(&p); + assert!(pairing_check(&[(&pa, &q), (&neg, &qa)])); + assert!(!pairing_check(&[(&pa, &q), (&neg, &q)])); + assert!(pairing_check(&[])); + let identity = pairing(&G1::identity(), &q).encode(); + assert_eq!(identity[47], 1); + assert!(identity[..47].iter().all(|b| *b == 0)); + assert!(identity[48..].iter().all(|b| *b == 0)); + assert_ne!(pairing(&p, &q).encode(), identity); + let mut secret = Scalar::from_u64(99); + secret.zeroize(); + assert!(secret.is_zero()); + let mut point = p; + point.zeroize(); + assert!(point.is_identity()); + let mut point = q; + point.zeroize(); + assert!(point.is_identity()); + let mut gt = pairing(&G1::generator(), &G2::generator()); + gt.zeroize(); + assert_eq!(gt.encode(), [0; 576]); + } + + #[test] + fn scalar_sampling_uses_64_bytes_and_retries_only_required_zero() { + struct Draws { + calls: usize, + } + impl rand_core::RngCore for Draws { + fn next_u32(&mut self) -> u32 { + panic!("sampling must fill 64 bytes") + } + fn next_u64(&mut self) -> u64 { + panic!("sampling must fill 64 bytes") + } + fn fill_bytes(&mut self, dest: &mut [u8]) { + assert_eq!(dest.len(), 64); + dest.fill(if self.calls == 0 { 0 } else { 1 }); + self.calls += 1; + } + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { + self.fill_bytes(dest); + Ok(()) + } + } + impl rand_core::CryptoRng for Draws {} + let mut rng = Draws { calls: 0 }; + assert!(Scalar::random(&mut rng, false).is_zero()); + assert_eq!(rng.calls, 1); + let mut rng = Draws { calls: 0 }; + let s = Scalar::random(&mut rng, true); + assert_eq!(rng.calls, 2); + assert_eq!(s.to_bytes(), Scalar::reduce(&[1; 64]).to_bytes()); + } + + proptest! { + #[test] + fn scalar_random_bytes_reject_or_canonical(bytes in prop::collection::vec(any::(), 0..80)) { + if let Ok(s) = Scalar::from_bytes(&bytes) { + let encoded = s.to_bytes(); + prop_assert_eq!(encoded.as_slice(), bytes.as_slice()); + } + } + #[test] + fn points_random_bytes_reject_or_canonical(bytes in prop::collection::vec(any::(), 0..110)) { + if let Ok(p) = G1::decode(&bytes, true) { + let encoded = p.encode(); + prop_assert_eq!(encoded.as_slice(), bytes.as_slice()); + } + if let Ok(p) = G2::decode(&bytes, true) { + let encoded = p.encode(); + prop_assert_eq!(encoded.as_slice(), bytes.as_slice()); + } + } + #[test] + fn random_scalar_and_point_roundtrips(seed in any::<[u8;32]>(), offset in 0usize..96, mask in any::()) { + let mut rng = ChaCha20Rng::from_seed(seed); + let s = Scalar::random(&mut rng, true); + prop_assert!(!s.is_zero()); + prop_assert_eq!(Scalar::from_bytes(&s.to_bytes()).unwrap().to_bytes(), s.to_bytes()); + prop_assert_eq!(s.mul(&s.inverse().unwrap()).to_bytes(), Scalar::from_u64(1).to_bytes()); + let p = G1::generator().mul(&s); let q = G2::generator().mul(&s); + prop_assert_eq!(G1::decode(&p.encode(), false).unwrap(), p.clone()); + prop_assert_eq!(G2::decode(&q.encode(), false).unwrap(), q.clone()); + let mut sb = s.to_bytes(); sb[offset % 32] ^= mask; + if let Ok(s) = Scalar::from_bytes(&sb) { prop_assert_eq!(s.to_bytes(), sb); } + let mut pb = p.encode(); pb[offset % 48] ^= mask; + if let Ok(p) = G1::decode(&pb, true) { prop_assert_eq!(p.encode(), pb); } + let mut qb = q.encode(); qb[offset] ^= mask; + if let Ok(q) = G2::decode(&qb, true) { prop_assert_eq!(q.encode(), qb); } + } + } +} diff --git a/tinycloud-threshold/src/derive.rs b/tinycloud-threshold/src/derive.rs new file mode 100644 index 00000000..2f301126 --- /dev/null +++ b/tinycloud-threshold/src/derive.rs @@ -0,0 +1,449 @@ +//! Context-bound encrypted key shares (SPEC §3.6). +use crate::{ + context::{AuthorizedDerivation, KeyContext}, + curve::{hash_g1, pairing_check, Scalar, G1, G2}, + dst, + encoding::{Decoder, Encoder}, + error::{CombineError, Error, ShareError}, + identity::IbeIdentity, + ids::*, +}; +use core::fmt; +use rand_core::CryptoRngCore; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +/// Master public key in G2, never the identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MasterPublicKey(pub(crate) G2); +impl MasterPublicKey { + /// Decodes a canonical non-identity prime-order point. + pub fn decode(bytes: &[u8]) -> Result { + Ok(Self(G2::decode(bytes, false)?)) + } + /// Returns the canonical compressed point. + pub fn encode(&self) -> [u8; 96] { + self.0.encode() + } +} + +/// Public shares for exactly one key context; constructed by a certificate path. +#[derive(Clone, Debug)] +pub struct PublicKeySet { + pub(crate) context: KeyContext, + pub(crate) mpk: MasterPublicKey, + pub(crate) public_shares: Vec<(MemberIndex, G2)>, +} +impl PublicKeySet { + /// The exact context from which this set was constructed. + pub fn context(&self) -> &KeyContext { + &self.context + } + /// The master public key. + pub fn master_public_key(&self) -> &MasterPublicKey { + &self.mpk + } + fn public_share(&self, index: MemberIndex) -> Option<&G2> { + self.public_shares + .iter() + .find(|(i, _)| *i == index) + .map(|(_, p)| p) + } +} + +/// One member's context-bound scalar, with no public S0.1 constructor. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct KeyShare { + #[zeroize(skip)] + pub(crate) context: KeyContext, + #[zeroize(skip)] + pub(crate) index: MemberIndex, + pub(crate) scalar: Scalar, +} +impl KeyShare { + /// Context under which this scalar may issue shares. + pub fn context(&self) -> &KeyContext { + &self.context + } + /// The Shamir x-coordinate. + pub fn index(&self) -> MemberIndex { + self.index + } +} +impl fmt::Debug for KeyShare { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("KeyShare([REDACTED])") + } +} + +/// Reader transport scalar; generated from 64 CSPRNG bytes reduced modulo r. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct TransportSecretKey(pub(crate) Scalar); +impl TransportSecretKey { + /// Samples a nonzero transport scalar. + pub fn generate(rng: &mut impl CryptoRngCore) -> Self { + Self(Scalar::random(rng, true)) + } + /// Corresponding non-identity G1 point. + pub fn public_key(&self) -> TransportPublicKey { + TransportPublicKey(G1::generator().mul(&self.0)) + } +} +impl fmt::Debug for TransportSecretKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TransportSecretKey([REDACTED])") + } +} + +/// Reader transport point in G1. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransportPublicKey(pub(crate) G1); +impl TransportPublicKey { + /// Decodes and checks the transport point's non-identity field role. + pub fn decode(bytes: &[u8]) -> Result { + Ok(Self(G1::decode(bytes, false)?)) + } + /// Returns its canonical compressed bytes. + pub fn encode(&self) -> [u8; 48] { + self.0.encode() + } +} + +/// Raw request; possession of this type does not authorize issuing a share. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DecryptRequest { + pub(crate) context_digest: [u8; 32], + pub(crate) network: NetworkUrn, + pub(crate) rekey: RekeyCounter, + pub(crate) epoch: Epoch, + pub(crate) certificate: CertDigest, + pub(crate) identity: IbeIdentity, + pub(crate) tpk: TransportPublicKey, +} +impl DecryptRequest { + /// Builds a request for a context, requiring the identity's outer network. + pub fn new( + ctx: &KeyContext, + identity: IbeIdentity, + tpk: TransportPublicKey, + ) -> Result { + if identity.network() != ctx.network() { + return Err(Error::ContextMismatch); + } + Ok(Self { + context_digest: ctx.digest(), + network: ctx.network().clone(), + rekey: ctx.rekey(), + epoch: ctx.epoch(), + certificate: *ctx.certificate(), + identity, + tpk, + }) + } + /// The identity to derive. + pub fn identity(&self) -> &IbeIdentity { + &self.identity + } + /// The request's canonical digest. + pub fn digest(&self) -> RequestDigest { + RequestDigest::new(dst::tagged_hash(dst::DECRYPT_REQUEST, &self.encode())) + } + /// Canonical request layout documented in SPEC. + pub fn encode(&self) -> Vec { + let mut e = Encoder::new(); + e.u8(1); + e.fixed(&self.context_digest); + e.lp(self.network.as_bytes()); + e.u64(self.rekey.get()); + e.u64(self.epoch.get()); + e.fixed(self.certificate.as_bytes()); + e.lp(self.identity.as_bytes()); + e.fixed(&self.tpk.encode()); + e.finish() + } + /// Bounded decoding checks the identity/network relation before returning. + pub fn decode(bytes: &[u8]) -> Result { + let mut d = Decoder::new(bytes)?; + if d.u8()? != 1 { + return Err(Error::InvalidEncoding); + } + let context_digest = d.fixed()?; + let network = NetworkUrn::new( + core::str::from_utf8(d.lp(1024)?).map_err(|_| Error::InvalidEncoding)?, + )?; + let rekey = RekeyCounter::new(d.u64()?); + let epoch = Epoch::new(d.u64()?); + let certificate = CertDigest::new(d.fixed()?); + let identity = IbeIdentity::decode(d.lp(4096)?)?; + let tpk = TransportPublicKey::decode(&d.fixed::<48>()?)?; + d.finish()?; + if identity.network() != &network { + return Err(Error::ContextMismatch); + } + Ok(Self { + context_digest, + network, + rekey, + epoch, + certificate, + identity, + tpk, + }) + } + fn matches(&self, ctx: &KeyContext) -> bool { + self.context_digest == ctx.digest() + && self.network == *ctx.network() + && self.rekey == ctx.rekey() + && self.epoch == ctx.epoch() + && self.certificate == *ctx.certificate() + && self.identity.network() == &self.network + } +} +impl TryFrom<&[u8]> for DecryptRequest { + type Error = Error; + fn try_from(v: &[u8]) -> Result { + Self::decode(v) + } +} + +/// Encrypted member share. Both context and request digest are checked first. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EncryptedKeyShare { + pub(crate) member: MemberIndex, + pub(crate) context_digest: [u8; 32], + pub(crate) request_digest: [u8; 32], + pub(crate) c1: G1, + pub(crate) c2: G2, + pub(crate) c3: G1, +} +impl EncryptedKeyShare { + /// The issuing member's index. + pub fn index(&self) -> MemberIndex { + self.member + } + /// Canonical layout, including the approved request-digest amendment. + pub fn encode(&self) -> Vec { + let mut e = Encoder::new(); + e.u8(1); + e.u16(self.member.get()); + e.fixed(&self.context_digest); + e.fixed(&self.request_digest); + e.fixed(&self.c1.encode()); + e.fixed(&self.c2.encode()); + e.fixed(&self.c3.encode()); + e.finish() + } + /// Decodes c1/c2 as non-identity and c3 with identity permitted. + pub fn decode(bytes: &[u8]) -> Result { + let mut d = Decoder::new(bytes)?; + if d.u8()? != 1 { + return Err(Error::InvalidEncoding); + } + let member = MemberIndex::new(d.u16()?)?; + let context_digest = d.fixed()?; + let request_digest = d.fixed()?; + let c1 = G1::decode(&d.fixed::<48>()?, false)?; + let c2 = G2::decode(&d.fixed::<96>()?, false)?; + let c3 = G1::decode(&d.fixed::<48>()?, true)?; + d.finish()?; + Ok(Self { + member, + context_digest, + request_digest, + c1, + c2, + c3, + }) + } + /// Tagged 32-byte preimage for a node-key signature; request digest is explicit. + pub fn signing_preimage(&self, req: &DecryptRequest) -> Vec { + let mut e = Encoder::new(); + e.fixed(req.digest().as_bytes()); + e.fixed(req.certificate.as_bytes()); + e.u16(self.member.get()); + e.fixed(&self.encode()); + dst::tagged_hash(dst::DECRYPT_SHARE, &e.finish()).to_vec() + } + fn matches(&self, ctx: &KeyContext, req: &DecryptRequest) -> bool { + self.context_digest == ctx.digest() + && self.request_digest == *req.digest().as_bytes() + && self.member.get() <= ctx.threshold().n() + } +} +impl TryFrom<&[u8]> for EncryptedKeyShare { + type Error = Error; + fn try_from(v: &[u8]) -> Result { + Self::decode(v) + } +} + +/// Issues an encrypted share only for the exact authorized request context. +pub fn issue_share( + share: &KeyShare, + auth: &AuthorizedDerivation, + rng: &mut impl CryptoRngCore, +) -> Result { + let req = &auth.request; + if !req.matches(&share.context) || share.index.get() > share.context.threshold().n() { + return Err(Error::ContextMismatch); + } + let r = Scalar::random(rng, true); + let contribution = Zeroizing::new(hash_g1(req.identity.as_bytes(), dst::H1).mul(&share.scalar)); + let mask = Zeroizing::new(req.tpk.0.mul(&r)); + Ok(EncryptedKeyShare { + member: share.index, + context_digest: share.context.digest(), + request_digest: *req.digest().as_bytes(), + c1: G1::generator().mul(&r), + c2: G2::generator().mul(&r), + c3: contribution.add(&mask), + }) +} + +/// Checks context before separately checking both member-share equations. +pub fn verify_share( + public: &PublicKeySet, + req: &DecryptRequest, + share: &EncryptedKeyShare, +) -> Result<(), ShareError> { + if !req.matches(&public.context) || !share.matches(&public.context, req) { + return Err(ShareError::ContextMismatch); + } + let pk = public + .public_share(share.member) + .ok_or(ShareError::ContextMismatch)?; + let g1 = G1::generator(); + let g2 = G2::generator(); + let neg_g1 = G1::identity().sub(&g1); + if !pairing_check(&[(&share.c1, &g2), (&neg_g1, &share.c2)]) { + return Err(ShareError::Invalid { + member: share.member, + }); + } + let h = hash_g1(req.identity.as_bytes(), dst::H1); + let neg_h = G1::identity().sub(&h); + let neg_tpk = G1::identity().sub(&req.tpk.0); + if !pairing_check(&[(&share.c3, &g2), (&neg_h, pk), (&neg_tpk, &share.c2)]) { + return Err(ShareError::Invalid { + member: share.member, + }); + } + Ok(()) +} + +/// An aggregate for one request; identity intermediate components are permitted. +#[derive(Debug)] +pub struct EncryptedDerivedKey { + context_digest: [u8; 32], + request_digest: [u8; 32], + pub(crate) c1: G1, + // SPEC retains all three aggregate components; unmasking uses C1 and C3. + #[allow(dead_code)] + pub(crate) c2: G2, + pub(crate) c3: G1, +} + +/// Verifies every input, counts each member once, then uses the lowest t indices. +pub fn combine( + public: &PublicKeySet, + req: &DecryptRequest, + shares: &[EncryptedKeyShare], +) -> Result { + if !req.matches(&public.context) { + return Err(CombineError::ContextMismatch); + } + // This entire pass precedes *every* pairing, including shares not selected. + if shares.iter().any(|s| !s.matches(&public.context, req)) { + return Err(CombineError::MixedContexts); + } + let mut distinct: [Option<&EncryptedKeyShare>; 16] = [None; 16]; + for s in shares { + verify_share(public, req, s).map_err(|e| match e { + ShareError::ContextMismatch => CombineError::MixedContexts, + ShareError::Invalid { member } => CombineError::Invalid { member }, + })?; + let index = usize::from(s.member.get()) + .checked_sub(1) + .ok_or(CombineError::MixedContexts)?; + let entry = distinct.get_mut(index).ok_or(CombineError::MixedContexts)?; + if entry.is_none() { + *entry = Some(s) + } + } + let t = usize::from(public.context.threshold().t()); + if distinct.iter().flatten().count() < t { + return Err(CombineError::NotEnoughShares); + } + let mut c1 = G1::identity(); + let mut c2 = G2::identity(); + let mut c3 = G1::identity(); + for s in distinct.iter().flatten().take(t) { + let lambda = crate::poly::lagrange( + s.member, + distinct.iter().flatten().take(t).map(|s| s.member), + ) + .map_err(|_| CombineError::MixedContexts)?; + c1 = c1.add(&s.c1.mul(&lambda)); + c2 = c2.add(&s.c2.mul(&lambda)); + c3 = c3.add(&s.c3.mul(&lambda)); + } + Ok(EncryptedDerivedKey { + context_digest: public.context.digest(), + request_digest: *req.digest().as_bytes(), + c1, + c2, + c3, + }) +} + +/// A derived G1 key carrying its exact identity and key-context digest. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct DerivedKey { + #[zeroize(skip)] + pub(crate) identity: IbeIdentity, + #[zeroize(skip)] + context_digest: [u8; 32], + pub(crate) key: Zeroizing, +} +impl DerivedKey { + /// The identity associated with the key. + pub fn identity(&self) -> &IbeIdentity { + &self.identity + } + /// Context to which the derivation belongs. + pub fn context_digest(&self) -> &[u8; 32] { + &self.context_digest + } +} +impl fmt::Debug for DerivedKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DerivedKey([REDACTED])") + } +} + +/// Removes the reader's mask and checks the resulting key against id and mpk. +pub fn decrypt_derived_key( + ek: &EncryptedDerivedKey, + tsk: &TransportSecretKey, + public: &PublicKeySet, + req: &DecryptRequest, +) -> Result { + if !req.matches(&public.context) + || ek.context_digest != public.context.digest() + || ek.request_digest != *req.digest().as_bytes() + || tsk.public_key() != req.tpk + { + return Err(Error::ContextMismatch); + } + let mask = Zeroizing::new(ek.c1.mul(&tsk.0)); + let key = Zeroizing::new(ek.c3.sub(&mask)); + let g2 = G2::generator(); + let neg_h = G1::identity().sub(&hash_g1(req.identity.as_bytes(), dst::H1)); + if key.is_identity() || !pairing_check(&[(&key, &g2), (&neg_h, &public.mpk.0)]) { + return Err(Error::InvalidDerivedKey); + } + Ok(DerivedKey { + identity: req.identity.clone(), + context_digest: public.context.digest(), + key, + }) +} diff --git a/tinycloud-threshold/src/dst.rs b/tinycloud-threshold/src/dst.rs new file mode 100644 index 00000000..509ff630 --- /dev/null +++ b/tinycloud-threshold/src/dst.rs @@ -0,0 +1,236 @@ +//! Frozen domains from plan §3.3 and RFC 9380 §5 SHA-256 XMD. + +use sha2::{Digest, Sha256}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::curve::Scalar; +#[cfg(test)] +use crate::Error; + +pub(crate) const H1: &[u8] = b"TC-THRESHOLD-V1-IBE-ID_BLS12381G1_XMD:SHA-256_SSWU_RO_"; +pub(crate) const PEDERSEN_H: &[u8] = b"TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_"; +pub(crate) const H2: &[u8] = b"TC-THRESHOLD-V1-IBE-H2"; +pub(crate) const H3: &[u8] = b"TC-THRESHOLD-V1-IBE-H3"; +pub(crate) const H4: &[u8] = b"TC-THRESHOLD-V1-IBE-H4"; +pub(crate) const IDENTITY_PREFIX: &[u8] = b"TC-THRESHOLD-V1-IBE-ID-ENC"; +pub(crate) const KEY_CONTEXT: &[u8] = b"TC-THRESHOLD-V1-KEY-CONTEXT"; +pub(crate) const DECRYPT_REQUEST: &[u8] = b"TC-THRESHOLD-V1-DECRYPT-REQUEST"; +pub(crate) const DECRYPT_SHARE: &[u8] = b"TC-THRESHOLD-V1-DECRYPT-SHARE"; +pub(crate) const COMMITMENTS: &[u8] = b"TC-THRESHOLD-V1-COMMITMENTS"; +pub(crate) const CEREMONY_AUTH: &[u8] = b"TC-THRESHOLD-V1-CEREMONY-AUTH"; +pub(crate) const CEREMONY_MSG: &[u8] = b"TC-THRESHOLD-V1-CEREMONY-MSG"; +pub(crate) const ROUND_CLOSE: &[u8] = b"TC-THRESHOLD-V1-ROUND-CLOSE"; +pub(crate) const TRANSCRIPT: &[u8] = b"TC-THRESHOLD-V1-TRANSCRIPT"; +pub(crate) const NETWORK_CERT: &[u8] = b"TC-THRESHOLD-V1-NETWORK-CERT"; +pub(crate) const CERT_STATEMENT: &[u8] = b"TC-THRESHOLD-V1-CERT-STATEMENT"; +pub(crate) const READY: &[u8] = b"TC-THRESHOLD-V1-READY"; +pub(crate) const ACTIVATE: &[u8] = b"TC-THRESHOLD-V1-ACTIVATE"; +pub(crate) const DEALING: &[u8] = b"TC-THRESHOLD-V1-DEALING"; + +/// The complete frozen domain table, including later ceremony/certificate uses. +pub const DOMAINS: &[&[u8]] = &[ + H1, + PEDERSEN_H, + H2, + H3, + H4, + IDENTITY_PREFIX, + KEY_CONTEXT, + DECRYPT_REQUEST, + DECRYPT_SHARE, + COMMITMENTS, + CEREMONY_AUTH, + CEREMONY_MSG, + ROUND_CLOSE, + TRANSCRIPT, + NETWORK_CERT, + CERT_STATEMENT, + READY, + ACTIVATE, + DEALING, +]; + +/// Callers use the bounded, frozen tags above, never message-sized domains. +pub(crate) fn tagged_hash(tag: &[u8], body: &[u8]) -> [u8; 32] { + let mut hash = Sha256::new(); + hash.update((tag.len() as u32).to_be_bytes()); + hash.update(tag); + hash.update(body); + hash.finalize().into() +} + +/// RFC 9380 §§5.3.1,5.3.3. SHA-256 limits ell to 255 blocks. +#[cfg(test)] +pub(crate) fn expand_xmd(msg: &[u8], dst: &[u8]) -> Result<[u8; N], Error> { + if N == 0 || N > 8160 { + return Err(Error::InvalidLength); + } + let length = u16::try_from(N) + .map_err(|_| Error::InvalidLength)? + .to_be_bytes(); + let mut shortened = [0; 32]; + let dst = if dst.len() > 255 { + let mut hash = Sha256::new(); + hash.update(b"H2C-OVERSIZE-DST-"); + hash.update(dst); + shortened.copy_from_slice(&hash.finalize()); + shortened.as_slice() + } else { + dst + }; + let dst_len = u8::try_from(dst.len()).map_err(|_| Error::InvalidLength)?; + Ok(xmd(msg, dst, length, dst_len)) +} + +/// Internal callers supply statically valid lengths. Keeping this infallible +/// avoids impossible-error fallbacks in the frozen H2/H3/H4 functions. +fn xmd(msg: &[u8], dst: &[u8], length: [u8; 2], dst_len: u8) -> [u8; N] { + let mut hash = Sha256::new(); + hash.update([0; 64]); + hash.update(msg); + hash.update(length); + hash.update([0]); + hash.update(dst); + hash.update([dst_len]); + let mut digest = hash.finalize(); + let mut b0 = Zeroizing::new([0; 32]); + b0.copy_from_slice(&digest); + for byte in digest.iter_mut() { + byte.zeroize(); + } + + let mut previous = Zeroizing::new([0; 32]); + let mut input = Zeroizing::new([0; 32]); + let mut out = [0; N]; + for (chunk, counter) in out.chunks_mut(32).zip(1u8..=255) { + if counter == 1 { + input.copy_from_slice(b0.as_ref()); + } else { + for ((dst, a), b) in input.iter_mut().zip(b0.iter()).zip(previous.iter()) { + *dst = a ^ b; + } + } + let mut hash = Sha256::new(); + hash.update(input.as_ref()); + hash.update([counter]); + hash.update(dst); + hash.update([dst_len]); + let mut digest = hash.finalize(); + previous.copy_from_slice(&digest); + for byte in digest.iter_mut() { + byte.zeroize(); + } + for (dst, source) in chunk.iter_mut().zip(previous.iter()) { + *dst = *source; + } + } + out +} + +pub(crate) fn h2(gt: &[u8; 576]) -> [u8; 32] { + xmd(gt, H2, [0, 32], 22) +} + +/// RFC 9380 hash_to_field with count=1, m=1, L=48, modulus r, NOT p. +pub(crate) fn h3(input: &[u8; 64]) -> Scalar { + let uniform = Zeroizing::new(xmd::<48>(input, H3, [0, 48], 22)); + Scalar::reduce(uniform.as_ref()) +} + +pub(crate) fn h4(sigma: &[u8; 32]) -> [u8; 32] { + xmd(sigma, H4, [0, 32], 22) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc9380_appendix_k1_expand_xmd_sha256() { + let f: serde_json::Value = + serde_json::from_str(include_str!("../vectors/v1/rfc9380-k1.json")).unwrap(); + for case in f["cases"].as_array().unwrap() { + let msg = case["inputs"]["msg"].as_str().unwrap().as_bytes(); + let dst = f["dst"].as_str().unwrap().as_bytes(); + let expected = case["outputs"]["uniform_bytes"].as_str().unwrap(); + match case["inputs"]["len"].as_u64().unwrap() { + 32 => assert_eq!(hex::encode(expand_xmd::<32>(msg, dst).unwrap()), expected), + 128 => assert_eq!(hex::encode(expand_xmd::<128>(msg, dst).unwrap()), expected), + _ => panic!("unexpected RFC length"), + } + } + } + + #[test] + fn h3_modulus_r_known_answer() { + // RFC 9380 §5 with L=48 and r from plan §3.2. KAT calculated + // directly with Python hashlib and integer remainder, not a curve library. + assert_eq!( + hex::encode(h3(&[0; 64]).to_bytes()), + "3947ae6f5d03f2e986b43b61590c8024201cb3f932154b5f2565f1ccd3deb325" + ); + assert_eq!( + hex::encode(h2(&[0; 576])), + "198dcd319e3485eb604d6707177332b2ea6781f77428676161adb0b37baea90f" + ); + assert_eq!( + hex::encode(h4(&[0; 32])), + "dd63b23305e0799f7e1d9977599d82a04a995888f4414d9aac22e489cc09a36d" + ); + assert_eq!( + hex::encode(tagged_hash(b"tag", b"body")), + "55e024df6096cd1ffeecb9075b33ac7e902bedc07ef0bad9886bbd4375811459" + ); + } + + #[test] + fn domains_and_tagged_hashes_are_distinct() { + let tags = [ + H1, + PEDERSEN_H, + H2, + H3, + H4, + IDENTITY_PREFIX, + KEY_CONTEXT, + DECRYPT_REQUEST, + DECRYPT_SHARE, + COMMITMENTS, + CEREMONY_AUTH, + CEREMONY_MSG, + ROUND_CLOSE, + TRANSCRIPT, + NETWORK_CERT, + CERT_STATEMENT, + READY, + ACTIVATE, + DEALING, + ]; + for (i, tag) in tags.iter().enumerate() { + for other in tags.iter().skip(i + 1) { + assert_ne!(tag, other); + assert_ne!(tagged_hash(tag, b"body"), tagged_hash(other, b"body")); + } + } + assert_ne!(tagged_hash(b"a", b"bc"), tagged_hash(b"ab", b"c")); + assert_eq!(h2(&[0; 576]), expand_xmd::<32>(&[0; 576], H2).unwrap()); + assert_eq!(h4(&[0; 32]), expand_xmd::<32>(&[0; 32], H4).unwrap()); + } + + #[test] + fn xmd_bounds_and_oversize_dst() { + assert!(expand_xmd::<0>(b"", H1).is_err()); + assert!(expand_xmd::<8161>(b"", H1).is_err()); + assert!(expand_xmd::<8160>(b"", H1).is_ok()); + let dst = [42u8; 256]; + let mut h = Sha256::new(); + h.update(b"H2C-OVERSIZE-DST-"); + h.update(dst); + let shortened = h.finalize(); + assert_eq!( + expand_xmd::<48>(b"abc", &dst).unwrap(), + expand_xmd::<48>(b"abc", &shortened).unwrap() + ); + assert_eq!(expand_xmd::<1>(b"abc", H1).unwrap().len(), 1); + } +} diff --git a/tinycloud-threshold/src/encoding.rs b/tinycloud-threshold/src/encoding.rs new file mode 100644 index 00000000..5d063cc4 --- /dev/null +++ b/tinycloud-threshold/src/encoding.rs @@ -0,0 +1,198 @@ +//! Fixed-order, big-endian encoding for construction-bounded crate values. + +use crate::error::Error; + +const MESSAGE_CAP: usize = 65_536; + +/// An encoder for validated fields whose complete message fits the message cap. +/// +/// Callers supply only construction-bounded fields: `lp` lengths are at most +/// 64 KiB, and complete encoded messages are at most 64 KiB. Untrusted lengths +/// enter through `Decoder`, not this encoder. +#[derive(Default)] +pub(crate) struct Encoder { + bytes: Vec, +} + +impl Encoder { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn u8(&mut self, value: u8) { + self.bytes.push(value); + } + + pub(crate) fn u16(&mut self, value: u16) { + self.fixed(&value.to_be_bytes()); + } + + pub(crate) fn u32(&mut self, value: u32) { + self.fixed(&value.to_be_bytes()); + } + + pub(crate) fn u64(&mut self, value: u64) { + self.fixed(&value.to_be_bytes()); + } + + pub(crate) fn fixed(&mut self, value: &[u8]) { + self.bytes.extend_from_slice(value); + } + + pub(crate) fn lp(&mut self, value: &[u8]) { + // Every field is construction-bounded to <= 64 KiB by the caller. + self.u32(value.len() as u32); + self.fixed(value); + } + + pub(crate) fn finish(self) -> Vec { + self.bytes + } +} + +/// A borrowed decoder that bounds lengths before returning a field slice. +pub(crate) struct Decoder<'a> { + remaining: &'a [u8], +} + +impl<'a> Decoder<'a> { + pub(crate) fn new(bytes: &'a [u8]) -> Result { + if bytes.len() > MESSAGE_CAP { + return Err(Error::LimitExceeded); + } + Ok(Self { remaining: bytes }) + } + + fn take(&mut self, length: usize) -> Result<&'a [u8], Error> { + let (value, remaining) = self + .remaining + .split_at_checked(length) + .ok_or(Error::InvalidLength)?; + self.remaining = remaining; + Ok(value) + } + + pub(crate) fn u8(&mut self) -> Result { + let [value] = self.fixed()?; + Ok(value) + } + + pub(crate) fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes(self.fixed()?)) + } + + pub(crate) fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.fixed()?)) + } + + pub(crate) fn u64(&mut self) -> Result { + Ok(u64::from_be_bytes(self.fixed()?)) + } + + pub(crate) fn fixed(&mut self) -> Result<[u8; N], Error> { + self.take(N)?.try_into().map_err(|_| Error::InvalidLength) + } + + pub(crate) fn lp(&mut self, cap: usize) -> Result<&'a [u8], Error> { + let length = usize::try_from(self.u32()?).map_err(|_| Error::LimitExceeded)?; + if length > cap { + return Err(Error::LimitExceeded); + } + self.take(length) + } + + pub(crate) fn finish(self) -> Result<(), Error> { + if !self.remaining.is_empty() { + return Err(Error::InvalidEncoding); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + #[test] + fn fixed_width_and_length_prefixed_encoding_is_big_endian() { + let mut encoder = Encoder::new(); + encoder.u8(1); + encoder.u16(0x0203); + encoder.u32(0x04050607); + encoder.u64(0x08090a0b0c0d0e0f); + encoder.fixed(&[16, 17]); + encoder.lp(&[18, 19]); + let bytes = encoder.finish(); + assert_eq!( + bytes, + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 0, 0, 0, 2, 18, 19] + ); + let mut decoder = Decoder::new(&bytes).unwrap(); + assert_eq!(decoder.u8(), Ok(1)); + assert_eq!(decoder.u16(), Ok(0x0203)); + assert_eq!(decoder.u32(), Ok(0x04050607)); + assert_eq!(decoder.u64(), Ok(0x08090a0b0c0d0e0f)); + assert_eq!(decoder.fixed::<2>(), Ok([16, 17])); + assert_eq!(decoder.lp(2), Ok([18, 19].as_slice())); + assert_eq!(decoder.finish(), Ok(())); + } + + #[test] + fn decoder_rejects_limits_truncation_and_trailing_bytes() { + assert!(matches!( + Decoder::new(&vec![0; 65537]), + Err(Error::LimitExceeded) + )); + assert!(Decoder::new(&vec![0; 65536]).is_ok()); + assert_eq!(Decoder::new(&[]).unwrap().u8(), Err(Error::InvalidLength)); + assert_eq!(Decoder::new(&[0]).unwrap().u16(), Err(Error::InvalidLength)); + assert_eq!( + Decoder::new(&[0; 3]).unwrap().u32(), + Err(Error::InvalidLength) + ); + assert_eq!( + Decoder::new(&[0; 7]).unwrap().u64(), + Err(Error::InvalidLength) + ); + assert_eq!( + Decoder::new(&[0; 7]).unwrap().fixed::<8>(), + Err(Error::InvalidLength) + ); + assert_eq!( + Decoder::new(&[0]).unwrap().finish(), + Err(Error::InvalidEncoding) + ); + assert_eq!( + Decoder::new(&[0, 0, 0, 1]).unwrap().lp(1), + Err(Error::InvalidLength) + ); + assert_eq!( + Decoder::new(&[0xff; 4]).unwrap().lp(1024), + Err(Error::LimitExceeded) + ); + assert_eq!( + Decoder::new(&[0, 0, 0, 2, 1, 2]).unwrap().lp(1), + Err(Error::LimitExceeded) + ); + assert_eq!( + Decoder::new(&[0, 0, 0, 0]).unwrap().lp(0), + Ok([].as_slice()) + ); + } + + proptest! { + #[test] + fn random_decoder_input_never_panics(bytes in prop::collection::vec(any::(), 0..70000)) { + if let Ok(mut decoder) = Decoder::new(&bytes) { + let _ = decoder.u8(); + let _ = decoder.u16(); + let _ = decoder.u32(); + let _ = decoder.u64(); + let _ = decoder.fixed::<32>(); + let _ = decoder.lp(1024); + let _ = decoder.finish(); + } + } + } +} diff --git a/tinycloud-threshold/src/error.rs b/tinycloud-threshold/src/error.rs new file mode 100644 index 00000000..6341bf64 --- /dev/null +++ b/tinycloud-threshold/src/error.rs @@ -0,0 +1,61 @@ +//! Errors at decoding, context and algebra boundaries. +use crate::ids::MemberIndex; + +/// Failure before a key or ciphertext can be returned. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("invalid encoding")] + InvalidEncoding, + #[error("invalid length")] + InvalidLength, + #[error("input exceeds its size cap")] + LimitExceeded, + #[error("non-canonical scalar")] + InvalidScalar, + #[error("invalid curve point")] + InvalidPoint, + #[error("identity point is not allowed for this field")] + IdentityPoint, + #[error("member index outside 1..=16")] + InvalidIndex, + #[error("threshold must satisfy 2 <= t <= n <= 16")] + InvalidThreshold, + #[error("genesis requires n >= 2t-1")] + UnsupportedFaultBudget, + #[error("counter overflow")] + CounterOverflow, + #[error("key context mismatch")] + ContextMismatch, + #[error("identity does not match the capability check")] + UnauthorizedIdentity, + #[error("derived key belongs to another identity")] + WrongIdentity, + #[error("randomness reduced to zero")] + DegenerateRandomness, + #[error("invalid IBE ciphertext")] + InvalidCiphertext, + #[error("derived key does not match the request")] + InvalidDerivedKey, +} + +/// Verification distinguishes context selection from a member's share equation. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ShareError { + #[error("key context mismatch")] + ContextMismatch, + #[error("invalid share from {member:?}")] + Invalid { member: MemberIndex }, +} + +/// Combining never interprets mixed-context inputs as member faults. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CombineError { + #[error("request context mismatch")] + ContextMismatch, + #[error("mixed share contexts")] + MixedContexts, + #[error("invalid share from {member:?}")] + Invalid { member: MemberIndex }, + #[error("not enough distinct shares")] + NotEnoughShares, +} diff --git a/tinycloud-threshold/src/ibe.rs b/tinycloud-threshold/src/ibe.rs new file mode 100644 index 00000000..2727e339 --- /dev/null +++ b/tinycloud-threshold/src/ibe.rs @@ -0,0 +1,139 @@ +//! Boneh–Franklin FullIdent for fixed-size data keys (SPEC §3.5). +use crate::{ + curve::{hash_g1, pairing, Scalar, G2}, + derive::{DerivedKey, MasterPublicKey}, + dst, + encoding::Decoder, + Error, IbeIdentity, +}; +use core::fmt; +use rand_core::CryptoRngCore; +use subtle::ConstantTimeEq; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +/// A 256-bit data key. Its bytes are wiped when dropped. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct DataKey(Zeroizing<[u8; 32]>); +impl DataKey { + /// Takes ownership of a fixed-width data key. + pub fn new(bytes: [u8; 32]) -> Self { + Self(Zeroizing::new(bytes)) + } + /// Borrows the key bytes; callers own the lifecycle of any copy they make. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} +impl fmt::Debug for DataKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DataKey([REDACTED])") + } +} + +/// Fixed-width `(U,V,W)` tuple; `U` is a non-identity G2 subgroup point. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IbeCiphertext { + pub(crate) u: G2, + pub(crate) v: [u8; 32], + pub(crate) w: [u8; 32], +} +impl IbeCiphertext { + /// Canonical 160-byte FullIdent tuple. + pub fn encode(&self) -> [u8; 160] { + let mut out = [0; 160]; + for (dst, src) in out + .iter_mut() + .zip(self.u.encode().into_iter().chain(self.v).chain(self.w)) + { + *dst = src; + } + out + } + /// Checks exact size, canonical point encoding, subgroup and U's field role. + pub fn decode(bytes: &[u8]) -> Result { + let mut d = Decoder::new(bytes)?; + let u = G2::decode(&d.fixed::<96>()?, false)?; + let v = d.fixed()?; + let w = d.fixed()?; + d.finish()?; + Ok(Self { u, v, w }) + } +} +impl TryFrom<&[u8]> for IbeCiphertext { + type Error = Error; + fn try_from(v: &[u8]) -> Result { + Self::decode(v) + } +} + +fn fo_input(sigma: &[u8; 32], message: &[u8; 32]) -> Zeroizing<[u8; 64]> { + let mut out = Zeroizing::new([0; 64]); + for (d, s) in out.iter_mut().zip(sigma.iter().chain(message)) { + *d = *s; + } + out +} +fn xor(a: &[u8; 32], b: &[u8; 32]) -> [u8; 32] { + let mut out = [0; 32]; + for ((d, a), b) in out.iter_mut().zip(a).zip(b) { + *d = a ^ b; + } + out +} + +/// Wraps a data key to an exact record or session identity under an mpk. +pub fn wrap_key( + mpk: &MasterPublicKey, + id: &IbeIdentity, + key: &DataKey, + rng: &mut impl CryptoRngCore, +) -> Result { + let mut sigma = Zeroizing::new([0; 32]); + rng.fill_bytes(sigma.as_mut()); + let rho = dst::h3(&fo_input(&sigma, key.as_bytes())); + wrap_with_rho(mpk, id, key, sigma, rho) +} + +pub(crate) fn wrap_with_rho( + mpk: &MasterPublicKey, + id: &IbeIdentity, + key: &DataKey, + sigma: Zeroizing<[u8; 32]>, + rho: Scalar, +) -> Result { + if rho.is_zero() { + return Err(Error::DegenerateRandomness); + } + let u = G2::generator().mul(&rho); + let ephemeral = Zeroizing::new(hash_g1(id.as_bytes(), dst::H1).mul(&rho)); + let gt = Zeroizing::new(pairing(&ephemeral, &mpk.0)); + let encoded = Zeroizing::new(gt.encode()); + let mask = Zeroizing::new(dst::h2(&encoded)); + let v = xor(&sigma, &mask); + let mask = Zeroizing::new(dst::h4(&sigma)); + let w = xor(key.as_bytes(), &mask); + Ok(IbeCiphertext { u, v, w }) +} + +/// Unwraps only with a key carrying the exact expected identity. +pub fn unwrap_key( + key: &DerivedKey, + expected_id: &IbeIdentity, + ct: &IbeCiphertext, +) -> Result { + if key.identity != *expected_id { + return Err(Error::WrongIdentity); + } + let gt = Zeroizing::new(pairing(&key.key, &ct.u)); + let encoded = Zeroizing::new(gt.encode()); + let mask = Zeroizing::new(dst::h2(&encoded)); + let sigma = Zeroizing::new(xor(&ct.v, &mask)); + let mask = Zeroizing::new(dst::h4(&sigma)); + let message = Zeroizing::new(xor(&ct.w, &mask)); + let rho = dst::h3(&fo_input(&sigma, &message)); + let expected = G2::generator().mul(&rho).encode(); + if rho.is_zero() || !bool::from(expected.ct_eq(&ct.u.encode())) { + return Err(Error::InvalidCiphertext); + } + Ok(DataKey::new(*message)) +} diff --git a/tinycloud-threshold/src/identity.rs b/tinycloud-threshold/src/identity.rs new file mode 100644 index 00000000..cbf7064e --- /dev/null +++ b/tinycloud-threshold/src/identity.rs @@ -0,0 +1,339 @@ +//! Canonical record and session identity encodings. + +use crate::dst::IDENTITY_PREFIX; +use crate::encoding::{Decoder, Encoder}; +use crate::error::Error; +use crate::ids::{KeyPath, KeyVersion, NetworkUrn, WriteNonce}; + +const VERSION: u8 = 1; +const RECORD: u8 = 1; +const SESSION: u8 = 2; + +/// An immutable identity with the exact version-one canonical bytes. +/// +/// Record mode includes a write nonce. Session mode deliberately omits it; +/// enabling session mode and choosing nonce uniqueness are caller policies. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IbeIdentity { + network: NetworkUrn, + path: KeyPath, + version: KeyVersion, + nonce: Option, + bytes: Vec, +} + +impl IbeIdentity { + /// Encode a record identity without normalizing the network or path. + pub fn record( + network: &NetworkUrn, + path: &KeyPath, + version: KeyVersion, + nonce: &WriteNonce, + ) -> Result { + Ok(Self::build(network, path, version, Some(*nonce))) + } + + /// Encode a session identity without a write nonce. + pub fn session( + network: &NetworkUrn, + path: &KeyPath, + version: KeyVersion, + ) -> Result { + Ok(Self::build(network, path, version, None)) + } + + fn build( + network: &NetworkUrn, + path: &KeyPath, + version: KeyVersion, + nonce: Option, + ) -> Self { + let mut encoder = Encoder::new(); + encoder.lp(IDENTITY_PREFIX); + encoder.u8(VERSION); + encoder.u8(if nonce.is_some() { RECORD } else { SESSION }); + encoder.lp(network.as_bytes()); + encoder.lp(path.as_bytes()); + encoder.u64(version.get()); + if let Some(nonce) = &nonce { + encoder.fixed(nonce.as_bytes()); + } + Self { + network: network.clone(), + path: path.clone(), + version, + nonce, + bytes: encoder.finish(), + } + } + + /// Decode exactly one canonical identity, rejecting malformed or trailing bytes. + /// + /// Nested field caps are checked before UTF-8 strings are allocated. + pub fn decode(bytes: &[u8]) -> Result { + let mut decoder = Decoder::new(bytes)?; + if decoder.lp(IDENTITY_PREFIX.len())? != IDENTITY_PREFIX { + return Err(Error::InvalidEncoding); + } + if decoder.u8()? != VERSION { + return Err(Error::InvalidEncoding); + } + let mode = decoder.u8()?; + if mode != RECORD && mode != SESSION { + return Err(Error::InvalidEncoding); + } + let network = + core::str::from_utf8(decoder.lp(1024)?).map_err(|_| Error::InvalidEncoding)?; + let network = NetworkUrn::new(network)?; + let path = core::str::from_utf8(decoder.lp(1024)?).map_err(|_| Error::InvalidEncoding)?; + let path = KeyPath::new(path)?; + let version = KeyVersion::new(decoder.u64()?); + let nonce = if mode == RECORD { + Some(WriteNonce::new(decoder.fixed()?)) + } else { + None + }; + decoder.finish()?; + Ok(Self { + network, + path, + version, + nonce, + bytes: bytes.to_vec(), + }) + } + + /// Borrow the network encoded in this identity. + pub fn network(&self) -> &NetworkUrn { + &self.network + } + + /// Borrow the exact canonical identity bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// Borrow the encoded key path. + pub fn key_path(&self) -> &KeyPath { + &self.path + } + + /// Return the encoded key version. + pub fn key_version(&self) -> KeyVersion { + self.version + } + + /// Borrow the write nonce in record mode, or return `None` in session mode. + pub fn nonce(&self) -> Option<&WriteNonce> { + self.nonce.as_ref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + fn encoded(mode: u8, network: &[u8], path: &[u8]) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&26u32.to_be_bytes()); + bytes.extend_from_slice(b"TC-THRESHOLD-V1-IBE-ID-ENC"); + bytes.extend_from_slice(&[1, mode]); + bytes.extend_from_slice(&(network.len() as u32).to_be_bytes()); + bytes.extend_from_slice(network); + bytes.extend_from_slice(&(path.len() as u32).to_be_bytes()); + bytes.extend_from_slice(path); + bytes.extend_from_slice(&7u64.to_be_bytes()); + if mode == 1 { + bytes.extend_from_slice(&[9; 16]); + } + bytes + } + + pub(super) fn identity( + record: bool, + network: &str, + path: &str, + version: u64, + nonce: [u8; 16], + ) -> IbeIdentity { + let network = NetworkUrn::new(network).unwrap(); + let path = KeyPath::new(path).unwrap(); + if record { + IbeIdentity::record( + &network, + &path, + KeyVersion::new(version), + &WriteNonce::new(nonce), + ) + .unwrap() + } else { + IbeIdentity::session(&network, &path, KeyVersion::new(version)).unwrap() + } + } + + #[test] + fn frozen_record_and_session_encoding_and_accessors() { + for record in [false, true] { + let id = identity(record, "net", "path", 7, [9; 16]); + assert_eq!( + id.as_bytes(), + encoded(if record { 1 } else { 2 }, b"net", b"path") + ); + assert_eq!(IbeIdentity::decode(id.as_bytes()), Ok(id.clone())); + assert_eq!(id.network().as_str(), "net"); + assert_eq!(id.key_path().as_str(), "path"); + assert_eq!(id.key_version(), KeyVersion::new(7)); + assert_eq!( + id.nonce().map(WriteNonce::as_bytes), + record.then_some(&[9; 16]) + ); + } + } + + #[test] + fn adversarial_field_splits_are_distinct_or_refused() { + assert_eq!(KeyPath::new(""), Err(Error::InvalidLength)); + assert!(IbeIdentity::decode(&encoded(2, b"a/b", b"")).is_err()); + let a = identity(false, "a/b", "c", 7, [9; 16]); + let b = identity(false, "a", "/bc", 7, [9; 16]); + assert_ne!(a.as_bytes(), b.as_bytes()); + assert_eq!( + IbeIdentity::decode(&encoded(2, b"a", b"/b")) + .unwrap() + .key_path() + .as_str(), + "/b" + ); + } + + #[test] + fn identity_decoder_rejects_malformed_fields() { + let valid = identity(true, "net", "path", 7, [9; 16]); + let bytes = valid.as_bytes(); + for cut in 0..bytes.len() { + assert!(IbeIdentity::decode(&bytes[..cut]).is_err()); + } + let mut trailing = bytes.to_vec(); + trailing.push(0); + assert_eq!(IbeIdentity::decode(&trailing), Err(Error::InvalidEncoding)); + let prefix_end = 4 + b"TC-THRESHOLD-V1-IBE-ID-ENC".len(); + for (offset, value) in [ + (4, b'X'), + (prefix_end, 2), + (prefix_end + 1, 0), + (prefix_end + 1, 3), + ] { + let mut bad = bytes.to_vec(); + bad[offset] = value; + assert_eq!(IbeIdentity::decode(&bad), Err(Error::InvalidEncoding)); + } + assert_eq!( + IbeIdentity::decode(&encoded(2, &[0xff], b"p")), + Err(Error::InvalidEncoding) + ); + assert_eq!( + IbeIdentity::decode(&encoded(2, b"n", &[0xff])), + Err(Error::InvalidEncoding) + ); + assert_eq!( + IbeIdentity::decode(&encoded(2, b"", b"p")), + Err(Error::InvalidLength) + ); + assert_eq!( + IbeIdentity::decode(&encoded(2, b"n", b"")), + Err(Error::InvalidLength) + ); + assert_eq!( + IbeIdentity::decode(&encoded(2, &vec![b'n'; 1025], b"p")), + Err(Error::LimitExceeded) + ); + for offset in [0, prefix_end + 2, prefix_end + 2 + 4 + 3] { + let mut bad = bytes.to_vec(); + bad[offset..offset + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(IbeIdentity::decode(&bad), Err(Error::LimitExceeded)); + } + assert_eq!( + IbeIdentity::decode(&vec![0; 65537]), + Err(Error::LimitExceeded) + ); + } + + proptest! { + #[test] + fn identity_random_roundtrip_and_injectivity( + n in ".{1,24}", p in ".{1,24}", n2 in ".{1,24}", p2 in ".{1,24}", + v in any::(), v2 in any::(), + nonce in any::<[u8; 16]>(), nonce2 in any::<[u8; 16]>(), + record in any::(), record2 in any::(), + ) { + let a = identity(record, &n, &p, v, nonce); + let b = identity(record2, &n2, &p2, v2, nonce2); + prop_assert_eq!(IbeIdentity::decode(a.as_bytes()), Ok(a.clone())); + prop_assert_eq!(a.as_bytes() == b.as_bytes(), + n == n2 && p == p2 && v == v2 && record == record2 && (!record || nonce == nonce2)); + } + + #[test] + fn identity_random_bytes_reject_or_canonical(bytes in prop::collection::vec(any::(), 0..70000)) { + if let Ok(id) = IbeIdentity::decode(&bytes) { + prop_assert_eq!(id.as_bytes(), bytes.as_slice()); + prop_assert!((1..=1024).contains(&id.network().as_bytes().len())); + prop_assert!((1..=1024).contains(&id.key_path().as_bytes().len())); + } + } + + #[test] + fn identity_structured_mutations_reject_or_canonical( + n in ".{1,24}", p in ".{1,24}", v in any::(), nonce in any::<[u8;16]>(), + record in any::(), offset in any::(), replacement in any::(), mutation in 0u8..3, + ) { + let id = identity(record, &n, &p, v, nonce); + let mut bytes = id.as_bytes().to_vec(); + let position = offset % bytes.len(); + match mutation { + 0 => bytes[position] = replacement, + 1 => { bytes.remove(position); }, + _ => bytes.insert(position, replacement), + } + if let Ok(decoded) = IbeIdentity::decode(&bytes) { + prop_assert_eq!(decoded.as_bytes(), bytes.as_slice()); + prop_assert_eq!(IbeIdentity::decode(decoded.as_bytes()), Ok(decoded.clone())); + } + } + } +} + +#[cfg(test)] +mod split_properties { + use super::tests::identity; + use proptest::prelude::*; + + proptest! { + #[test] + fn moving_separator_between_fields_never_collides(n in "[a-z]{1,24}", p in "[a-z]{1,24}") { + let a = identity(false, &format!("{n}/"), &p, 7, [0;16]); + let b = identity(false, &n, &format!("/{p}"), 7, [0;16]); + prop_assert_ne!(a.as_bytes(), b.as_bytes()); + } + } +} + +#[cfg(test)] +mod boundary_tests { + use super::*; + + #[test] + fn maximum_utf8_fields_and_integer_values_roundtrip_in_both_modes() { + let network = NetworkUrn::new("é".repeat(512)).unwrap(); + let path = KeyPath::new("p".repeat(1024)).unwrap(); + let version = KeyVersion::new(u64::MAX); + let nonce = WriteNonce::new([u8::MAX; 16]); + for id in [ + IbeIdentity::record(&network, &path, version, &nonce).unwrap(), + IbeIdentity::session(&network, &path, version).unwrap(), + ] { + assert_eq!(IbeIdentity::decode(id.as_bytes()), Ok(id)); + } + } +} diff --git a/tinycloud-threshold/src/ids.rs b/tinycloud-threshold/src/ids.rs new file mode 100644 index 00000000..ade7f830 --- /dev/null +++ b/tinycloud-threshold/src/ids.rs @@ -0,0 +1,275 @@ +//! Bounded identifiers used in the version-one encodings. + +use core::num::NonZeroU16; + +use crate::error::Error; + +/// A Shamir evaluation coordinate in the supported range `1..=16`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MemberIndex(NonZeroU16); + +impl MemberIndex { + /// Reject zero and indices beyond the maximum cohort size. + pub fn new(value: u16) -> Result { + if value > 16 { + return Err(Error::InvalidIndex); + } + NonZeroU16::new(value).map(Self).ok_or(Error::InvalidIndex) + } + + /// Return the nonzero evaluation coordinate. + pub fn get(&self) -> u16 { + self.0.get() + } +} + +/// A threshold and cohort size satisfying `2 <= t <= n <= 16`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Threshold { + t: u16, + n: u16, +} + +impl Threshold { + /// Validate a threshold for decryption and resharing. + pub fn new(t: u16, n: u16) -> Result { + if t < 2 || t > n || n > 16 { + return Err(Error::InvalidThreshold); + } + Ok(Self { t, n }) + } + + /// Return the number of distinct shares needed. + pub fn t(&self) -> u16 { + self.t + } + + /// Return the cohort size. + pub fn n(&self) -> u16 { + self.n + } + + /// Require the additional genesis-DKG condition `n >= 2t - 1`. + /// + /// A threshold valid for resharing need not satisfy this condition. + pub fn check_genesis_budget(&self) -> Result<(), Error> { + let required = self + .t + .checked_sub(1) + .and_then(|faults| self.t.checked_add(faults)) + .ok_or(Error::UnsupportedFaultBudget)?; + if self.n < required { + return Err(Error::UnsupportedFaultBudget); + } + Ok(()) + } +} + +macro_rules! text_id { + ($name:ident, $cap:literal, $description:literal) => { + #[doc = $description] + #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] + pub struct $name(String); + + impl $name { + /// Preserve the supplied UTF-8 exactly, rejecting empty or oversized values. + /// + /// Limits are measured in bytes; no normalization or syntax rewriting is applied. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(Error::InvalidLength); + } + if value.len() > $cap { + return Err(Error::LimitExceeded); + } + Ok(Self(value)) + } + + /// Borrow the exact supplied text. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Borrow the exact UTF-8 representation. + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + } + }; +} + +text_id!( + NetworkUrn, + 1024, + "A network URN containing 1 through 1024 UTF-8 bytes." +); +text_id!( + KeyPath, + 1024, + "A key path containing 1 through 1024 UTF-8 bytes." +); +text_id!(Did, 256, "A DID containing 1 through 256 UTF-8 bytes."); + +macro_rules! counter { + ($name:ident, $description:literal) => { + #[doc = $description] + #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] + pub struct $name(u64); + + impl $name { + /// Construct a counter with its exact numeric value. + pub fn new(value: u64) -> Self { + Self(value) + } + + /// Return the numeric value. + pub fn get(&self) -> u64 { + self.0 + } + + /// Increment by one, refusing to wrap at `u64::MAX`. + pub fn checked_next(&self) -> Result { + self.0 + .checked_add(1) + .map(Self) + .ok_or(Error::CounterOverflow) + } + } + }; +} + +counter!(Epoch, "A monotonically increasing key epoch."); +counter!( + RekeyCounter, + "A counter incremented when the master key changes." +); +counter!( + KeyVersion, + "The application-supplied version of an identity's key path." +); + +macro_rules! fixed_id { + ($name:ident, $length:literal, $description:literal) => { + #[doc = $description] + #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] + pub struct $name([u8; $length]); + + impl $name { + /// Construct from the exact fixed-width representation. + pub fn new(bytes: [u8; $length]) -> Self { + Self(bytes) + } + + /// Borrow the fixed-width representation. + pub fn as_bytes(&self) -> &[u8; $length] { + &self.0 + } + } + }; +} + +fixed_id!(WriteNonce, 16, "A record identity's per-write nonce."); +fixed_id!(CertDigest, 32, "A network-certificate digest."); +fixed_id!(TranscriptHash, 32, "A ceremony transcript hash."); +fixed_id!(RequestDigest, 32, "A decryption-request digest."); +fixed_id!(CeremonyId, 32, "A ceremony's fixed-width identifier."); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn member_indices_and_threshold_limits() { + assert_eq!(MemberIndex::new(0), Err(Error::InvalidIndex)); + assert_eq!(MemberIndex::new(17), Err(Error::InvalidIndex)); + assert_eq!(MemberIndex::new(u16::MAX), Err(Error::InvalidIndex)); + for i in 1..=16 { + assert_eq!(MemberIndex::new(i).unwrap().get(), i); + } + for (t, n) in [(0, 3), (1, 3), (4, 3), (2, 17), (16, u16::MAX)] { + assert_eq!(Threshold::new(t, n), Err(Error::InvalidThreshold)); + } + for n in 2..=16 { + for t in 2..=n { + let threshold = Threshold::new(t, n).unwrap(); + assert_eq!((threshold.t(), threshold.n()), (t, n)); + assert_eq!(threshold.check_genesis_budget().is_ok(), n >= 2 * t - 1); + } + } + assert_eq!( + Threshold::new(3, 4).unwrap().check_genesis_budget(), + Err(Error::UnsupportedFaultBudget) + ); + assert_eq!(Threshold::new(3, 5).unwrap().check_genesis_budget(), Ok(())); + } + + #[test] + fn text_limits_are_utf8_byte_limits_without_normalization() { + assert_eq!(NetworkUrn::new(""), Err(Error::InvalidLength)); + assert_eq!(KeyPath::new(""), Err(Error::InvalidLength)); + assert_eq!(Did::new(""), Err(Error::InvalidLength)); + assert_eq!(NetworkUrn::new("n".repeat(1025)), Err(Error::LimitExceeded)); + assert_eq!(KeyPath::new("p".repeat(1025)), Err(Error::LimitExceeded)); + assert_eq!(Did::new("d".repeat(257)), Err(Error::LimitExceeded)); + assert!(NetworkUrn::new("é".repeat(512)).is_ok()); + assert_eq!(KeyPath::new("é".repeat(513)), Err(Error::LimitExceeded)); + assert!(Did::new("é".repeat(128)).is_ok()); + assert_eq!(Did::new("é".repeat(129)), Err(Error::LimitExceeded)); + let path = KeyPath::new("a/../b\0").unwrap(); + assert_eq!(path.as_str(), "a/../b\0"); + assert_eq!(path.as_bytes(), b"a/../b\0"); + assert_eq!(NetworkUrn::new("n").unwrap().as_str(), "n"); + assert_eq!(Did::new("d").unwrap().as_bytes(), b"d"); + } + + #[test] + fn counters_increment_and_refuse_overflow() { + assert_eq!(Epoch::new(0).checked_next().unwrap().get(), 1); + assert_eq!(RekeyCounter::new(7).checked_next().unwrap().get(), 8); + assert_eq!(KeyVersion::new(42).checked_next().unwrap().get(), 43); + assert_eq!( + Epoch::new(u64::MAX).checked_next(), + Err(Error::CounterOverflow) + ); + assert_eq!( + RekeyCounter::new(u64::MAX).checked_next(), + Err(Error::CounterOverflow) + ); + assert_eq!( + KeyVersion::new(u64::MAX).checked_next(), + Err(Error::CounterOverflow) + ); + } + + #[test] + fn fixed_identifiers_preserve_every_byte() { + assert_eq!(WriteNonce::new([0xff; 16]).as_bytes(), &[0xff; 16]); + assert_eq!(CertDigest::new([1; 32]).as_bytes(), &[1; 32]); + assert_eq!(TranscriptHash::new([2; 32]).as_bytes(), &[2; 32]); + assert_eq!(RequestDigest::new([3; 32]).as_bytes(), &[3; 32]); + assert_eq!(CeremonyId::new([4; 32]).as_bytes(), &[4; 32]); + } +} + +#[cfg(test)] +mod properties { + use super::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn arbitrary_indices_and_thresholds_match_bounds(index in any::(), t in any::(), n in any::()) { + prop_assert_eq!(MemberIndex::new(index).is_ok(), (1..=16).contains(&index)); + prop_assert_eq!(Threshold::new(t, n).is_ok(), (2..=16).contains(&t) && t <= n && n <= 16); + } + + #[test] + fn arbitrary_counters_use_checked_increment(value in any::()) { + let expected = value.checked_add(1); + prop_assert_eq!(Epoch::new(value).checked_next().ok().map(|v| v.get()), expected); + prop_assert_eq!(RekeyCounter::new(value).checked_next().ok().map(|v| v.get()), expected); + prop_assert_eq!(KeyVersion::new(value).checked_next().ok().map(|v| v.get()), expected); + } + } +} diff --git a/tinycloud-threshold/src/lib.rs b/tinycloud-threshold/src/lib.rs new file mode 100644 index 00000000..d93316ce --- /dev/null +++ b/tinycloud-threshold/src/lib.rs @@ -0,0 +1,39 @@ +//! Context-bound threshold key derivation and identity-based key wrapping. +//! The workspace does not connect this crate to the node runtime. +#![deny(unsafe_code)] +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::arithmetic_side_effects, + clippy::unreachable, + clippy::todo + ) +)] + +pub mod context; +#[allow(unsafe_code)] +pub mod curve; +pub mod derive; +pub mod dst; +mod encoding; +pub mod error; +pub mod ibe; +pub mod identity; +pub mod ids; +mod poly; + +pub use context::{AuthorizedDerivation, KeyContext}; +pub use derive::*; +pub use error::{CombineError, Error, ShareError}; +pub use ibe::*; +pub use identity::IbeIdentity; +pub use ids::*; + +#[cfg(test)] +mod checkpoint; +#[cfg(test)] +mod vectors; diff --git a/tinycloud-threshold/src/poly.rs b/tinycloud-threshold/src/poly.rs new file mode 100644 index 00000000..643deed7 --- /dev/null +++ b/tinycloud-threshold/src/poly.rs @@ -0,0 +1,25 @@ +//! Scalar-field interpolation at x=0. +use crate::{curve::Scalar, Error, MemberIndex}; + +pub(crate) fn lagrange( + index: MemberIndex, + indices: impl Iterator, +) -> Result { + let x = Scalar::from_u64(u64::from(index.get())); + let mut numerator = Scalar::from_u64(1); + let mut denominator = Scalar::from_u64(1); + for other in indices.filter(|j| *j != index) { + let y = Scalar::from_u64(u64::from(other.get())); + numerator = numerator.mul(&y); + denominator = denominator.mul(&y.sub(&x)); + } + Ok(numerator.mul(&denominator.inverse()?)) +} + +#[cfg(test)] +pub(crate) fn evaluate(coefficients: &[Scalar], x: &Scalar) -> Scalar { + coefficients + .iter() + .rev() + .fold(Scalar::from_u64(0), |value, c| value.mul(x).add(c)) +} diff --git a/tinycloud-threshold/src/vectors.rs b/tinycloud-threshold/src/vectors.rs new file mode 100644 index 00000000..a3cccabb --- /dev/null +++ b/tinycloud-threshold/src/vectors.rs @@ -0,0 +1,96 @@ +//! Deterministic public fixtures; all scalar/seed inputs here are test material. +use super::*; +use crate::{ + checkpoint::{authorize, context, dealer, identity}, + curve::{hash_g1, hash_g2, pairing, Scalar, G1, G2}, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::{RngCore, SeedableRng}; +use serde_json::{json, Value}; + +fn primitives() -> Value { + let ctx = context(); + let (public, shares, master) = dealer(71, ctx.clone()); + let id = identity(&ctx, "records/vector"); + let mut transport_rng = ChaCha20Rng::from_seed([72; 32]); + let tsk = TransportSecretKey::generate(&mut transport_rng); + let req = DecryptRequest::new(&ctx, id.clone(), tsk.public_key()).unwrap(); + let auth = authorize(req.clone()); + let mut share_rng = ChaCha20Rng::from_seed([73; 32]); + let issued: Vec<_> = shares + .iter() + .map(|s| issue_share(s, &auth, &mut share_rng).unwrap()) + .collect(); + let aggregate = combine(&public, &req, &issued[..2]).unwrap(); + let derived = decrypt_derived_key(&aggregate, &tsk, &public, &req).unwrap(); + let data = DataKey::new([0x34; 32]); + let mut sigma = [0; 32]; + ChaCha20Rng::from_seed([74; 32]).fill_bytes(&mut sigma); + let ct = wrap_key( + public.master_public_key(), + &id, + &data, + &mut ChaCha20Rng::from_seed([74; 32]), + ) + .unwrap(); + let gt = pairing(&G1::generator(), &G2::generator()).encode(); + let gt2 = pairing( + &G1::generator().mul(&Scalar::from_u64(5)), + &G2::generator().mul(&Scalar::from_u64(7)), + ) + .encode(); + let h3_input = [0x25; 64]; + let public_shares: Vec<_> = public + .public_shares + .iter() + .map(|(i, p)| json!({"member":i.get(),"point":hex::encode(p.encode())})) + .collect(); + let scalar_shares: Vec<_> = shares + .iter() + .map(|s| json!({"member":s.index().get(),"scalar":hex::encode(s.scalar.to_bytes())})) + .collect(); + let encrypted_shares:Vec<_>=issued.iter().map(|s|json!({"member":s.index().get(),"encoded":hex::encode(s.encode()),"c1":hex::encode(s.c1.encode()),"c2":hex::encode(s.c2.encode()),"c3":hex::encode(s.c3.encode()),"signing_preimage":hex::encode(s.signing_preimage(&req))})).collect(); + let mut bad_share = issued[0].clone(); + bad_share.c3 = bad_share.c3.add(&G1::generator()); + let mut wrong_context = issued[0].clone(); + wrong_context.context_digest = [0; 32]; + let mut wrong_request = req.clone(); + wrong_request.identity = identity(&ctx, "records/other"); + let mut cases = vec![ + json!({"name":"h1_custom_dst","operation":"hash_g1","inputs":{"message":hex::encode(id.as_bytes()),"dst":String::from_utf8_lossy(dst::H1)},"outputs":{"point":hex::encode(hash_g1(id.as_bytes(),dst::H1).encode())}}), + json!({"name":"pedersen_h","operation":"hash_g2","inputs":{"message":hex::encode(b"pedersen-h"),"dst":String::from_utf8_lossy(dst::PEDERSEN_H)},"outputs":{"point":hex::encode(hash_g2(b"pedersen-h",dst::PEDERSEN_H).encode())}}), + json!({"name":"h3_scalar_r","operation":"h3","inputs":{"message":hex::encode(h3_input)},"outputs":{"scalar":hex::encode(dst::h3(&h3_input).to_bytes())}}), + json!({"name":"h2_gt","operation":"h2","inputs":{"message":hex::encode(gt)},"outputs":{"bytes":hex::encode(dst::h2(>))}}), + json!({"name":"h4_sigma","operation":"h4","inputs":{"message":hex::encode(sigma)},"outputs":{"bytes":hex::encode(dst::h4(&sigma))}}), + json!({"name":"pairing_generators","operation":"pairing","inputs":{"g1":hex::encode(G1::generator().encode()),"g2":hex::encode(G2::generator().encode())},"outputs":{"gt":hex::encode(gt)}}), + json!({"name":"pairing_5_7","operation":"pairing","inputs":{"g1":hex::encode(G1::generator().mul(&Scalar::from_u64(5)).encode()),"g2":hex::encode(G2::generator().mul(&Scalar::from_u64(7)).encode())},"outputs":{"gt":hex::encode(gt2)}}), + json!({"name":"fullident_wrap_unwrap","operation":"fullident","inputs":{"master_scalar":hex::encode(master.to_bytes()),"mpk":hex::encode(public.master_public_key().encode()),"identity":hex::encode(id.as_bytes()),"message":hex::encode(data.as_bytes()),"sigma":hex::encode(sigma)},"outputs":{"ciphertext":hex::encode(ct.encode()),"derived_key":hex::encode(derived.key.encode()),"unwrapped":hex::encode(unwrap_key(&derived,&id,&ct).unwrap().as_bytes())}}), + json!({"name":"threshold_2of3","operation":"threshold","inputs":{"context":hex::encode(ctx.encode()),"context_digest":hex::encode(ctx.digest()),"certificate_digest":hex::encode(ctx.certificate().as_bytes()),"threshold":2,"members":3,"master_scalar":hex::encode(master.to_bytes()),"mpk":hex::encode(public.master_public_key().encode()),"public_shares":public_shares,"scalar_shares":scalar_shares,"transport_scalar":hex::encode(tsk.0.to_bytes()),"transport_public":hex::encode(tsk.public_key().encode()),"identity":hex::encode(id.as_bytes()),"request":hex::encode(req.encode()),"request_digest":hex::encode(req.digest().as_bytes())},"outputs":{"encrypted_shares":encrypted_shares,"selected_members":[1,2],"aggregate":{"c1":hex::encode(aggregate.c1.encode()),"c2":hex::encode(aggregate.c2.encode()),"c3":hex::encode(aggregate.c3.encode())},"derived_key":hex::encode(derived.key.encode())}}), + json!({"name":"noncanonical_scalar","operation":"decode_scalar","inputs":{"bytes":"73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001"},"error":"InvalidScalar"}), + json!({"name":"identity_transport","operation":"decode_transport","inputs":{"bytes":hex::encode(G1::identity().encode())},"error":"IdentityPoint"}), + json!({"name":"identity_mpk","operation":"decode_mpk","inputs":{"bytes":hex::encode(G2::identity().encode())},"error":"IdentityPoint"}), + json!({"name":"share_c3_mutated","operation":"verify_share","inputs":{"reference":"threshold_2of3","share":hex::encode(bad_share.encode())},"error":"Invalid","member":1}), + json!({"name":"share_context_transplanted","operation":"verify_share","inputs":{"reference":"threshold_2of3","share":hex::encode(wrong_context.encode())},"error":"ContextMismatch"}), + json!({"name":"share_identity_transplanted","operation":"verify_share","inputs":{"reference":"threshold_2of3","request":hex::encode(wrong_request.encode()),"share":hex::encode(issued[0].encode())},"error":"ContextMismatch"}), + ]; + for field in ["u", "v", "w"] { + let mut bad = ct.clone(); + match field { + "u" => bad.u = bad.u.add(&G2::generator()), + "v" => bad.v[0] ^= 1, + _ => bad.w[0] ^= 1, + }; + cases.push(json!({"name":format!("fullident_{field}_mutated"),"operation":"unwrap","inputs":{"reference":"fullident_wrap_unwrap","ciphertext":hex::encode(bad.encode())},"error":"InvalidCiphertext"})); + } + json!({"format_version":1,"suite":"BLS12-381 / RFC9380 SHA-256 SSWU_RO / FullIdent / encrypted threshold shares","spec_sections":["3.2","3.3","3.4","3.5","3.6 + request-digest amendment"],"encoding":"hex; GT coefficients in SPEC 3.2 order","seeds":{"dealer":hex::encode([71;32]),"transport":hex::encode([72;32]),"shares":hex::encode([73;32]),"wrap":hex::encode([74;32])},"dst":{"h1":String::from_utf8_lossy(dst::H1),"pedersen_h":String::from_utf8_lossy(dst::PEDERSEN_H),"h2":String::from_utf8_lossy(dst::H2),"h3":String::from_utf8_lossy(dst::H3),"h4":String::from_utf8_lossy(dst::H4)},"cases":cases}) +} + +#[test] +fn primitive_vectors_regenerate_byte_for_byte() { + let encoded = format!("{}\n", serde_json::to_string_pretty(&primitives()).unwrap()); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("vectors/v1/primitives.json"); + if std::env::var("TC_THRESHOLD_REGEN_VECTORS").as_deref() == Ok("1") { + std::fs::write(&path, &encoded).unwrap(); + } + assert_eq!(std::fs::read_to_string(path).unwrap(), encoded); +} diff --git a/tinycloud-threshold/vectors/v1/primitives.json b/tinycloud-threshold/vectors/v1/primitives.json new file mode 100644 index 00000000..b10bd357 --- /dev/null +++ b/tinycloud-threshold/vectors/v1/primitives.json @@ -0,0 +1,281 @@ +{ + "cases": [ + { + "inputs": { + "dst": "TC-THRESHOLD-V1-IBE-ID_BLS12381G1_XMD:SHA-256_SSWU_RO_", + "message": "0000001a54432d5448524553484f4c442d56312d4942452d49442d454e4301010000001674696e79636c6f75643a746573743a6e6574776f726b0000000e7265636f7264732f766563746f72000000000000000409090909090909090909090909090909" + }, + "name": "h1_custom_dst", + "operation": "hash_g1", + "outputs": { + "point": "93b37c71a6363baf579645073749236841dcc8cda8755a62dba85fd7f75f600419dd99c35fa376d194a759afa9d97a25" + } + }, + { + "inputs": { + "dst": "TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_", + "message": "706564657273656e2d68" + }, + "name": "pedersen_h", + "operation": "hash_g2", + "outputs": { + "point": "94627aa0b2bce848142b56a35090ecb7a5a19ec3a0fae70d21b92a043b9ddaf8578225e2e8487d80933014ca5018989115b860e8a915cdfc59726aeb4230fcf1978d1711de9ddd3d8425cce304816bbd4e48702308ac3400b6b6e06a07c1b0ca" + } + }, + { + "inputs": { + "message": "25252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525252525" + }, + "name": "h3_scalar_r", + "operation": "h3", + "outputs": { + "scalar": "6562e6c9736924c15933aa14a67844f0eb4fbe368b4fc511b55dee1c79132f8f" + } + }, + { + "inputs": { + "message": "1250ebd871fc0a92a7b2d83168d0d727272d441befa15c503dd8e90ce98db3e7b6d194f60839c508a84305aaca1789b6089a1c5b46e5110b86750ec6a532348868a84045483c92b7af5af689452eafabf1a8943e50439f1d59882a98eaa0170f1368bb445c7c2d209703f239689ce34c0378a68e72a6b3b216da0e22a5031b54ddff57309396b38c881c4c849ec23e87193502b86edb8857c273fa075a50512937e0794e1e65a7617c90d8bd66065b1fffe51d7a579973b1315021ec3c19934f01b2f522473d171391125ba84dc4007cfbf2f8da752f7c74185203fcca589ac719c34dffbbaad8431dad1c1fb597aaa5018107154f25a764bd3c79937a45b84546da634b8f6be14a8061e55cceba478b23f7dacaa35c8ca78beae9624045b4b619f26337d205fb469cd6bd15c3d5a04dc88784fbb3d0b2dbdea54d43b2b73f2cbb12d58386a8703e0f948226e47ee89d06fba23eb7c5af0d9f80940ca771b6ffd5857baaf222eb95a7d2809d61bfe02e1bfd1b68ff02f0b8102ae1c2d5d5ab1a11b8b424cd48bf38fcef68083b0b0ec5c81a93b330ee1a677d0d15ff7b984e8978ef48881e32fac91b93b47333e2ba5703350f55a7aefcd3c31b4fcb6ce5771cc6a0e9786ab5973320c806ad360829107ba810c5a09ffdd9be2291a0c25a99a204c581234d086a9902249b64728ffd21a189e87935a954051c7cdba7b3872629a4fafc05066245cb9108f0242d0fe3ef0f41e58663bf08cf068672cbd01a7ec73baca4d72ca93544deff686bfd6df543d48eaa24afe47e1efde449383b676631" + }, + "name": "h2_gt", + "operation": "h2", + "outputs": { + "bytes": "3eb5c3a07cfd96b7f30e71b77b6238d649d516542ec28f122b6f7afb9f7b002a" + } + }, + { + "inputs": { + "message": "d2443431e6d5c54a2ca4abde6bd8a04f6cf5a0472a16243a2ceb315d64624e69" + }, + "name": "h4_sigma", + "operation": "h4", + "outputs": { + "bytes": "666aa1b51450c22a1b90f0f1d6eed59a63c7fea5bd287edd0aafc49d115354d7" + } + }, + { + "inputs": { + "g1": "97f1d3a73197d7942695638c4fa9ac0fc3688c4f9774b905a14e3a3f171bac586c55e83ff97a1aeffb3af00adb22c6bb", + "g2": "93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8" + }, + "name": "pairing_generators", + "operation": "pairing", + "outputs": { + "gt": "1250ebd871fc0a92a7b2d83168d0d727272d441befa15c503dd8e90ce98db3e7b6d194f60839c508a84305aaca1789b6089a1c5b46e5110b86750ec6a532348868a84045483c92b7af5af689452eafabf1a8943e50439f1d59882a98eaa0170f1368bb445c7c2d209703f239689ce34c0378a68e72a6b3b216da0e22a5031b54ddff57309396b38c881c4c849ec23e87193502b86edb8857c273fa075a50512937e0794e1e65a7617c90d8bd66065b1fffe51d7a579973b1315021ec3c19934f01b2f522473d171391125ba84dc4007cfbf2f8da752f7c74185203fcca589ac719c34dffbbaad8431dad1c1fb597aaa5018107154f25a764bd3c79937a45b84546da634b8f6be14a8061e55cceba478b23f7dacaa35c8ca78beae9624045b4b619f26337d205fb469cd6bd15c3d5a04dc88784fbb3d0b2dbdea54d43b2b73f2cbb12d58386a8703e0f948226e47ee89d06fba23eb7c5af0d9f80940ca771b6ffd5857baaf222eb95a7d2809d61bfe02e1bfd1b68ff02f0b8102ae1c2d5d5ab1a11b8b424cd48bf38fcef68083b0b0ec5c81a93b330ee1a677d0d15ff7b984e8978ef48881e32fac91b93b47333e2ba5703350f55a7aefcd3c31b4fcb6ce5771cc6a0e9786ab5973320c806ad360829107ba810c5a09ffdd9be2291a0c25a99a204c581234d086a9902249b64728ffd21a189e87935a954051c7cdba7b3872629a4fafc05066245cb9108f0242d0fe3ef0f41e58663bf08cf068672cbd01a7ec73baca4d72ca93544deff686bfd6df543d48eaa24afe47e1efde449383b676631" + } + }, + { + "inputs": { + "g1": "b0e7791fb972fe014159aa33a98622da3cdc98ff707965e536d8636b5fcc5ac7a91a8c46e59a00dca575af0f18fb13dc", + "g2": "8d0273f6bf31ed37c3b8d68083ec3d8e20b5f2cc170fa24b9b5be35b34ed013f9a921f1cad1644d4bdb14674247234c8049cd1dbb2d2c3581e54c088135fef36505a6823d61b859437bfc79b617030dc8b40e32bad1fa85b9c0f368af6d38d3c" + }, + "name": "pairing_5_7", + "operation": "pairing", + "outputs": { + "gt": "016cab6a2a187ac996d062aad5f25c52d9211493b1434398d964368cd5ec1de2ddc196fcbc56f2a194a6fb8f256085e60ba88e19d2045e8a7dbb1dc59d5d8edb68e1ee7a795eab19ca9afc2741d7bdc3667114b5e80d630d71cb34873066df1311be383ae281b84132b65252f7d909d7dd38df6442499e0bd42afe38b2b7565b2d4060a8d6dc956cec721d6cbcf80afe12d1178da6bc5e1724cc1259556b60896df6f840279c26ba15a8b2fa8bc8bf998edb9f201c848fb1dfcf66312c13ef4700664b432d8a8a6a02dd38881449f1cd760080aedd769b4fa38e1826c10454c6b2634531f7a962e451398317a5e2496713696d0422f7122122fc4a6946d757b7a865ceb7441357744d379e00291ad5e80a43f17347acfde6fdd94ecd7bdce84901ca814ff7083094975f2186a643146a7440e3e451905945743752b1401bc11a377d49af8939df3388152e89ad3d7f8e14b3f4389d9347fa57f8cb1036f6a43936bc5c5fd3c36230112ea895bd6c45a554327d63d2125c19a887d30b3c6f64ef173f8d588daf92c713fccb463c052c8359021710b2b03fc987305afc2438e723104abcc268c48a2e2583a02bd555916e0b40356ba88972c575266d95cc1c8fd1615cdbbec72de888d956f0a2cfffa74e90f20b78fee5c08a55863e45f606dda900e4919a80ad50e8284b62cc2c32de5a34ff7024c9417943c38623e6cca63503092558c4d7cc575d4ce8936f3d20e92f16dd215f3d476c02356ee7741f7ced89d248a7c4ad28b374337e3b50eab071aeed42fe13cc7cde247c801bc05d563943" + } + }, + { + "inputs": { + "identity": "0000001a54432d5448524553484f4c442d56312d4942452d49442d454e4301010000001674696e79636c6f75643a746573743a6e6574776f726b0000000e7265636f7264732f766563746f72000000000000000409090909090909090909090909090909", + "master_scalar": "68a16d6c76472e532cb05e41ea64d26e62c55253363e777be780baa394f53c38", + "message": "3434343434343434343434343434343434343434343434343434343434343434", + "mpk": "86023b0755368bc6cdb508818dd482fcb0be819d4501485cf9263f5b5adffb506abab90b75531ce6c8b89bca18a2a54f06258b3183edd7242a6917c73fcee0340b5b121d703cae3274e516de150174bba8a37ca52b6491382efab071d0d59eb1", + "sigma": "d2443431e6d5c54a2ca4abde6bd8a04f6cf5a0472a16243a2ceb315d64624e69" + }, + "name": "fullident_wrap_unwrap", + "operation": "fullident", + "outputs": { + "ciphertext": "a0487090e537cae158abb448c457ca7fc5f34c3aa1507a538283b738953ee8468642d362483c71e93c9d01285e9ed0ca07ce1e8aa4694817427c52e9211aa687ceab72c28324473f5ad09ade7faab27a139d59c32a19804db55535a52e1da83a25fb797e5b3cfa2c8265aa685ade9b3c76de1182d6143a87fc87671a30e6114e525e95812064f61e2fa4c4c5e2dae1ae57f3ca91891c4ae93e9bf0a9256760e3", + "derived_key": "b0088e2497ad67828f40db8aabe22819d7dd2fc3f20d23123e6b3cbddf6f7133d767f04aa6e51b9178e558287f548b71", + "unwrapped": "3434343434343434343434343434343434343434343434343434343434343434" + } + }, + { + "inputs": { + "certificate_digest": "0707070707070707070707070707070707070707070707070707070707070707", + "context": "010000001674696e79636c6f75643a746573743a6e6574776f726b00000000000000000000000000000000070707070707070707070707070707070707070707070707070707070707070700020003", + "context_digest": "a44e91f24f1d7cdd1704c2b1d966be8f84964eda7017666b418b573702e98a89", + "identity": "0000001a54432d5448524553484f4c442d56312d4942452d49442d454e4301010000001674696e79636c6f75643a746573743a6e6574776f726b0000000e7265636f7264732f766563746f72000000000000000409090909090909090909090909090909", + "master_scalar": "68a16d6c76472e532cb05e41ea64d26e62c55253363e777be780baa394f53c38", + "members": 3, + "mpk": "86023b0755368bc6cdb508818dd482fcb0be819d4501485cf9263f5b5adffb506abab90b75531ce6c8b89bca18a2a54f06258b3183edd7242a6917c73fcee0340b5b121d703cae3274e516de150174bba8a37ca52b6491382efab071d0d59eb1", + "public_shares": [ + { + "member": 1, + "point": "842a9879b9a95b50eb144a61d49c6a909e1fc0839fa70bdc5e5536568e29787c1155fd00b9d55e391f154fc792edec9816001943da0bd36715d8fde7f6f233404a39ed0aed61136a3320cfded8139a939db7dc515f83dc9625573c3676fb1c0f" + }, + { + "member": 2, + "point": "8a45856ed3023f6bc4d061a33b0a64d0bd94e05d0fc1cbc5f8f04a71340928df7413ea28c3488da9928e6ea0a4b73ce8042294fbc1fd721f95c57356431423a729d5239a76e503ade3461db6cd6cab3d5cefd6723fad57fba6e7961909b3c943" + }, + { + "member": 3, + "point": "b00cd899e70fd0f1ff4454af638a67efe46fa56ded629abf0bf4dc32b18798c9ae20d194a374bcba534e21f7b1932b241648df208e3e36e30eac4d8655d0a0394ebd0adefd1baec0a4a868ec072a6f9d9bf0e098616c438fab6bd3373868fae6" + } + ], + "request": "01a44e91f24f1d7cdd1704c2b1d966be8f84964eda7017666b418b573702e98a890000001674696e79636c6f75643a746573743a6e6574776f726b000000000000000000000000000000000707070707070707070707070707070707070707070707070707070707070707000000640000001a54432d5448524553484f4c442d56312d4942452d49442d454e4301010000001674696e79636c6f75643a746573743a6e6574776f726b0000000e7265636f7264732f766563746f72000000000000000409090909090909090909090909090909a6ae46dc9d2f6f2da55b6a12a5a0c46d75e2952429824c83466196bc9c56a34c32c7b955fd78d60b8a4bb3c9467440c1", + "request_digest": "3af0788b84cd207cd5deb118e61e96a632130a5964b58fc98e4e584da5511f12", + "scalar_shares": [ + { + "member": 1, + "scalar": "328840dec627b115243dd4213f278034bba24ab5fee884e2e54ad2d58a98db3f" + }, + { + "member": 2, + "scalar": "705cbba43fa5b11f4f0522089d8c0600683ce71bc790ee48e314eb06803c7a47" + }, + { + "member": 3, + "scalar": "3a438f168f8633e1469297e7f24eb3c6c119df7e903afbafe0df033875e0194e" + } + ], + "threshold": 2, + "transport_public": "a6ae46dc9d2f6f2da55b6a12a5a0c46d75e2952429824c83466196bc9c56a34c32c7b955fd78d60b8a4bb3c9467440c1", + "transport_scalar": "41702b414dc83c6baa1d50260257bd2f7107066ebe5fd7203a5a529848ce1777" + }, + "name": "threshold_2of3", + "operation": "threshold", + "outputs": { + "aggregate": { + "c1": "83e85c7a481a6cca661a92795d6d83470e7b31c45c7a73dd55b3be47c04893eced891fa0191fb03a1c86ac85606133c8", + "c2": "8607e2c6f98afafcf458c60ed0414647516fe553c7171366f2826019f51ca1982ce74cc321acc4e82e6ae7c79e4242e9019b53c76a2b4a7961eaf117499520499c8782ec4f32b9e731bd89467ae9feae5cb8c60c0a366ec28801a1da04f8abdf", + "c3": "8eadbe8303ca150a116858cd9984b794bd3d4b1e14fa4a51eb0a214e65b0de9065209aefcc399e4af6ed792ec8a4e63d" + }, + "derived_key": "b0088e2497ad67828f40db8aabe22819d7dd2fc3f20d23123e6b3cbddf6f7133d767f04aa6e51b9178e558287f548b71", + "encrypted_shares": [ + { + "c1": "b6c7ce39ad467cd7d95c39ae2b9bcd525fa8a9b83b1133515df1792c208cd6c7f4c59e61720b35c7b0893b414ccf0d2d", + "c2": "b1b9b69930426e98bdd1c9fcb377eb2fe585a6f2adf1048e20cac2239fb297823971ce9b83a82909bd4129b63408512413655b9d4105f283212b39d1a2ca070e2c27ddb793f4a64016754083ea811afdd71f54b65d04944efd333c85c22e558d", + "c3": "833aec51d1edd4ca3f3a523451dfb9e1714e3a8dc7f1297d21cc38f5ef07a54ffad7c23ab6f0b0e0678b43b4ef4ddf2a", + "encoded": "010001a44e91f24f1d7cdd1704c2b1d966be8f84964eda7017666b418b573702e98a893af0788b84cd207cd5deb118e61e96a632130a5964b58fc98e4e584da5511f12b6c7ce39ad467cd7d95c39ae2b9bcd525fa8a9b83b1133515df1792c208cd6c7f4c59e61720b35c7b0893b414ccf0d2db1b9b69930426e98bdd1c9fcb377eb2fe585a6f2adf1048e20cac2239fb297823971ce9b83a82909bd4129b63408512413655b9d4105f283212b39d1a2ca070e2c27ddb793f4a64016754083ea811afdd71f54b65d04944efd333c85c22e558d833aec51d1edd4ca3f3a523451dfb9e1714e3a8dc7f1297d21cc38f5ef07a54ffad7c23ab6f0b0e0678b43b4ef4ddf2a", + "member": 1, + "signing_preimage": "e64d8f1577e39da919e6fd3b2b20fbe5d3b7269914069e7fd52f1c653c0b31ee" + }, + { + "c1": "a88cf57333c8f4e748f312d586b2b8acbe50f4e33236b4366f1cc3b49f8983eda50fad54d761c7b861588b7d96a6ae54", + "c2": "ad732bd83c8b0a6228180749a3bc0993613afba0c9e9f37705a3c9d7ebb6e622aee66fe76b66487a62185d03ff1126ee04f7fbe746a8db3f5688ae06df824a534e7f652487dac23cea2005f15725fefdc8ea703f070b1f87a80f07e677122b69", + "c3": "a41a7464254f2187ed35ac621c3392f07e09800f9b3ae43b112893dda72b48e58dea75c56b68f8344e4a1d2d4471dfc8", + "encoded": "010002a44e91f24f1d7cdd1704c2b1d966be8f84964eda7017666b418b573702e98a893af0788b84cd207cd5deb118e61e96a632130a5964b58fc98e4e584da5511f12a88cf57333c8f4e748f312d586b2b8acbe50f4e33236b4366f1cc3b49f8983eda50fad54d761c7b861588b7d96a6ae54ad732bd83c8b0a6228180749a3bc0993613afba0c9e9f37705a3c9d7ebb6e622aee66fe76b66487a62185d03ff1126ee04f7fbe746a8db3f5688ae06df824a534e7f652487dac23cea2005f15725fefdc8ea703f070b1f87a80f07e677122b69a41a7464254f2187ed35ac621c3392f07e09800f9b3ae43b112893dda72b48e58dea75c56b68f8344e4a1d2d4471dfc8", + "member": 2, + "signing_preimage": "63a8b293aa1f88039b9b3e185109ab7b4516b877c25e43b8fb7270ae9e0c5dab" + }, + { + "c1": "a4036808a000bad523ef15af06eb83ae75e5af38b3018c16bac9c2021f4aaa68575d8a9c1228b9983ad1c17892b54a9d", + "c2": "b86c4c2b7e6cf21e785aaef5691c61e78f87339e081316893bb8f928f3b77bb4d9b124b2d6d773174937a559bb67842117be1789717871677211bfa2c13dff55328c95131dca2765b0e18c49193c6f355f4e34f0a116b42b961e61bbbdea165b", + "c3": "8ee24861da5e79a7a611bdd938743bfa434269bdd10ca87a5c8086922abfc2b3c138de9b90a37b55e82985ddcd668014", + "encoded": "010003a44e91f24f1d7cdd1704c2b1d966be8f84964eda7017666b418b573702e98a893af0788b84cd207cd5deb118e61e96a632130a5964b58fc98e4e584da5511f12a4036808a000bad523ef15af06eb83ae75e5af38b3018c16bac9c2021f4aaa68575d8a9c1228b9983ad1c17892b54a9db86c4c2b7e6cf21e785aaef5691c61e78f87339e081316893bb8f928f3b77bb4d9b124b2d6d773174937a559bb67842117be1789717871677211bfa2c13dff55328c95131dca2765b0e18c49193c6f355f4e34f0a116b42b961e61bbbdea165b8ee24861da5e79a7a611bdd938743bfa434269bdd10ca87a5c8086922abfc2b3c138de9b90a37b55e82985ddcd668014", + "member": 3, + "signing_preimage": "d9abd845842f68121243e5dac4a96882cf5de2c71f53e40ecbb0eee49405a826" + } + ], + "selected_members": [ + 1, + 2 + ] + } + }, + { + "error": "InvalidScalar", + "inputs": { + "bytes": "73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001" + }, + "name": "noncanonical_scalar", + "operation": "decode_scalar" + }, + { + "error": "IdentityPoint", + "inputs": { + "bytes": "c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "name": "identity_transport", + "operation": "decode_transport" + }, + { + "error": "IdentityPoint", + "inputs": { + "bytes": "c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "name": "identity_mpk", + "operation": "decode_mpk" + }, + { + "error": "Invalid", + "inputs": { + "reference": "threshold_2of3", + "share": "010001a44e91f24f1d7cdd1704c2b1d966be8f84964eda7017666b418b573702e98a893af0788b84cd207cd5deb118e61e96a632130a5964b58fc98e4e584da5511f12b6c7ce39ad467cd7d95c39ae2b9bcd525fa8a9b83b1133515df1792c208cd6c7f4c59e61720b35c7b0893b414ccf0d2db1b9b69930426e98bdd1c9fcb377eb2fe585a6f2adf1048e20cac2239fb297823971ce9b83a82909bd4129b63408512413655b9d4105f283212b39d1a2ca070e2c27ddb793f4a64016754083ea811afdd71f54b65d04944efd333c85c22e558d81144f9a16884615f64ee8968e1daf5a93eaacf05e1709d0cb0583cfffff20b2f2f01f57f9b6e3f81bfd631952d3b737" + }, + "member": 1, + "name": "share_c3_mutated", + "operation": "verify_share" + }, + { + "error": "ContextMismatch", + "inputs": { + "reference": "threshold_2of3", + "share": "01000100000000000000000000000000000000000000000000000000000000000000003af0788b84cd207cd5deb118e61e96a632130a5964b58fc98e4e584da5511f12b6c7ce39ad467cd7d95c39ae2b9bcd525fa8a9b83b1133515df1792c208cd6c7f4c59e61720b35c7b0893b414ccf0d2db1b9b69930426e98bdd1c9fcb377eb2fe585a6f2adf1048e20cac2239fb297823971ce9b83a82909bd4129b63408512413655b9d4105f283212b39d1a2ca070e2c27ddb793f4a64016754083ea811afdd71f54b65d04944efd333c85c22e558d833aec51d1edd4ca3f3a523451dfb9e1714e3a8dc7f1297d21cc38f5ef07a54ffad7c23ab6f0b0e0678b43b4ef4ddf2a" + }, + "name": "share_context_transplanted", + "operation": "verify_share" + }, + { + "error": "ContextMismatch", + "inputs": { + "reference": "threshold_2of3", + "request": "01a44e91f24f1d7cdd1704c2b1d966be8f84964eda7017666b418b573702e98a890000001674696e79636c6f75643a746573743a6e6574776f726b000000000000000000000000000000000707070707070707070707070707070707070707070707070707070707070707000000630000001a54432d5448524553484f4c442d56312d4942452d49442d454e4301010000001674696e79636c6f75643a746573743a6e6574776f726b0000000d7265636f7264732f6f74686572000000000000000409090909090909090909090909090909a6ae46dc9d2f6f2da55b6a12a5a0c46d75e2952429824c83466196bc9c56a34c32c7b955fd78d60b8a4bb3c9467440c1", + "share": "010001a44e91f24f1d7cdd1704c2b1d966be8f84964eda7017666b418b573702e98a893af0788b84cd207cd5deb118e61e96a632130a5964b58fc98e4e584da5511f12b6c7ce39ad467cd7d95c39ae2b9bcd525fa8a9b83b1133515df1792c208cd6c7f4c59e61720b35c7b0893b414ccf0d2db1b9b69930426e98bdd1c9fcb377eb2fe585a6f2adf1048e20cac2239fb297823971ce9b83a82909bd4129b63408512413655b9d4105f283212b39d1a2ca070e2c27ddb793f4a64016754083ea811afdd71f54b65d04944efd333c85c22e558d833aec51d1edd4ca3f3a523451dfb9e1714e3a8dc7f1297d21cc38f5ef07a54ffad7c23ab6f0b0e0678b43b4ef4ddf2a" + }, + "name": "share_identity_transplanted", + "operation": "verify_share" + }, + { + "error": "InvalidCiphertext", + "inputs": { + "ciphertext": "81bfadcd88886ec11d878dd560641c107b09dfd2958069ac4b425ad1df97d810aad12ee56e0d80183cd34ab273010a3b0694eaea1c07c5e3e24e7147e3694cd915c3f88ff8e1d2a0ac63bcde26c90fb50ca8a7602a2e780bca05d56f1d1ead8e25fb797e5b3cfa2c8265aa685ade9b3c76de1182d6143a87fc87671a30e6114e525e95812064f61e2fa4c4c5e2dae1ae57f3ca91891c4ae93e9bf0a9256760e3", + "reference": "fullident_wrap_unwrap" + }, + "name": "fullident_u_mutated", + "operation": "unwrap" + }, + { + "error": "InvalidCiphertext", + "inputs": { + "ciphertext": "a0487090e537cae158abb448c457ca7fc5f34c3aa1507a538283b738953ee8468642d362483c71e93c9d01285e9ed0ca07ce1e8aa4694817427c52e9211aa687ceab72c28324473f5ad09ade7faab27a139d59c32a19804db55535a52e1da83a24fb797e5b3cfa2c8265aa685ade9b3c76de1182d6143a87fc87671a30e6114e525e95812064f61e2fa4c4c5e2dae1ae57f3ca91891c4ae93e9bf0a9256760e3", + "reference": "fullident_wrap_unwrap" + }, + "name": "fullident_v_mutated", + "operation": "unwrap" + }, + { + "error": "InvalidCiphertext", + "inputs": { + "ciphertext": "a0487090e537cae158abb448c457ca7fc5f34c3aa1507a538283b738953ee8468642d362483c71e93c9d01285e9ed0ca07ce1e8aa4694817427c52e9211aa687ceab72c28324473f5ad09ade7faab27a139d59c32a19804db55535a52e1da83a25fb797e5b3cfa2c8265aa685ade9b3c76de1182d6143a87fc87671a30e6114e535e95812064f61e2fa4c4c5e2dae1ae57f3ca91891c4ae93e9bf0a9256760e3", + "reference": "fullident_wrap_unwrap" + }, + "name": "fullident_w_mutated", + "operation": "unwrap" + } + ], + "dst": { + "h1": "TC-THRESHOLD-V1-IBE-ID_BLS12381G1_XMD:SHA-256_SSWU_RO_", + "h2": "TC-THRESHOLD-V1-IBE-H2", + "h3": "TC-THRESHOLD-V1-IBE-H3", + "h4": "TC-THRESHOLD-V1-IBE-H4", + "pedersen_h": "TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_" + }, + "encoding": "hex; GT coefficients in SPEC 3.2 order", + "format_version": 1, + "seeds": { + "dealer": "4747474747474747474747474747474747474747474747474747474747474747", + "shares": "4949494949494949494949494949494949494949494949494949494949494949", + "transport": "4848484848484848484848484848484848484848484848484848484848484848", + "wrap": "4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a" + }, + "spec_sections": [ + "3.2", + "3.3", + "3.4", + "3.5", + "3.6 + request-digest amendment" + ], + "suite": "BLS12-381 / RFC9380 SHA-256 SSWU_RO / FullIdent / encrypted threshold shares" +} diff --git a/tinycloud-threshold/vectors/v1/rfc9380-g1.json b/tinycloud-threshold/vectors/v1/rfc9380-g1.json new file mode 100644 index 00000000..c082fd34 --- /dev/null +++ b/tinycloud-threshold/vectors/v1/rfc9380-g1.json @@ -0,0 +1,67 @@ +{ + "source": "RFC 9380 Appendix J.9.1", + "suite": "BLS12381G1_XMD:SHA-256_SSWU_RO_", + "dst": "QUUX-V01-CS02-with-BLS12381G1_XMD:SHA-256_SSWU_RO_", + "cases": [ + { + "name": "empty", + "inputs": { + "msg": "" + }, + "outputs": { + "P.x": "052926add2207b76ca4fa57a8734416c8dc95e24501772c814278700eed6d1e4e8cf62d9c09db0fac349612b759e79a1", + "P.y": "08ba738453bfed09cb546dbb0783dbb3a5f1f566ed67bb6be0e8c67e2e81a4cc68ee29813bb7994998f3eae0c9c6a265", + "Q0.x": "11a3cce7e1d90975990066b2f2643b9540fa40d6137780df4e753a8054d07580db3b7f1f03396333d4a359d1fe3766fe", + "Q0.y": "0eeaf6d794e479e270da10fdaf768db4c96b650a74518fc67b04b03927754bac66f3ac720404f339ecdcc028afa091b7" + } + }, + { + "name": "abc", + "inputs": { + "msg": "abc" + }, + "outputs": { + "P.x": "03567bc5ef9c690c2ab2ecdf6a96ef1c139cc0b2f284dca0a9a7943388a49a3aee664ba5379a7655d3c68900be2f6903", + "P.y": "0b9c15f3fe6e5cf4211f346271d7b01c8f3b28be689c8429c85b67af215533311f0b8dfaaa154fa6b88176c229f2885d", + "Q0.x": "125435adce8e1cbd1c803e7123f45392dc6e326d292499c2c45c5865985fd74fe8f042ecdeeec5ecac80680d04317d80", + "Q0.y": "0e8828948c989126595ee30e4f7c931cbd6f4570735624fd25aef2fa41d3f79cfb4b4ee7b7e55a8ce013af2a5ba20bf2" + } + }, + { + "name": "abcdef0123456789", + "inputs": { + "msg": "abcdef0123456789" + }, + "outputs": { + "P.x": "11e0b079dea29a68f0383ee94fed1b940995272407e3bb916bbf268c263ddd57a6a27200a784cbc248e84f357ce82d98", + "P.y": "03a87ae2caf14e8ee52e51fa2ed8eefe80f02457004ba4d486d6aa1f517c0889501dc7413753f9599b099ebcbbd2d709", + "Q0.x": "08834484878c217682f6d09a4b51444802fdba3d7f2df9903a0ddadb92130ebbfa807fffa0eabf257d7b48272410afff", + "Q0.y": "0b318f7ecf77f45a0f038e62d7098221d2dbbca2a394164e2e3fe953dc714ac2cde412d8f2d7f0c03b259e6795a2508e" + } + }, + { + "name": "q128_qqqqqqqqqqqqqqq", + "inputs": { + "msg": "q128_qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" + }, + "outputs": { + "P.x": "15f68eaa693b95ccb85215dc65fa81038d69629f70aeee0d0f677cf22285e7bf58d7cb86eefe8f2e9bc3f8cb84fac488", + "P.y": "1807a1d50c29f430b8cafc4f8638dfeeadf51211e1602a5f184443076715f91bb90a48ba1e370edce6ae1062f5e6dd38", + "Q0.x": "0cbd7f84ad2c99643fea7a7ac8f52d63d66cefa06d9a56148e58b984b3dd25e1f41ff47154543343949c64f88d48a710", + "Q0.y": "052c00e4ed52d000d94881a5638ae9274d3efc8bc77bc0e5c650de04a000b2c334a9e80b85282a00f3148dfdface0865" + } + }, + { + "name": "a512_aaaaaaaaaaaaaaa", + "inputs": { + "msg": "a512_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "outputs": { + "P.x": "082aabae8b7dedb0e78aeb619ad3bfd9277a2f77ba7fad20ef6aabdc6c31d19ba5a6d12283553294c1825c4b3ca2dcfe", + "P.y": "05b84ae5a942248eea39e1d91030458c40153f3b654ab7872d779ad1e942856a20c438e8d99bc8abfbf74729ce1f7ac8", + "Q0.x": "0cf97e6dbd0947857f3e578231d07b309c622ade08f2c08b32ff372bd90db19467b2563cc997d4407968d4ac80e154f8", + "Q0.y": "127f0cddf2613058101a5701f4cb9d0861fd6c2a1b8e0afe194fccf586a3201a53874a2761a9ab6d7220c68661a35ab3" + } + } + ] +} diff --git a/tinycloud-threshold/vectors/v1/rfc9380-g2.json b/tinycloud-threshold/vectors/v1/rfc9380-g2.json new file mode 100644 index 00000000..4cff2f9d --- /dev/null +++ b/tinycloud-threshold/vectors/v1/rfc9380-g2.json @@ -0,0 +1,67 @@ +{ + "source": "RFC 9380 Appendix J.10.1", + "suite": "BLS12381G2_XMD:SHA-256_SSWU_RO_", + "dst": "QUUX-V01-CS02-with-BLS12381G2_XMD:SHA-256_SSWU_RO_", + "cases": [ + { + "name": "empty", + "inputs": { + "msg": "" + }, + "outputs": { + "P.x": "0141ebfbdca40eb85b87142e130ab689c673cf60f1a3e98d69335266f30d9b8d4ac44c1038e9dcdd5393faf5c41fb78a+ I * 05cb8437535e20ecffaef7752baddf98034139c38452458baeefab379ba13dff5bf5dd71b72418717047f5b0f37da03d", + "P.y": "0503921d7f6a12805e72940b963c0cf3471c7b2a524950ca195d11062ee75ec076daf2d4bc358c4b190c0c98064fdd92+ I * 12424ac32561493f3fe3c260708a12b7c620e7be00099a974e259ddc7d1f6395c3c811cdd19f1e8dbf3e9ecfdcbab8d6", + "Q0.x": "019ad3fc9c72425a998d7ab1ea0e646a1f6093444fc6965f1cad5a3195a7b1e099c050d57f45e3fa191cc6d75ed7458c+ I * 171c88b0b0efb5eb2b88913a9e74fe111a4f68867b59db252ce5868af4d1254bfab77ebde5d61cd1a86fb2fe4a5a1c1d", + "Q0.y": "0ba10604e62bdd9eeeb4156652066167b72c8d743b050fb4c1016c31b505129374f76e03fa127d6a156213576910fef3+ I * 0eb22c7a543d3d376e9716a49b72e79a89c9bfe9feee8533ed931cbb5373dde1fbcd7411d8052e02693654f71e15410a" + } + }, + { + "name": "abc", + "inputs": { + "msg": "abc" + }, + "outputs": { + "P.x": "02c2d18e033b960562aae3cab37a27ce00d80ccd5ba4b7fe0e7a210245129dbec7780ccc7954725f4168aff2787776e6+ I * 139cddbccdc5e91b9623efd38c49f81a6f83f175e80b06fc374de9eb4b41dfe4ca3a230ed250fbe3a2acf73a41177fd8", + "P.y": "1787327b68159716a37440985269cf584bcb1e621d3a7202be6ea05c4cfe244aeb197642555a0645fb87bf7466b2ba48+ I * 00aa65dae3c8d732d10ecd2c50f8a1baf3001578f71c694e03866e9f3d49ac1e1ce70dd94a733534f106d4cec0eddd16", + "Q0.x": "12b2e525281b5f4d2276954e84ac4f42cf4e13b6ac4228624e17760faf94ce5706d53f0ca1952f1c5ef75239aeed55ad+ I * 05d8a724db78e570e34100c0bc4a5fa84ad5839359b40398151f37cff5a51de945c563463c9efbdda569850ee5a53e77", + "Q0.y": "02eacdc556d0bdb5d18d22f23dcb086dd106cad713777c7e6407943edbe0b3d1efe391eedf11e977fac55f9b94f2489c+ I * 04bbe48bfd5814648d0b9e30f0717b34015d45a861425fabc1ee06fdfce36384ae2c808185e693ae97dcde118f34de41" + } + }, + { + "name": "abcdef0123456789", + "inputs": { + "msg": "abcdef0123456789" + }, + "outputs": { + "P.x": "121982811d2491fde9ba7ed31ef9ca474f0e1501297f68c298e9f4c0028add35aea8bb83d53c08cfc007c1e005723cd0+ I * 190d119345b94fbd15497bcba94ecf7db2cbfd1e1fe7da034d26cbba169fb3968288b3fafb265f9ebd380512a71c3f2c", + "P.y": "05571a0f8d3c08d094576981f4a3b8eda0a8e771fcdcc8ecceaf1356a6acf17574518acb506e435b639353c2e14827c8+ I * 0bb5e7572275c567462d91807de765611490205a941a5a6af3b1691bfe596c31225d3aabdf15faff860cb4ef17c7c3be", + "Q0.x": "0f48f1ea1318ddb713697708f7327781fb39718971d72a9245b9731faaca4dbaa7cca433d6c434a820c28b18e20ea208+ I * 06051467c8f85da5ba2540974758f7a1e0239a5981de441fdd87680a995649c211054869c50edbac1f3a86c561ba3162", + "Q0.y": "168b3d6df80069dbbedb714d41b32961ad064c227355e1ce5fac8e105de5e49d77f0c64867f3834848f152497eb76333+ I * 134e0e8331cee8cb12f9c2d0742714ed9eee78a84d634c9a95f6a7391b37125ed48bfc6e90bf3546e99930ff67cc97bc" + } + }, + { + "name": "q128_qqqqqqqqqqqqqqq", + "inputs": { + "msg": "q128_qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" + }, + "outputs": { + "P.x": "19a84dd7248a1066f737cc34502ee5555bd3c19f2ecdb3c7d9e24dc65d4e25e50d83f0f77105e955d78f4762d33c17da+ I * 0934aba516a52d8ae479939a91998299c76d39cc0c035cd18813bec433f587e2d7a4fef038260eef0cef4d02aae3eb91", + "P.y": "14f81cd421617428bc3b9fe25afbb751d934a00493524bc4e065635b0555084dd54679df1536101b2c979c0152d09192+ I * 09bcccfa036b4847c9950780733633f13619994394c23ff0b32fa6b795844f4a0673e20282d07bc69641cee04f5e5662", + "Q0.x": "09eccbc53df677f0e5814e3f86e41e146422834854a224bf5a83a50e4cc0a77bfc56718e8166ad180f53526ea9194b57+ I * 0c3633943f91daee715277bd644fba585168a72f96ded64fc5a384cce4ec884a4c3c30f08e09cd2129335dc8f67840ec", + "Q0.y": "0eb6186a0457d5b12d132902d4468bfeb7315d83320b6c32f1c875f344efcba979952b4aa418589cb01af712f98cc555+ I * 119e3cf167e69eb16c1c7830e8df88856d48be12e3ff0a40791a5cd2f7221311d4bf13b1847f371f467357b3f3c0b4c7" + } + }, + { + "name": "a512_aaaaaaaaaaaaaaa", + "inputs": { + "msg": "a512_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "outputs": { + "P.x": "01a6ba2f9a11fa5598b2d8ace0fbe0a0eacb65deceb476fbbcb64fd24557c2f4b18ecfc5663e54ae16a84f5ab7f62534+ I * 11fca2ff525572795a801eed17eb12785887c7b63fb77a42be46ce4a34131d71f7a73e95fee3f812aea3de78b4d01569", + "P.y": "0b6798718c8aed24bc19cb27f866f1c9effcdbf92397ad6448b5c9db90d2b9da6cbabf48adc1adf59a1a28344e79d57e+ I * 03a47f8e6d1763ba0cad63d6114c0accbef65707825a511b251a660a9b3994249ae4e63fac38b23da0c398689ee2ab52", + "Q0.x": "17cadf8d04a1a170f8347d42856526a24cc466cb2ddfd506cff01191666b7f944e31244d662c904de5440516a2b09004+ I * 0d13ba91f2a8b0051cf3279ea0ee63a9f19bc9cb8bfcc7d78b3cbd8cc4fc43ba726774b28038213acf2b0095391c523e", + "Q0.y": "17ef19497d6d9246fa94d35575c0f8d06ee02f21a284dbeaa78768cb1e25abd564e3381de87bda26acd04f41181610c5+ I * 12c3c913ba4ed03c24f0721a81a6be7430f2971ffca8fd1729aafe496bb725807531b44b34b59b3ae5495e5a2dcbd5c8" + } + } + ] +} diff --git a/tinycloud-threshold/vectors/v1/rfc9380-k1.json b/tinycloud-threshold/vectors/v1/rfc9380-k1.json new file mode 100644 index 00000000..6ce340c1 --- /dev/null +++ b/tinycloud-threshold/vectors/v1/rfc9380-k1.json @@ -0,0 +1,107 @@ +{ + "source": "RFC 9380 Appendix K.1", + "suite": "expand_message_xmd(SHA-256)", + "dst": "QUUX-V01-CS02-with-expander-SHA256-128", + "cases": [ + { + "name": "empty-0x20", + "inputs": { + "msg": "", + "len": 32 + }, + "outputs": { + "uniform_bytes": "68a985b87eb6b46952128911f2a4412bbc302a9d759667f87f7a21d803f07235" + } + }, + { + "name": "abc-0x20", + "inputs": { + "msg": "abc", + "len": 32 + }, + "outputs": { + "uniform_bytes": "d8ccab23b5985ccea865c6c97b6e5b8350e794e603b4b97902f53a8a0d605615" + } + }, + { + "name": "abcdef0123456789-0x20", + "inputs": { + "msg": "abcdef0123456789", + "len": 32 + }, + "outputs": { + "uniform_bytes": "eff31487c770a893cfb36f912fbfcbff40d5661771ca4b2cb4eafe524333f5c1" + } + }, + { + "name": "q128_qqqqqqqqqqqqqqq-0x20", + "inputs": { + "msg": "q128_qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "len": 32 + }, + "outputs": { + "uniform_bytes": "b23a1d2b4d97b2ef7785562a7e8bac7eed54ed6e97e29aa51bfe3f12ddad1ff9" + } + }, + { + "name": "a512_aaaaaaaaaaaaaaa-0x20", + "inputs": { + "msg": "a512_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "len": 32 + }, + "outputs": { + "uniform_bytes": "4623227bcc01293b8c130bf771da8c298dede7383243dc0993d2d94823958c4c" + } + }, + { + "name": "empty-0x80", + "inputs": { + "msg": "", + "len": 128 + }, + "outputs": { + "uniform_bytes": "af84c27ccfd45d41914fdff5df25293e221afc53d8ad2ac06d5e3e29485dadbee0d121587713a3e0dd4d5e69e93eb7cd4f5df4cd103e188cf60cb02edc3edf18eda8576c412b18ffb658e3dd6ec849469b979d444cf7b26911a08e63cf31f9dcc541708d3491184472c2c29bb749d4286b004ceb5ee6b9a7fa5b646c993f0ced" + } + }, + { + "name": "abc-0x80", + "inputs": { + "msg": "abc", + "len": 128 + }, + "outputs": { + "uniform_bytes": "abba86a6129e366fc877aab32fc4ffc70120d8996c88aee2fe4b32d6c7b6437a647e6c3163d40b76a73cf6a5674ef1d890f95b664ee0afa5359a5c4e07985635bbecbac65d747d3d2da7ec2b8221b17b0ca9dc8a1ac1c07ea6a1e60583e2cb00058e77b7b72a298425cd1b941ad4ec65e8afc50303a22c0f99b0509b4c895f40" + } + }, + { + "name": "abcdef0123456789-0x80", + "inputs": { + "msg": "abcdef0123456789", + "len": 128 + }, + "outputs": { + "uniform_bytes": "ef904a29bffc4cf9ee82832451c946ac3c8f8058ae97d8d629831a74c6572bd9ebd0df635cd1f208e2038e760c4994984ce73f0d55ea9f22af83ba4734569d4bc95e18350f740c07eef653cbb9f87910d833751825f0ebefa1abe5420bb52be14cf489b37fe1a72f7de2d10be453b2c9d9eb20c7e3f6edc5a60629178d9478df" + } + }, + { + "name": "q128_qqqqqqqqqqqqqqq-0x80", + "inputs": { + "msg": "q128_qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "len": 128 + }, + "outputs": { + "uniform_bytes": "80be107d0884f0d881bb460322f0443d38bd222db8bd0b0a5312a6fedb49c1bbd88fd75d8b9a09486c60123dfa1d73c1cc3169761b17476d3c6b7cbbd727acd0e2c942f4dd96ae3da5de368d26b32286e32de7e5a8cb2949f866a0b80c58116b29fa7fabb3ea7d520ee603e0c25bcaf0b9a5e92ec6a1fe4e0391d1cdbce8c68a" + } + }, + { + "name": "a512_aaaaaaaaaaaaaaa-0x80", + "inputs": { + "msg": "a512_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "len": 128 + }, + "outputs": { + "uniform_bytes": "546aff5444b5b79aa6148bd81728704c32decb73a3ba76e9e75885cad9def1d06d6792f8a7d12794e90efed817d96920d728896a4510864370c207f99bd4a608ea121700ef01ed879745ee3e4ceef777eda6d9e5e38b90c86ea6fb0b36504ba4a45d22e86f6db5dd43d98a294bebb9125d5b794e9d2a81181066eb954966a487" + } + } + ] +} From d91c16c8e9e4b43006f1a8942952f935a7005c51 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:32:31 +0000 Subject: [PATCH 2/6] fix(threshold): bind signing preimages to issued requests Details: TC-2219 --- tinycloud-threshold/src/checkpoint.rs | 37 +++++++++++++++++++++++++-- tinycloud-threshold/src/derive.rs | 11 +++++--- tinycloud-threshold/src/vectors.rs | 2 +- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/tinycloud-threshold/src/checkpoint.rs b/tinycloud-threshold/src/checkpoint.rs index f01788f8..1c0d7306 100644 --- a/tinycloud-threshold/src/checkpoint.rs +++ b/tinycloud-threshold/src/checkpoint.rs @@ -89,6 +89,39 @@ fn setup( (public, shares, tsk, req, issued) } +#[test] +fn signing_preimage_rejects_changed_record_nonce() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../vectors/v1/primitives.json")).unwrap(); + let threshold = fixture["cases"] + .as_array() + .unwrap() + .iter() + .find(|case| case["name"] == "threshold_2of3") + .unwrap(); + let request_bytes = hex::decode(threshold["inputs"]["request"].as_str().unwrap()).unwrap(); + let request = DecryptRequest::decode(&request_bytes).unwrap(); + let response = &threshold["outputs"]["encrypted_shares"][0]; + let share = + EncryptedKeyShare::decode(&hex::decode(response["encoded"].as_str().unwrap()).unwrap()) + .unwrap(); + let expected = hex::decode(response["signing_preimage"].as_str().unwrap()).unwrap(); + assert_eq!(share.signing_preimage(&request), Ok(expected)); + + let mut changed = request_bytes; + let last_nonce = changed.len() - 49; + changed[last_nonce] ^= 1; + let other_request = DecryptRequest::decode(&changed).unwrap(); + assert_eq!(other_request.context_digest, request.context_digest); + assert_eq!(other_request.tpk, request.tpk); + assert_ne!(other_request.identity().nonce(), request.identity().nonce()); + assert_ne!(share.request_digest, *other_request.digest().as_bytes()); + assert_eq!( + share.signing_preimage(&other_request), + Err(Error::ContextMismatch) + ); +} + #[test] fn checkpoint_2of3() { let (public, shares, tsk, req, issued) = setup(11); @@ -312,8 +345,8 @@ fn both_share_equations_and_deduplication() { Err(ShareError::ContextMismatch) ); assert_ne!( - issued[0].signing_preimage(&req), - issued[1].signing_preimage(&req) + issued[0].signing_preimage(&req).unwrap(), + issued[1].signing_preimage(&req).unwrap() ); } diff --git a/tinycloud-threshold/src/derive.rs b/tinycloud-threshold/src/derive.rs index 2f301126..e0738846 100644 --- a/tinycloud-threshold/src/derive.rs +++ b/tinycloud-threshold/src/derive.rs @@ -255,13 +255,18 @@ impl EncryptedKeyShare { }) } /// Tagged 32-byte preimage for a node-key signature; request digest is explicit. - pub fn signing_preimage(&self, req: &DecryptRequest) -> Vec { + /// Returns `ContextMismatch` if the supplied request differs from the issued request. + pub fn signing_preimage(&self, req: &DecryptRequest) -> Result, Error> { + let request_digest = req.digest(); + if self.request_digest != *request_digest.as_bytes() { + return Err(Error::ContextMismatch); + } let mut e = Encoder::new(); - e.fixed(req.digest().as_bytes()); + e.fixed(request_digest.as_bytes()); e.fixed(req.certificate.as_bytes()); e.u16(self.member.get()); e.fixed(&self.encode()); - dst::tagged_hash(dst::DECRYPT_SHARE, &e.finish()).to_vec() + Ok(dst::tagged_hash(dst::DECRYPT_SHARE, &e.finish()).to_vec()) } fn matches(&self, ctx: &KeyContext, req: &DecryptRequest) -> bool { self.context_digest == ctx.digest() diff --git a/tinycloud-threshold/src/vectors.rs b/tinycloud-threshold/src/vectors.rs index a3cccabb..9752c585 100644 --- a/tinycloud-threshold/src/vectors.rs +++ b/tinycloud-threshold/src/vectors.rs @@ -49,7 +49,7 @@ fn primitives() -> Value { .iter() .map(|s| json!({"member":s.index().get(),"scalar":hex::encode(s.scalar.to_bytes())})) .collect(); - let encrypted_shares:Vec<_>=issued.iter().map(|s|json!({"member":s.index().get(),"encoded":hex::encode(s.encode()),"c1":hex::encode(s.c1.encode()),"c2":hex::encode(s.c2.encode()),"c3":hex::encode(s.c3.encode()),"signing_preimage":hex::encode(s.signing_preimage(&req))})).collect(); + let encrypted_shares:Vec<_>=issued.iter().map(|s|json!({"member":s.index().get(),"encoded":hex::encode(s.encode()),"c1":hex::encode(s.c1.encode()),"c2":hex::encode(s.c2.encode()),"c3":hex::encode(s.c3.encode()),"signing_preimage":hex::encode(s.signing_preimage(&req).unwrap())})).collect(); let mut bad_share = issued[0].clone(); bad_share.c3 = bad_share.c3.add(&G1::generator()); let mut wrong_context = issued[0].clone(); From d0e7fa1b446432c19af01beb1ac2305ef0931d65 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:29:56 +0000 Subject: [PATCH 3/6] fix(threshold): include workspace member in image preparation Details: TC-2219 --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index ab9585d9..98df4e68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY ./rust-toolchain.toml ./ COPY ./tinycloud-node-server/ ./tinycloud-node-server/ COPY ./tinycloud-auth/ ./tinycloud-auth/ COPY ./tinycloud-core/ ./tinycloud-core/ +COPY ./tinycloud-threshold/ ./tinycloud-threshold/ COPY ./tinycloud-sdk-rs/ ./tinycloud-sdk-rs/ COPY ./tinycloud-sdk-wasm/ ./tinycloud-sdk-wasm/ COPY ./tinycloud-verifier-wasm/ ./tinycloud-verifier-wasm/ From 6827222c8476272af474db3fc91ef65743f46dce Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:15:25 +0000 Subject: [PATCH 4/6] feat(threshold): compute independent primitive vectors; Details: TC-2219 --- tinycloud-threshold/vectors/check/.gitignore | 2 + tinycloud-threshold/vectors/check/README.md | 81 ++++++ tinycloud-threshold/vectors/check/bun.lock | 35 +++ tinycloud-threshold/vectors/check/bunfig.toml | 2 + tinycloud-threshold/vectors/check/cli.ts | 14 + tinycloud-threshold/vectors/check/isolate.ts | 19 ++ .../vectors/check/package.json | 17 ++ .../vectors/check/protocol.test.ts | 148 +++++++++++ tinycloud-threshold/vectors/check/protocol.ts | 243 ++++++++++++++++++ tinycloud-threshold/vectors/check/schema.ts | 88 +++++++ .../vectors/check/tsconfig.json | 14 + .../vectors/check/vectors.test.ts | 79 ++++++ tinycloud-threshold/vectors/check/vectors.ts | 145 +++++++++++ 13 files changed, 887 insertions(+) create mode 100644 tinycloud-threshold/vectors/check/.gitignore create mode 100644 tinycloud-threshold/vectors/check/README.md create mode 100644 tinycloud-threshold/vectors/check/bun.lock create mode 100644 tinycloud-threshold/vectors/check/bunfig.toml create mode 100644 tinycloud-threshold/vectors/check/cli.ts create mode 100644 tinycloud-threshold/vectors/check/isolate.ts create mode 100644 tinycloud-threshold/vectors/check/package.json create mode 100644 tinycloud-threshold/vectors/check/protocol.test.ts create mode 100644 tinycloud-threshold/vectors/check/protocol.ts create mode 100644 tinycloud-threshold/vectors/check/schema.ts create mode 100644 tinycloud-threshold/vectors/check/tsconfig.json create mode 100644 tinycloud-threshold/vectors/check/vectors.test.ts create mode 100644 tinycloud-threshold/vectors/check/vectors.ts diff --git a/tinycloud-threshold/vectors/check/.gitignore b/tinycloud-threshold/vectors/check/.gitignore new file mode 100644 index 00000000..b7b50247 --- /dev/null +++ b/tinycloud-threshold/vectors/check/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +!bun.lock diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md new file mode 100644 index 00000000..f8d6cde5 --- /dev/null +++ b/tinycloud-threshold/vectors/check/README.md @@ -0,0 +1,81 @@ +# Primitive vector checker + +Independent TypeScript computation for `../v1/primitives.json`, using +`@noble/curves` 2.4.0 and Bun 1.4.4. All scalar inputs are public test values. + +```sh +bun install --frozen-lockfile && bun run check +``` + +`check` runs the Bun tests, TypeScript checking, and the real vector CLI. The +existing `threshold` CI job invokes this command. To check another JSON file: + +```sh +bun run vectors /absolute/path/to/primitives.json +``` + +The CLI reports each computed record and exits nonzero on a schema, byte, +equation, or expected-error disagreement. Its default path is relative to the +CLI module, not the calling directory. + +## Computations + +- H1 and Pedersen h use the specified RFC 9380 custom DSTs. +- SHA-256 XMD computes H2/H4 and H3 with modulus r, count 1, and L=48. +- Both reduced pairing values use twelve 48-byte big-endian coefficients in + SPEC §3.2 tower order. +- FullIdent recomputes the ciphertext from explicit sigma, derives the key from + the public master scalar, and unwraps the encoded tuple. +- Context, identity, request and share layouts are decoded and re-encoded. + Shares carry both digests in the amended 259-byte layout. Signing preimages + include the matching request digest explicitly. +- Each share is checked against its scalar/public share and both pairing + equations. All three 2-of-3 subsets derive the master-derived key and unwrap + the FullIdent tuple. The recorded aggregate uses the two lowest indices. +- Negative records execute their decoder, verifier or unwrap operation and + compare the computed typed error, including member attribution. + +The share randomizers are not explicit fixture inputs. The checker treats +c1/c2 as public group inputs, verifies their common-randomizer equation, and +recomputes c3 from the scalar share and transport scalar. It does not recreate +the producer RNG from the seed metadata. + +`schema.ts` implements the supplied JSON-only handoff shape, including the +current named record set, field widths, literal metadata and additional-field +rules. That shape is a vector-format contract, not a general test-count policy. +Expected cryptographic output bytes are not part of the schema. + +`protocol.test.ts` uses independently chosen identities, scalar shares, +transport keys and randomizers. It covers point/scalar encodings, request +bindings, both share equations, duplicate members, binding precedence, +zero-randomness aggregates, and FullIdent tuple/identity errors. +`vectors.test.ts` perturbs expected bytes, replaces negative inputs with valid +ones, and runs the real CLI against committed and throwaway JSON inputs. +Tests create private tool homes using `mktemp -d -p /var/tmp`, record the exact +created path, set the homes before writing, and remove that exact directory. + +## Provenance + +This checker was written in a separate implementation session from SPEC, +public JSON vectors, and the JSON-only schema/byte-layout handoff. No Rust +source, Rust tests, Rust diffs or Rust implementation traces were opened. +Dependency implementation files were not opened or used as design inputs. + +| Consulted source | Use | +| --- | --- | +| TinyCloud approved design/plan and `../../SPEC.md` §§3.1–3.6, S0.1 appendix | Equations, DSTs, encodings, error ordering and RequestDigest amendment | +| `../v1/primitives.json` and JSON-only schema/byte-layout handoff | Public inputs, expected bytes and current record format | +| [RFC 9380 §§5.2–5.3.1](https://www.rfc-editor.org/rfc/rfc9380#section-5.2) | Scalar-field reduction and SHA-256 XMD algorithm | +| [noble-curves 2.4.0 API documentation](https://github.com/paulmillr/noble-curves/blob/2.4.0/README.md) | Group operations, hash-to-curve, pairings and field API | +| npm registry version metadata | Version, integrity and license declarations | + +## Dependencies + +`bun.lock` freezes package versions and integrity digests. Runtime dependencies +are `@noble/curves` 2.4.0 and its `@noble/hashes` 2.4.0 dependency, both MIT. +Development dependencies are TypeScript 5.9.3 (Apache-2.0), `@types/bun` 1.3.1, +`bun-types` 1.3.1, `@types/node` 26.6.6, `undici-types` 8.9.0, +`@types/react` 19.3.0 and `csstype` 3.2.3 (all MIT). License declarations were +read from the npm registry metadata for those exact versions. + +Details: TC-2219 diff --git a/tinycloud-threshold/vectors/check/bun.lock b/tinycloud-threshold/vectors/check/bun.lock new file mode 100644 index 00000000..9da4d2be --- /dev/null +++ b/tinycloud-threshold/vectors/check/bun.lock @@ -0,0 +1,35 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "tinycloud-threshold-vector-checker", + "dependencies": { + "@noble/curves": "2.4.0", + }, + "devDependencies": { + "@types/bun": "1.3.1", + "typescript": "5.9.3", + }, + }, + }, + "packages": { + "@noble/curves": ["@noble/curves@2.4.0", "", { "dependencies": { "@noble/hashes": "2.4.0" } }, "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew=="], + + "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], + + "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], + + "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + } +} diff --git a/tinycloud-threshold/vectors/check/bunfig.toml b/tinycloud-threshold/vectors/check/bunfig.toml new file mode 100644 index 00000000..f0307abe --- /dev/null +++ b/tinycloud-threshold/vectors/check/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./isolate.ts"] diff --git a/tinycloud-threshold/vectors/check/cli.ts b/tinycloud-threshold/vectors/check/cli.ts new file mode 100644 index 00000000..acc0cf62 --- /dev/null +++ b/tinycloud-threshold/vectors/check/cli.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { checkVectors } from './vectors.ts'; + +try { + if (process.argv.length > 3) throw new Error('usage: bun cli.ts [primitive-vector-file]'); + const path = process.argv[2] ?? new URL('../v1/primitives.json', import.meta.url); + const input: unknown = JSON.parse(readFileSync(path, 'utf8')); + const report = checkVectors(input); + for (const name of report.passed) console.log(`PASS ${name}`); + console.log(`Primitive vectors: ${report.passed.length}/${report.passed.length}; combinations: ${report.combinations.map(m => m.join('+')).join(', ')}`); +} catch (e) { + console.error(`FAIL ${e instanceof Error ? e.message : String(e)}`); + process.exitCode = 1; +} diff --git a/tinycloud-threshold/vectors/check/isolate.ts b/tinycloud-threshold/vectors/check/isolate.ts new file mode 100644 index 00000000..ff7cd399 --- /dev/null +++ b/tinycloud-threshold/vectors/check/isolate.ts @@ -0,0 +1,19 @@ +import { afterAll } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; + +const created = spawnSync('mktemp', ['-d', '-p', '/var/tmp', 'tc-2219-checker-test.XXXXXXXX'], { encoding: 'utf8' }); +if (created.status !== 0) throw new Error('isolated tool home creation failed'); +const root = created.stdout.trim(); +if (!root.startsWith('/var/tmp/tc-2219-checker-test.')) throw new Error('unexpected tool home'); +const homes: Record = { + HOME: 'home', COURIER_HOME: 'courier', TC_HOME: 'tc', TC_CONFIG_DIR: 'tc-config', + TC_STATE_DIR: 'tc-state', TC_PROFILE_DIR: 'tc-profile', TC_SECRETS_STATE: 'tc-secrets', + BUN_INSTALL_CACHE_DIR: 'bun-cache', XDG_CONFIG_HOME: 'config', XDG_CACHE_HOME: 'cache', + XDG_DATA_HOME: 'data', XDG_STATE_HOME: 'state', +}; +for (const [key, suffix] of Object.entries(homes)) process.env[key] = `${root}/${suffix}`; +if (Object.keys(homes).some(key => !process.env[key]?.startsWith(`${root}/`))) throw new Error('tool homes not isolated'); +writeFileSync(`${root}/created-paths.txt`, `${root}\n`, { mode: 0o600 }); +for (const key of Object.keys(homes)) mkdirSync(process.env[key]!, { mode: 0o700 }); +afterAll(() => rmSync(root, { recursive: true })); diff --git a/tinycloud-threshold/vectors/check/package.json b/tinycloud-threshold/vectors/check/package.json new file mode 100644 index 00000000..22421a9d --- /dev/null +++ b/tinycloud-threshold/vectors/check/package.json @@ -0,0 +1,17 @@ +{ + "name": "tinycloud-threshold-vector-checker", + "private": true, + "type": "module", + "scripts": { + "check": "bun test --timeout 30000 && bun run typecheck && bun run vectors", + "typecheck": "tsc --noEmit", + "vectors": "bun cli.ts" + }, + "dependencies": { + "@noble/curves": "2.4.0" + }, + "devDependencies": { + "@types/bun": "1.3.1", + "typescript": "5.9.3" + } +} diff --git a/tinycloud-threshold/vectors/check/protocol.test.ts b/tinycloud-threshold/vectors/check/protocol.test.ts new file mode 100644 index 00000000..83091bb7 --- /dev/null +++ b/tinycloud-threshold/vectors/check/protocol.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from 'bun:test'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { + R, ProtocolError, bytes, hex, be, concat, scalar, g1, g2, h1, h3, + encodeIdentity, decodeIdentity, encodeContext, decodeContext, contextDigest, + encodeRequest, decodeRequest, requestDigest, encodeShare, decodeShare, + signingPreimage, verifyShare, combine, wrap, unwrap, +} from './protocol.ts'; + +const G1 = bls.G1.Point.BASE; +const G2 = bls.G2.Point.BASE; +const identity = encodeIdentity({ mode: 1, network: 'tinycloud:test:independent', path: 'records/é', version: 9n, nonce: new Uint8Array(16).fill(17) }); +const context = { network: 'tinycloud:test:independent', rekey: 2n, epoch: 7n, certificate: new Uint8Array(32).fill(41), threshold: 2, members: 3 }; +const request = encodeRequest({ ...context, context: contextDigest(context), identity, tpk: G1.multiply(11n).toBytes() }); +const keyset = { context, mpk: G2.multiply(13n), publicShares: new Map([1, 2, 3].map(i => [i, G2.multiply(13n + 5n * BigInt(i))])) }; +function share(member: number, randomness = BigInt(member + 20)) { + return encodeShare({ member, context: contextDigest(context), request: requestDigest(request), c1: G1.multiply(randomness).toBytes(), c2: G2.multiply(randomness).toBytes(), c3: h1(identity).multiply(13n + 5n * BigInt(member)).add(G1.multiply(11n * randomness)).toBytes() }); +} +function expectError(f: () => unknown, code: string, member?: number) { + try { f(); } catch (e) { + expect(e).toBeInstanceOf(ProtocolError); + expect((e as ProtocolError).code).toBe(code); + if (member !== undefined) expect((e as ProtocolError).member).toBe(member); + return; + } + throw new Error(`expected ${code}`); +} +function changed(input: Uint8Array, offset: number, value?: number) { + const result = input.slice(); + result[offset] = value ?? (result[offset]! ^ 1); + return result; +} + +describe('canonical bytes and role decoders', () => { + test('scalar accepts zero and r-1 but not r, overlong or short encodings', () => { + expect(scalar(be(0n, 32))).toBe(0n); + expect(scalar(be(R - 1n, 32))).toBe(R - 1n); + for (const b of [be(R, 32), new Uint8Array(31), new Uint8Array(33)]) expectError(() => scalar(b), 'InvalidScalar'); + }); + test('hex consumes every character', () => { + for (const value of ['0', '0g', 'aa ', '0x00', 'AA']) expectError(() => bytes(value), 'InvalidEncoding'); + }); + test('G1 and G2 reject lengths, flags, noncanonical coordinates and torsion', () => { + for (const [decode, base, size] of [[g1, G1.toBytes(), 48], [g2, G2.toBytes(), 96]] as const) { + expectError(() => decode(base.slice(1)), 'InvalidPoint'); + expectError(() => decode(changed(base, 0, base[0]! & 0x7f)), 'InvalidPoint'); + const noncanonical = new Uint8Array(size).fill(255); noncanonical[0] = 0x9f; + expectError(() => decode(noncanonical), 'InvalidPoint'); + } + const torsion = new Uint8Array(48); torsion[0] = 0x80; + expectError(() => g1(torsion), 'InvalidPoint'); + }); + test('identity is allowed only when the field permits it', () => { + expectError(() => g1(bls.G1.Point.ZERO.toBytes()), 'IdentityPoint'); + expectError(() => g2(bls.G2.Point.ZERO.toBytes()), 'IdentityPoint'); + expect(g1(bls.G1.Point.ZERO.toBytes(), true).equals(bls.G1.Point.ZERO)).toBe(true); + }); + test('identity encoding preserves UTF-8, modes, lengths and split boundaries', () => { + expect(hex(encodeIdentity(decodeIdentity(identity)))).toBe(hex(identity)); + const session = encodeIdentity({ mode: 2, network: 'a', path: '/b', version: 0n }); + expect(decodeIdentity(session).nonce).toBeUndefined(); + expect(hex(session)).not.toBe(hex(encodeIdentity({ mode: 2, network: 'a/', path: 'b', version: 0n }))); + for (const b of [identity.slice(0, -1), concat(identity, be(0n, 1)), changed(identity, 31, 3), changed(identity, 32, 255)]) expectError(() => decodeIdentity(b), 'InvalidEncoding'); + }); + test('context and request round-trip with their full bindings', () => { + expect(hex(encodeContext(decodeContext(encodeContext(context))))).toBe(hex(encodeContext(context))); + expect(hex(encodeRequest(decodeRequest(request)))).toBe(hex(request)); + expectError(() => decodeRequest(concat(request, be(0n, 1))), 'InvalidEncoding'); + const other = encodeIdentity({ ...decodeIdentity(identity), network: 'elsewhere' }); + expectError(() => decodeRequest(encodeRequest({ ...decodeRequest(request), identity: other })), 'ContextMismatch'); + expectError(() => decodeContext(encodeContext({ ...context, threshold: 1 })), 'InvalidParameters'); + }); +}); + +describe('request-bound encrypted shares', () => { + test('both pairing equations identify independently chosen member errors', () => { + const good = share(2); + expect(verifyShare(keyset, request, good)).toBe(2); + const decoded = decodeShare(good); + expectError(() => verifyShare(keyset, request, encodeShare({ ...decoded, c1: G1.multiply(31n).toBytes() })), 'Invalid', 2); + expectError(() => verifyShare(keyset, request, encodeShare({ ...decoded, c2: G2.multiply(31n).toBytes() })), 'Invalid', 2); + expectError(() => verifyShare(keyset, request, encodeShare({ ...decoded, c3: g1(decoded.c3, true).add(G1).toBytes() })), 'Invalid', 2); + }); + test('identity c3 satisfies the second equation when its terms cancel', () => { + const req = encodeRequest({ ...decodeRequest(request), tpk: h1(identity).multiply(R - 18n).toBytes() }); + const encoded = encodeShare({ + member: 1, context: contextDigest(context), request: requestDigest(req), + c1: G1.toBytes(), c2: G2.toBytes(), c3: bls.G1.Point.ZERO.toBytes(), + }); + expect(verifyShare(keyset, req, encoded)).toBe(1); + }); + test('request and share binding precedes algebra and signing', () => { + const good = share(1); + for (const offset of [3, 35]) { + const bad = changed(good, offset); + bad.set(G1.multiply(3n).toBytes(), 67); + expectError(() => verifyShare(keyset, request, bad), 'ContextMismatch'); + } + for (const req of [encodeRequest({ ...decodeRequest(request), tpk: G1.multiply(17n).toBytes() }), encodeRequest({ ...decodeRequest(request), identity: encodeIdentity({ ...decodeIdentity(identity), path: 'other' }) }), encodeRequest({ ...decodeRequest(request), epoch: 8n }), encodeRequest({ ...decodeRequest(request), certificate: new Uint8Array(32) })]) { + expectError(() => verifyShare(keyset, req, good), 'ContextMismatch'); + } + expectError(() => signingPreimage(good, new Uint8Array(32), context.certificate), 'ContextMismatch'); + expectError(() => verifyShare(keyset, request, encodeShare({ ...decodeShare(good), member: 4 })), 'ContextMismatch'); + }); + test('combine checks every binding before verifying even an unselected share', () => { + const invalidEquation = encodeShare({ ...decodeShare(share(1)), c2: G2.multiply(9n).toBytes() }); + const mixed = changed(share(3), 35); + expectError(() => combine(keyset, request, [invalidEquation, share(2), mixed], 11n), 'MixedContexts'); + }); + test('all subsets derive the master key; duplicate indices count once', () => { + const expected = hex(h1(identity).multiply(13n).toBytes()); + for (const pair of [[1, 2], [1, 3], [2, 3]]) expect(hex(combine(keyset, request, pair.map(i => share(i)), 11n).key)).toBe(expected); + expectError(() => combine(keyset, request, [share(1), share(1)], 11n), 'NotEnoughShares'); + expect(combine(keyset, request, [share(3), share(2), share(1), share(2)], 11n).selected).toEqual([1, 2]); + expectError(() => combine(keyset, request, [share(1), share(2)], 12n), 'InvalidDerivedKey'); + }); + test('zero aggregate randomness remains a valid combination', () => { + const result = combine(keyset, request, [share(1, 1n), share(2, 2n)], 11n); + expect(hex(result.aggregate.c1)).toBe(hex(bls.G1.Point.ZERO.toBytes())); + expect(hex(result.key)).toBe(hex(h1(identity).multiply(13n).toBytes())); + }); + test('amended share has 259 bytes and rejects trailing bytes and old layout', () => { + const good = share(1); + expect(good.byteLength).toBe(259); + expect(hex(encodeShare(decodeShare(good)))).toBe(hex(good)); + expectError(() => decodeShare(concat(good, be(0n, 1))), 'InvalidEncoding'); + expectError(() => decodeShare(concat(good.slice(0, 35), good.slice(67))), 'InvalidEncoding'); + }); +}); + +describe('FullIdent from independently chosen inputs', () => { + const sigma = new Uint8Array(32).fill(83); + const message = new Uint8Array(32).fill(149); + const key = h1(identity).multiply(13n).toBytes(); + test('explicit sigma wraps and unwraps; H3 is a scalar-field value', () => { + const ciphertext = wrap(G2.multiply(13n), identity, message, sigma); + expect(hex(unwrap(key, identity, identity, ciphertext))).toBe(hex(message)); + expect(h3(concat(sigma, message))).toBeLessThan(R); + }); + test('U, V and W mutations produce the same ciphertext error', () => { + const ciphertext = wrap(G2.multiply(13n), identity, message, sigma); + const u = ciphertext.slice(); u.set(G2.multiply(41n).toBytes(), 0); + for (const bad of [u, changed(ciphertext, 99), changed(ciphertext, 147), ciphertext.slice(1)]) expectError(() => unwrap(key, identity, identity, bad), 'InvalidCiphertext'); + }); + test('identity association is checked before ciphertext parsing', () => { + expectError(() => unwrap(key, identity, encodeIdentity({ ...decodeIdentity(identity), path: 'other' }), new Uint8Array()), 'WrongIdentity'); + }); +}); diff --git a/tinycloud-threshold/vectors/check/protocol.ts b/tinycloud-threshold/vectors/check/protocol.ts new file mode 100644 index 00000000..ca739404 --- /dev/null +++ b/tinycloud-threshold/vectors/check/protocol.ts @@ -0,0 +1,243 @@ +import { createHash } from 'node:crypto'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; + +export type Bytes = Uint8Array; +export type G1 = typeof bls.G1.Point.BASE; +export type G2 = typeof bls.G2.Point.BASE; +export type GT = typeof bls.fields.Fp12.ONE; +export const R = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001n; +export const DST = { + h1: 'TC-THRESHOLD-V1-IBE-ID_BLS12381G1_XMD:SHA-256_SSWU_RO_', + pedersen_h: 'TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_', + h2: 'TC-THRESHOLD-V1-IBE-H2', h3: 'TC-THRESHOLD-V1-IBE-H3', h4: 'TC-THRESHOLD-V1-IBE-H4', +}; +const PREFIX = 'TC-THRESHOLD-V1-IBE-ID-ENC'; +const encoder = new TextEncoder(); +const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +export class ProtocolError extends Error { + constructor(readonly code: string, readonly member?: number) { super(code); } +} +export function hex(b: Bytes): string { return Buffer.from(b).toString('hex'); } +export function bytes(s: string): Bytes { + if (typeof s !== 'string' || !/^(?:[0-9a-f]{2})*$/.test(s)) throw new ProtocolError('InvalidEncoding'); + return new Uint8Array(Buffer.from(s, 'hex')); +} +export function concat(...parts: Bytes[]): Bytes { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let offset = 0; + for (const p of parts) { out.set(p, offset); offset += p.length; } + return out; +} +export function be(value: bigint, length: number): Bytes { + if (value < 0n || value >= 1n << BigInt(8 * length)) throw new ProtocolError('InvalidEncoding'); + return bytes(value.toString(16).padStart(length * 2, '0')); +} +function integer(b: Bytes): bigint { return b.length ? BigInt(`0x${hex(b)}`) : 0n; } +function lp(b: Bytes): Bytes { return concat(be(BigInt(b.length), 4), b); } +function equal(a: Bytes, b: Bytes): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } +function sha256(b: Bytes): Bytes { return new Uint8Array(createHash('sha256').update(b).digest()); } +export function tagged(tag: string, body: Bytes): Bytes { return sha256(concat(lp(encoder.encode(tag)), body)); } + +export function xmd(message: Bytes, dst: string, length: number): Bytes { + const tag = encoder.encode(dst); + const count = Math.ceil(length / 32); + if (tag.length > 255 || length < 1 || count > 255) throw new ProtocolError('InvalidEncoding'); + const suffix = concat(tag, be(BigInt(tag.length), 1)); + const b0 = sha256(concat(new Uint8Array(64), message, be(BigInt(length), 2), be(0n, 1), suffix)); + let previous = sha256(concat(b0, be(1n, 1), suffix)); + const blocks = [previous]; + for (let i = 2; i <= count; i++) { + previous = sha256(concat(xor(b0, previous), be(BigInt(i), 1), suffix)); + blocks.push(previous); + } + return concat(...blocks).slice(0, length); +} +function xor(a: Bytes, b: Bytes): Bytes { + if (a.length !== b.length) throw new ProtocolError('InvalidEncoding'); + return a.map((v, i) => v ^ b[i]!); +} +export function scalar(b: Bytes): bigint { + if (b.length !== 32 || integer(b) >= R) throw new ProtocolError('InvalidScalar'); + return integer(b); +} +export function g1(b: Bytes, identityAllowed = false): G1 { + let p: G1; + try { + if (b.length !== 48 || !(b[0]! & 0x80)) throw new Error(); + p = bls.G1.Point.fromBytes(b); + p.assertValidity(); + if (!p.isTorsionFree() || !equal(p.toBytes(), b)) throw new Error(); + } catch { throw new ProtocolError('InvalidPoint'); } + if (!identityAllowed && p.equals(bls.G1.Point.ZERO)) throw new ProtocolError('IdentityPoint'); + return p; +} +export function g2(b: Bytes, identityAllowed = false): G2 { + let p: G2; + try { + if (b.length !== 96 || !(b[0]! & 0x80)) throw new Error(); + p = bls.G2.Point.fromBytes(b); + p.assertValidity(); + if (!p.isTorsionFree() || !equal(p.toBytes(), b)) throw new Error(); + } catch { throw new ProtocolError('InvalidPoint'); } + if (!identityAllowed && p.equals(bls.G2.Point.ZERO)) throw new ProtocolError('IdentityPoint'); + return p; +} +export function h1(identity: Bytes): G1 { return bls.G1.hashToCurve(identity, { DST: DST.h1 }); } +export function h3(message: Bytes): bigint { return integer(xmd(message, DST.h3, 48)) % R; } +export function pairing(a: G1, b: G2): GT { + if (a.equals(bls.G1.Point.ZERO) || b.equals(bls.G2.Point.ZERO)) return bls.fields.Fp12.ONE; + return bls.pairing(a, b, true); +} +export function encodeGT(gt: GT): Bytes { + const coefficients: bigint[] = []; + for (const a of [gt.c0, gt.c1]) for (const b of [a.c0, a.c1, a.c2]) coefficients.push(b.c0, b.c1); + return concat(...coefficients.map(c => be(c, 48))); +} + +class Reader { + private offset = 0; + constructor(private readonly input: Bytes) { + if (input.length > 65536) throw new ProtocolError('InvalidEncoding'); + } + take(length: number): Bytes { + if (length < 0 || length > this.input.length - this.offset) throw new ProtocolError('InvalidEncoding'); + const b = this.input.slice(this.offset, this.offset + length); this.offset += length; return b; + } + int(length: number): bigint { return integer(this.take(length)); } + field(cap: number): Bytes { + const length = Number(this.int(4)); + if (length > cap) throw new ProtocolError('InvalidEncoding'); + return this.take(length); + } + text(): string { + const b = this.field(1024); + if (b.length === 0) throw new ProtocolError('InvalidEncoding'); + try { return decoder.decode(b); } catch { throw new ProtocolError('InvalidEncoding'); } + } + version() { if (this.int(1) !== 1n) throw new ProtocolError('InvalidEncoding'); } + end() { if (this.offset !== this.input.length) throw new ProtocolError('InvalidEncoding'); } +} +export interface Identity { mode: number; network: string; path: string; version: bigint; nonce?: Bytes } +export function encodeIdentity(id: Identity): Bytes { + return concat(lp(encoder.encode(PREFIX)), be(1n, 1), be(BigInt(id.mode), 1), lp(encoder.encode(id.network)), lp(encoder.encode(id.path)), be(id.version, 8), id.mode === 1 ? (id.nonce ?? new Uint8Array()) : new Uint8Array()); +} +export function decodeIdentity(b: Bytes): Identity { + const r = new Reader(b); + if (!equal(r.field(26), encoder.encode(PREFIX))) throw new ProtocolError('InvalidEncoding'); + r.version(); + const mode = Number(r.int(1)); + if (mode !== 1 && mode !== 2) throw new ProtocolError('InvalidEncoding'); + const id: Identity = { mode, network: r.text(), path: r.text(), version: r.int(8) }; + if (mode === 1) id.nonce = r.take(16); + r.end(); return id; +} +export interface Context { network: string; rekey: bigint; epoch: bigint; certificate: Bytes; threshold: number; members: number } +export function encodeContext(c: Context): Bytes { + return concat(be(1n, 1), lp(encoder.encode(c.network)), be(c.rekey, 8), be(c.epoch, 8), c.certificate, be(BigInt(c.threshold), 2), be(BigInt(c.members), 2)); +} +export function decodeContext(b: Bytes): Context { + const r = new Reader(b); r.version(); + const c = { network: r.text(), rekey: r.int(8), epoch: r.int(8), certificate: r.take(32), threshold: Number(r.int(2)), members: Number(r.int(2)) }; + r.end(); + if (c.threshold < 2 || c.threshold > c.members || c.members > 16) throw new ProtocolError('InvalidParameters'); + return c; +} +export function contextDigest(c: Context): Bytes { return tagged('TC-THRESHOLD-V1-KEY-CONTEXT', encodeContext(c)); } +export interface Request { context: Bytes; network: string; rekey: bigint; epoch: bigint; certificate: Bytes; identity: Bytes; tpk: Bytes } +export function encodeRequest(r: Request): Bytes { + return concat(be(1n, 1), r.context, lp(encoder.encode(r.network)), be(r.rekey, 8), be(r.epoch, 8), r.certificate, lp(r.identity), r.tpk); +} +export function decodeRequest(b: Bytes): Request { + const r = new Reader(b); r.version(); + const req = { context: r.take(32), network: r.text(), rekey: r.int(8), epoch: r.int(8), certificate: r.take(32), identity: r.field(4096), tpk: r.take(48) }; + r.end(); + if (decodeIdentity(req.identity).network !== req.network) throw new ProtocolError('ContextMismatch'); + g1(req.tpk); return req; +} +export function requestDigest(b: Bytes): Bytes { return tagged('TC-THRESHOLD-V1-DECRYPT-REQUEST', b); } +export interface Share { member: number; context: Bytes; request: Bytes; c1: Bytes; c2: Bytes; c3: Bytes } +export function encodeShare(s: Share): Bytes { + return concat(be(1n, 1), be(BigInt(s.member), 2), s.context, s.request, s.c1, s.c2, s.c3); +} +export function decodeShare(b: Bytes): Share { + const r = new Reader(b); r.version(); + const s = { member: Number(r.int(2)), context: r.take(32), request: r.take(32), c1: r.take(48), c2: r.take(96), c3: r.take(48) }; + r.end(); + if (s.member < 1 || s.member > 16) throw new ProtocolError('InvalidEncoding'); + g1(s.c1); g2(s.c2); g1(s.c3, true); return s; +} +export function signingPreimage(share: Bytes, request: Bytes, certificate: Bytes): Bytes { + const s = decodeShare(share); + if (request.length !== 32 || certificate.length !== 32 || !equal(s.request, request)) throw new ProtocolError('ContextMismatch'); + return tagged('TC-THRESHOLD-V1-DECRYPT-SHARE', concat(request, certificate, be(BigInt(s.member), 2), share)); +} +export interface Keyset { context: Context; mpk: G2; publicShares: Map } +function boundRequest(keys: Keyset, encoded: Bytes): Request { + const req = decodeRequest(encoded), c = keys.context; + if (!equal(req.context, contextDigest(c)) || req.network !== c.network || req.rekey !== c.rekey || req.epoch !== c.epoch || !equal(req.certificate, c.certificate)) throw new ProtocolError('ContextMismatch'); + return req; +} +function boundShare(keys: Keyset, req: Request, digest: Bytes, s: Share): boolean { + return equal(s.context, req.context) && equal(s.request, digest) && s.member <= keys.context.members && keys.publicShares.has(s.member); +} +function equations(keys: Keyset, req: Request, s: Share) { + const c1 = g1(s.c1), c2 = g2(s.c2), c3 = g1(s.c3, true), fp = bls.fields.Fp12; + const first = fp.eql(pairing(c1, bls.G2.Point.BASE), pairing(bls.G1.Point.BASE, c2)); + const second = fp.eql(pairing(c3, bls.G2.Point.BASE), fp.mul(pairing(h1(req.identity), keys.publicShares.get(s.member)!), pairing(g1(req.tpk), c2))); + if (!first || !second) throw new ProtocolError('Invalid', s.member); +} +export function verifyShare(keys: Keyset, request: Bytes, share: Bytes): number { + const req = boundRequest(keys, request), s = decodeShare(share); + if (!boundShare(keys, req, requestDigest(request), s)) throw new ProtocolError('ContextMismatch'); + equations(keys, req, s); return s.member; +} +export function combine(keys: Keyset, request: Bytes, shares: Bytes[], transport: bigint): { key: Bytes; selected: number[]; aggregate: { c1: Bytes; c2: Bytes; c3: Bytes } } { + const req = boundRequest(keys, request), digest = requestDigest(request); + const decoded = shares.map(decodeShare); + for (const s of decoded) if (!boundShare(keys, req, digest, s)) throw new ProtocolError('MixedContexts'); + for (const s of decoded) equations(keys, req, s); + const distinct = new Map(decoded.map(s => [s.member, s])); + if (distinct.size < keys.context.threshold) throw new ProtocolError('NotEnoughShares'); + const selected = [...distinct.keys()].sort((a, b) => a - b).slice(0, keys.context.threshold); + let c1 = bls.G1.Point.ZERO, c2 = bls.G2.Point.ZERO, c3 = bls.G1.Point.ZERO; + for (const i of selected) { + let lambda = 1n; + for (const j of selected) if (i !== j) lambda = bls.fields.Fr.mul(lambda, bls.fields.Fr.div(BigInt(j), bls.fields.Fr.create(BigInt(j - i)))); + const s = distinct.get(i)!; + c1 = c1.add(g1(s.c1).multiply(lambda)); + c2 = c2.add(g2(s.c2).multiply(lambda)); + const p3 = g1(s.c3, true); + c3 = c3.add(p3.equals(bls.G1.Point.ZERO) ? p3 : p3.multiply(lambda)); + } + const k = c3.subtract(c1.equals(bls.G1.Point.ZERO) || transport === 0n ? bls.G1.Point.ZERO : c1.multiply(transport)); + if (k.equals(bls.G1.Point.ZERO) || !bls.fields.Fp12.eql(pairing(k, bls.G2.Point.BASE), pairing(h1(req.identity), keys.mpk))) throw new ProtocolError('InvalidDerivedKey'); + return { key: k.toBytes(), selected, aggregate: { + c1: (c1.equals(bls.G1.Point.ZERO) ? bls.G1.Point.ZERO : c1).toBytes(), + c2: (c2.equals(bls.G2.Point.ZERO) ? bls.G2.Point.ZERO : c2).toBytes(), + c3: (c3.equals(bls.G1.Point.ZERO) ? bls.G1.Point.ZERO : c3).toBytes(), + } }; +} +export function wrap(mpk: G2, identity: Bytes, message: Bytes, sigma: Bytes): Bytes { + decodeIdentity(identity); + if (message.length !== 32 || sigma.length !== 32) throw new ProtocolError('InvalidEncoding'); + const rho = h3(concat(sigma, message)); + if (rho === 0n) throw new ProtocolError('DegenerateRandomness'); + const u = bls.G2.Point.BASE.multiply(rho).toBytes(); + const mask = xmd(encodeGT(pairing(h1(identity).multiply(rho), mpk)), DST.h2, 32); + return concat(u, xor(sigma, mask), xor(message, xmd(sigma, DST.h4, 32))); +} +export function unwrap(key: Bytes, keyIdentity: Bytes, expectedIdentity: Bytes, ciphertext: Bytes): Bytes { + if (!equal(keyIdentity, expectedIdentity)) throw new ProtocolError('WrongIdentity'); + if (ciphertext.length !== 160) throw new ProtocolError('InvalidCiphertext'); + let u: G2; + try { u = g2(ciphertext.slice(0, 96)); } catch (e) { + if (e instanceof ProtocolError) throw new ProtocolError('InvalidCiphertext'); + throw e; + } + const sigma = xor(ciphertext.slice(96, 128), xmd(encodeGT(pairing(g1(key), u)), DST.h2, 32)); + const message = xor(ciphertext.slice(128), xmd(sigma, DST.h4, 32)); + const rho = h3(concat(sigma, message)); + if (rho === 0n || !bls.G2.Point.BASE.multiply(rho).equals(u)) throw new ProtocolError('InvalidCiphertext'); + return message; +} diff --git a/tinycloud-threshold/vectors/check/schema.ts b/tinycloud-threshold/vectors/check/schema.ts new file mode 100644 index 00000000..b88bfdb9 --- /dev/null +++ b/tinycloud-threshold/vectors/check/schema.ts @@ -0,0 +1,88 @@ +import { DST } from './protocol.ts'; + +type Parser = (value: unknown, path: string) => T; +type Parsed

= P extends Parser ? T : never; +function invalid(path: string): never { throw new Error(`schema: ${path}`); } +function literal(expected: T): Parser { + return (v, p) => v === expected ? expected : invalid(p); +} +function hexField(length: number): Parser { + return (v, p) => typeof v === 'string' && v.length === length * 2 && /^[0-9a-f]+$/.test(v) ? v : invalid(p); +} +function intField(min: number, max: number): Parser { + return (v, p) => typeof v === 'number' && Number.isInteger(v) && v >= min && v <= max ? v : invalid(p); +} +function object>>(shape: S): Parser<{ [K in keyof S]: Parsed }> { + return (v, p) => { + if (!v || typeof v !== 'object' || Array.isArray(v)) return invalid(p); + const input = v as Record; + if (Object.keys(input).length !== Object.keys(shape).length || Object.keys(input).some(k => !(k in shape))) return invalid(p); + const result: Record = {}; + for (const k of Object.keys(shape)) result[k] = shape[k]!(input[k], `${p}.${k}`); + return result as { [K in keyof S]: Parsed }; + }; +} +function array(parse: Parser, length: number): Parser { + return (v, p) => { + if (!Array.isArray(v) || v.length !== length) return invalid(p); + return v.map((x: unknown, i) => parse(x, `${p}[${i}]`)); + }; +} +const h32 = hexField(32), h48 = hexField(48), h96 = hexField(96), member = intField(1, 16); +const shareOutput = object({ member, encoded: hexField(259), c1: h48, c2: h96, c3: h48, signing_preimage: h32 }); +const thresholdInputs = object({ + context: hexField(79), context_digest: h32, certificate_digest: h32, + threshold: literal(2), members: literal(3), master_scalar: h32, mpk: h96, + public_shares: array(object({ member, point: h96 }), 3), scalar_shares: array(object({ member, scalar: h32 }), 3), + transport_scalar: h32, transport_public: h48, identity: hexField(100), request: hexField(259), request_digest: h32, +}); +const cases = { + h1_custom_dst: object({ name: literal('h1_custom_dst'), operation: literal('hash_g1'), inputs: object({ message: hexField(100), dst: literal(DST.h1) }), outputs: object({ point: h48 }) }), + pedersen_h: object({ name: literal('pedersen_h'), operation: literal('hash_g2'), inputs: object({ message: hexField(10), dst: literal(DST.pedersen_h) }), outputs: object({ point: h96 }) }), + h3_scalar_r: object({ name: literal('h3_scalar_r'), operation: literal('h3'), inputs: object({ message: hexField(64) }), outputs: object({ scalar: h32 }) }), + h2_gt: object({ name: literal('h2_gt'), operation: literal('h2'), inputs: object({ message: hexField(576) }), outputs: object({ bytes: h32 }) }), + h4_sigma: object({ name: literal('h4_sigma'), operation: literal('h4'), inputs: object({ message: h32 }), outputs: object({ bytes: h32 }) }), + pairing_generators: object({ name: literal('pairing_generators'), operation: literal('pairing'), inputs: object({ g1: h48, g2: h96 }), outputs: object({ gt: hexField(576) }) }), + pairing_5_7: object({ name: literal('pairing_5_7'), operation: literal('pairing'), inputs: object({ g1: h48, g2: h96 }), outputs: object({ gt: hexField(576) }) }), + fullident_wrap_unwrap: object({ name: literal('fullident_wrap_unwrap'), operation: literal('fullident'), inputs: object({ master_scalar: h32, mpk: h96, identity: hexField(100), message: h32, sigma: h32 }), outputs: object({ ciphertext: hexField(160), derived_key: h48, unwrapped: h32 }) }), + threshold_2of3: object({ name: literal('threshold_2of3'), operation: literal('threshold'), inputs: thresholdInputs, outputs: object({ encrypted_shares: array(shareOutput, 3), selected_members: array(intField(0, 65535), 2), aggregate: object({ c1: h48, c2: h96, c3: h48 }), derived_key: h48 }) }), + noncanonical_scalar: object({ name: literal('noncanonical_scalar'), operation: literal('decode_scalar'), inputs: object({ bytes: h32 }), error: literal('InvalidScalar') }), + identity_transport: object({ name: literal('identity_transport'), operation: literal('decode_transport'), inputs: object({ bytes: h48 }), error: literal('IdentityPoint') }), + identity_mpk: object({ name: literal('identity_mpk'), operation: literal('decode_mpk'), inputs: object({ bytes: h96 }), error: literal('IdentityPoint') }), + share_c3_mutated: object({ name: literal('share_c3_mutated'), operation: literal('verify_share'), inputs: object({ reference: literal('threshold_2of3'), share: hexField(259) }), error: literal('Invalid'), member: literal(1) }), + share_context_transplanted: object({ name: literal('share_context_transplanted'), operation: literal('verify_share'), inputs: object({ reference: literal('threshold_2of3'), share: hexField(259) }), error: literal('ContextMismatch') }), + share_identity_transplanted: object({ name: literal('share_identity_transplanted'), operation: literal('verify_share'), inputs: object({ reference: literal('threshold_2of3'), share: hexField(259), request: hexField(258) }), error: literal('ContextMismatch') }), + fullident_u_mutated: object({ name: literal('fullident_u_mutated'), operation: literal('unwrap'), inputs: object({ reference: literal('fullident_wrap_unwrap'), ciphertext: hexField(160) }), error: literal('InvalidCiphertext') }), + fullident_v_mutated: object({ name: literal('fullident_v_mutated'), operation: literal('unwrap'), inputs: object({ reference: literal('fullident_wrap_unwrap'), ciphertext: hexField(160) }), error: literal('InvalidCiphertext') }), + fullident_w_mutated: object({ name: literal('fullident_w_mutated'), operation: literal('unwrap'), inputs: object({ reference: literal('fullident_wrap_unwrap'), ciphertext: hexField(160) }), error: literal('InvalidCiphertext') }), +}; +export type VectorCase = Parsed<(typeof cases)[keyof typeof cases]>; +const parseCases: Parser = (v, p) => { + if (!Array.isArray(v) || v.length !== Object.keys(cases).length) return invalid(p); + const seen = new Set(); + const result = v.map((entry: unknown, i) => { + if (!entry || typeof entry !== 'object' || !('name' in entry) || typeof entry.name !== 'string' || !Object.hasOwn(cases, entry.name) || seen.has(entry.name)) return invalid(`${p}[${i}]`); + seen.add(entry.name); + return cases[entry.name as keyof typeof cases](entry, `${p}[${i}].${entry.name}`); + }); + return result; +}; +const sections: Parser = (v, p) => { + const expected = ['3.2', '3.3', '3.4', '3.5', '3.6 + request-digest amendment']; + if (!Array.isArray(v) || v.length !== expected.length || v.some((x, i) => x !== expected[i])) return invalid(p); + return expected; +}; +const file = object({ + format_version: literal(1), suite: literal('BLS12-381 / RFC9380 SHA-256 SSWU_RO / FullIdent / encrypted threshold shares'), + encoding: literal('hex; GT coefficients in SPEC 3.2 order'), spec_sections: sections, + seeds: object({ dealer: h32, transport: h32, shares: h32, wrap: h32 }), + dst: object({ h1: literal(DST.h1), h2: literal(DST.h2), h3: literal(DST.h3), h4: literal(DST.h4), pedersen_h: literal(DST.pedersen_h) }), + cases: parseCases, +}); +export type VectorFile = Parsed; +export function parseVectors(value: unknown): VectorFile { return file(value, 'vectors'); } +export function vector(file: VectorFile, name: N): Extract { + const found = file.cases.find((c): c is Extract => c.name === name); + if (!found) return invalid(name); + return found; +} diff --git a/tinycloud-threshold/vectors/check/tsconfig.json b/tinycloud-threshold/vectors/check/tsconfig.json new file mode 100644 index 00000000..53c996af --- /dev/null +++ b/tinycloud-threshold/vectors/check/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "types": ["bun"], + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/tinycloud-threshold/vectors/check/vectors.test.ts b/tinycloud-threshold/vectors/check/vectors.test.ts new file mode 100644 index 00000000..3638d642 --- /dev/null +++ b/tinycloud-threshold/vectors/check/vectors.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from 'bun:test'; +import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import { checkVectors } from './vectors.ts'; +import { parseVectors, vector, type VectorFile } from './schema.ts'; + +const raw: unknown = JSON.parse(readFileSync(new URL('../v1/primitives.json', import.meta.url), 'utf8')); +const fixture = parseVectors(raw); +const clone = () => structuredClone(fixture); +function perturb(target: object, field: string) { + const value: unknown = Reflect.get(target, field); + if (typeof value !== 'string') throw new Error(`not a byte field: ${field}`); + Reflect.set(target, field, (value[0] === '0' ? '1' : '0') + value.slice(1)); +} + +test('committed primitive vectors compute every named result', () => { + const result = checkVectors(fixture); + expect(result.passed).toEqual(fixture.cases.map((c: { name: string }) => c.name)); + expect(result.combinations).toEqual([[1, 2], [1, 3], [2, 3]]); +}); + +test('expected bytes and errors are compared with computation', () => { + for (const [name, output] of [['h1_custom_dst', 'point'], ['pedersen_h', 'point'], ['h3_scalar_r', 'scalar'], ['h2_gt', 'bytes'], ['h4_sigma', 'bytes'], ['pairing_generators', 'gt'], ['pairing_5_7', 'gt'], ['fullident_wrap_unwrap', 'ciphertext']] as const) { + const file = clone(); + perturb(vector(file, name).outputs, output); + expect(() => checkVectors(file)).toThrow(name); + } + const validNegative = clone(); + const base = vector(validNegative, 'threshold_2of3'); + vector(validNegative, 'share_c3_mutated').inputs.share = base.outputs.encrypted_shares[0]!.encoded; + expect(() => checkVectors(validNegative)).toThrow('share_c3_mutated'); + const validCiphertext = clone(); + vector(validCiphertext, 'fullident_v_mutated').inputs.ciphertext = vector(validCiphertext, 'fullident_wrap_unwrap').outputs.ciphertext; + expect(() => checkVectors(validCiphertext)).toThrow('fullident_v_mutated'); +}, 30000); + +test('share encoded bytes, signing preimages and aggregates are computed', () => { + for (const field of ['encoded', 'signing_preimage', 'c3']) { + const file = clone(); + perturb(vector(file, 'threshold_2of3').outputs.encrypted_shares[1]!, field); + expect(() => checkVectors(file)).toThrow('threshold_2of3'); + } + for (const field of ['c1', 'c2', 'c3']) { + const file = clone(); + perturb(vector(file, 'threshold_2of3').outputs.aggregate, field); + expect(() => checkVectors(file)).toThrow('threshold_2of3'); + } +}, 30000); + +test('schema refuses missing, duplicate, unknown and mistyped records', () => { + for (const mutate of [ + (f: VectorFile) => { f.cases = []; }, + (f: VectorFile) => { f.cases.pop(); }, + (f: VectorFile) => { f.cases[1] = f.cases[0]!; }, + (f: VectorFile) => { Reflect.set(f.cases[0]!, 'operation', 'unknown'); }, + (f: VectorFile) => { Reflect.set(f.cases[0]!.inputs, 'extra', true); }, + (f: VectorFile) => { vector(f, 'h1_custom_dst').outputs.point = '00'; }, + (f: VectorFile) => { f.dst.h3 = 'other'; }, + (f: VectorFile) => { Reflect.set(f, 'format_version', 2); }, + (f: VectorFile) => { Reflect.set(f.cases[0]!, 'outputs', null); }, + ]) { + const file = clone(); mutate(file); + expect(() => checkVectors(file)).toThrow(); + } +}); + +test('real CLI accepts committed JSON and exits nonzero for a perturbed file', () => { + const cli = new URL('./cli.ts', import.meta.url).pathname; + const run = (...args: string[]) => Bun.spawnSync([process.execPath, cli, ...args], { env: process.env, stdout: 'pipe', stderr: 'pipe', timeout: 30000 }); + const good = run(); + expect(good.exitCode).toBe(0); + const file = clone(); vector(file, 'h1_custom_dst').outputs.point = '00'.repeat(48); + const path = `${process.env.HOME}/perturbed.json`; + writeFileSync(path, JSON.stringify(file), { mode: 0o600 }); + try { + const bad = run(path); + expect(bad.exitCode).not.toBe(0); + expect(bad.stderr.toString()).toContain('h1_custom_dst'); + } finally { unlinkSync(path); } +}); diff --git a/tinycloud-threshold/vectors/check/vectors.ts b/tinycloud-threshold/vectors/check/vectors.ts new file mode 100644 index 00000000..b464bf4d --- /dev/null +++ b/tinycloud-threshold/vectors/check/vectors.ts @@ -0,0 +1,145 @@ +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { parseVectors, vector, type VectorCase } from './schema.ts'; +import { + ProtocolError, DST, bytes, hex, be, scalar, g1, g2, h1, h3, xmd, pairing, encodeGT, + decodeIdentity, encodeIdentity, decodeContext, encodeContext, contextDigest, + decodeRequest, encodeRequest, requestDigest, encodeShare, signingPreimage, + verifyShare, combine, wrap, unwrap, type Keyset, +} from './protocol.ts'; + +export interface CheckReport { passed: string[]; combinations: number[][] } +function same(label: string, actual: unknown, expected: unknown) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${label}: observed ${JSON.stringify(actual)}; expected ${JSON.stringify(expected)}`); +} +function checkThreshold(c: Extract): number[][] { + const i = c.inputs, o = c.outputs, context = decodeContext(bytes(i.context)); + same('context bytes', hex(encodeContext(context)), i.context); + same('context digest', hex(contextDigest(context)), i.context_digest); + same('certificate', hex(context.certificate), i.certificate_digest); + same('threshold', context.threshold, i.threshold); + same('members', context.members, i.members); + const identity = bytes(i.identity), transport = scalar(bytes(i.transport_scalar)), master = scalar(bytes(i.master_scalar)); + same('identity bytes', hex(encodeIdentity(decodeIdentity(identity))), i.identity); + same('mpk', hex(bls.G2.Point.BASE.multiply(master).toBytes()), i.mpk); + same('transport', hex(bls.G1.Point.BASE.multiply(transport).toBytes()), i.transport_public); + const request = bytes(i.request), req = decodeRequest(request); + same('request bytes', hex(encodeRequest(req)), i.request); + same('request context', hex(req.context), i.context_digest); + same('request network', req.network, context.network); + same('request rekey', req.rekey.toString(), context.rekey.toString()); + same('request epoch', req.epoch.toString(), context.epoch.toString()); + same('request certificate', hex(req.certificate), i.certificate_digest); + same('request identity', hex(req.identity), i.identity); + same('request transport', hex(req.tpk), i.transport_public); + const digest = requestDigest(request); + same('request digest', hex(digest), i.request_digest); + const indices = Array.from({ length: context.members }, (_, n) => n + 1); + same('scalar member order', i.scalar_shares.map(s => s.member), indices); + same('public member order', i.public_shares.map(s => s.member), indices); + same('encrypted member order', o.encrypted_shares.map(s => s.member), indices); + const publicShares = new Map(i.scalar_shares.map(s => [s.member, bls.G2.Point.BASE.multiply(scalar(bytes(s.scalar)))])); + for (const s of i.public_shares) same(`public share ${s.member}`, hex(publicShares.get(s.member)!.toBytes()), s.point); + const keys: Keyset = { context, mpk: g2(bytes(i.mpk)), publicShares }; + const encrypted = o.encrypted_shares.map(s => { + const c1 = g1(bytes(s.c1)), c2 = g2(bytes(s.c2)); + const si = scalar(bytes(i.scalar_shares.find(x => x.member === s.member)!.scalar)); + const c3 = h1(identity).multiply(si).add(c1.multiply(transport)).toBytes(); + same(`share ${s.member} c3`, hex(c3), s.c3); + const encoded = encodeShare({ member: s.member, context: contextDigest(context), request: digest, c1: c1.toBytes(), c2: c2.toBytes(), c3 }); + same(`share ${s.member} encoded`, hex(encoded), s.encoded); + same(`share ${s.member} signing preimage`, hex(signingPreimage(encoded, digest, context.certificate)), s.signing_preimage); + verifyShare(keys, request, encoded); + return encoded; + }); + const expected = hex(h1(identity).multiply(master).toBytes()); + same('derived key', expected, o.derived_key); + const combinations = [[1, 2], [1, 3], [2, 3]]; + for (const members of combinations) { + const result = combine(keys, request, members.map(n => encrypted[n - 1]!), transport); + same(`combine ${members.join(',')} key`, hex(result.key), expected); + if (members[0] === 1 && members[1] === 2) { + same('selected members', result.selected, o.selected_members); + same('aggregate c1', hex(result.aggregate.c1), o.aggregate.c1); + same('aggregate c2', hex(result.aggregate.c2), o.aggregate.c2); + same('aggregate c3', hex(result.aggregate.c3), o.aggregate.c3); + } + } + return combinations; +} +export function checkVectors(input: unknown): CheckReport { + const file = parseVectors(input), passed: string[] = []; + const full = vector(file, 'fullident_wrap_unwrap'), threshold = vector(file, 'threshold_2of3'); + const fi = full.inputs, ti = threshold.inputs; + const keys: Keyset = { + context: decodeContext(bytes(ti.context)), mpk: g2(bytes(ti.mpk)), + publicShares: new Map(ti.public_shares.map(s => [s.member, g2(bytes(s.point))])), + }; + const fullKey = h1(bytes(fi.identity)).multiply(scalar(bytes(fi.master_scalar))).toBytes(); + let combinations: number[][] = []; + for (const c of file.cases) { + try { + switch (c.operation) { + case 'hash_g1': + same('H1', hex(bls.G1.hashToCurve(bytes(c.inputs.message), { DST: c.inputs.dst }).toBytes()), c.outputs.point); + break; + case 'hash_g2': + same('Pedersen h', hex(bls.G2.hashToCurve(bytes(c.inputs.message), { DST: c.inputs.dst }).toBytes()), c.outputs.point); + break; + case 'h3': same('H3', hex(be(h3(bytes(c.inputs.message)), 32)), c.outputs.scalar); break; + case 'h2': same('H2', hex(xmd(bytes(c.inputs.message), DST.h2, 32)), c.outputs.bytes); break; + case 'h4': same('H4', hex(xmd(bytes(c.inputs.message), DST.h4, 32)), c.outputs.bytes); break; + case 'pairing': { + const a = c.name === 'pairing_generators' ? bls.G1.Point.BASE : bls.G1.Point.BASE.multiply(5n); + const b = c.name === 'pairing_generators' ? bls.G2.Point.BASE : bls.G2.Point.BASE.multiply(7n); + same('pairing input G1', hex(a.toBytes()), c.inputs.g1); + same('pairing input G2', hex(b.toBytes()), c.inputs.g2); + same('reduced pairing GT', hex(encodeGT(pairing(g1(bytes(c.inputs.g1)), g2(bytes(c.inputs.g2))))), c.outputs.gt); + break; + } + case 'fullident': { + same('master public key', hex(bls.G2.Point.BASE.multiply(scalar(bytes(fi.master_scalar))).toBytes()), fi.mpk); + const ciphertext = wrap(g2(bytes(fi.mpk)), bytes(fi.identity), bytes(fi.message), bytes(fi.sigma)); + same('FullIdent ciphertext', hex(ciphertext), c.outputs.ciphertext); + same('FullIdent key', hex(fullKey), c.outputs.derived_key); + const plaintext = unwrap(fullKey, bytes(fi.identity), bytes(fi.identity), bytes(c.outputs.ciphertext)); + same('FullIdent plaintext', hex(plaintext), fi.message); + same('FullIdent unwrapped', hex(plaintext), c.outputs.unwrapped); + break; + } + case 'threshold': + same('shared identity', ti.identity, fi.identity); + same('shared master', ti.master_scalar, fi.master_scalar); + same('shared mpk', ti.mpk, fi.mpk); + combinations = checkThreshold(c); + for (const members of combinations) { + const derived = combine(keys, bytes(ti.request), members.map(n => bytes(c.outputs.encrypted_shares[n - 1]!.encoded)), scalar(bytes(ti.transport_scalar))); + same(`FullIdent from subset ${members}`, hex(unwrap(derived.key, bytes(ti.identity), bytes(fi.identity), bytes(full.outputs.ciphertext))), fi.message); + } + break; + case 'decode_scalar': case 'decode_transport': case 'decode_mpk': case 'verify_share': case 'unwrap': { + let observed: { code: string; member?: number } | undefined; + try { + switch (c.operation) { + case 'decode_scalar': scalar(bytes(c.inputs.bytes)); break; + case 'decode_transport': g1(bytes(c.inputs.bytes)); break; + case 'decode_mpk': g2(bytes(c.inputs.bytes)); break; + case 'verify_share': + verifyShare(keys, bytes('request' in c.inputs ? c.inputs.request : ti.request), bytes(c.inputs.share)); break; + case 'unwrap': unwrap(fullKey, bytes(fi.identity), bytes(fi.identity), bytes(c.inputs.ciphertext)); break; + } + } catch (e) { + if (!(e instanceof ProtocolError)) throw e; + observed = { code: e.code, ...(e.member === undefined ? {} : { member: e.member }) }; + } + same('computed error', observed, { code: c.error, ...('member' in c ? { member: c.member } : {}) }); + break; + } + default: { const exhaustive: never = c; throw new Error(`unsupported case ${exhaustive}`); } + } + passed.push(c.name); + } catch (e) { + throw new Error(`${c.name}: ${e instanceof Error ? e.message : String(e)}`); + } + } + return { passed, combinations }; +} From eadb1900cb55f34b71cb479c4fffd5e25316c2df Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:43:24 +0000 Subject: [PATCH 5/6] test(threshold): exercise chosen vector corruptions; Details: TC-2219 --- .../vectors/check/corruption.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tinycloud-threshold/vectors/check/corruption.test.ts diff --git a/tinycloud-threshold/vectors/check/corruption.test.ts b/tinycloud-threshold/vectors/check/corruption.test.ts new file mode 100644 index 00000000..b1204781 --- /dev/null +++ b/tinycloud-threshold/vectors/check/corruption.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { parseVectors, vector, type VectorCase } from './schema.ts'; + +const input: unknown = JSON.parse(readFileSync(new URL('../v1/primitives.json', import.meta.url), 'utf8')); +const fixture = parseVectors(input); +const cli = new URL('./cli.ts', import.meta.url).pathname; + +interface Corruption { + path: (string | number)[]; + offset: number; + mask: number; + encoding: 'hex' | 'utf8'; + outcome: string; + observed?: { code: string } | 'success' | 'schema'; +} +const corruptions: Record = { + h1_custom_dst: { path: ['outputs', 'point'], offset: 47, mask: 1, encoding: 'hex', outcome: 'computed H1 bytes disagree' }, + pedersen_h: { path: ['outputs', 'point'], offset: 95, mask: 1, encoding: 'hex', outcome: 'computed Pedersen bytes disagree' }, + h3_scalar_r: { path: ['outputs', 'scalar'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed H3 scalar disagrees' }, + h2_gt: { path: ['outputs', 'bytes'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed H2 bytes disagree' }, + h4_sigma: { path: ['outputs', 'bytes'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed H4 bytes disagree' }, + pairing_generators: { path: ['outputs', 'gt'], offset: 575, mask: 1, encoding: 'hex', outcome: 'computed generator GT disagrees' }, + pairing_5_7: { path: ['outputs', 'gt'], offset: 575, mask: 1, encoding: 'hex', outcome: 'computed second GT disagrees' }, + fullident_wrap_unwrap: { path: ['outputs', 'ciphertext'], offset: 159, mask: 1, encoding: 'hex', outcome: 'computed FullIdent tuple disagrees' }, + threshold_2of3: { path: ['outputs', 'encrypted_shares', 0, 'signing_preimage'], offset: 31, mask: 1, encoding: 'hex', outcome: 'computed response signing preimage disagrees' }, + noncanonical_scalar: { path: ['inputs', 'bytes'], offset: 31, mask: 1, encoding: 'hex', outcome: 'r becomes r-1 and decodes successfully', observed: 'success' }, + identity_transport: { path: ['inputs', 'bytes'], offset: 0, mask: 128, encoding: 'hex', outcome: 'IdentityPoint becomes InvalidPoint', observed: { code: 'InvalidPoint' } }, + identity_mpk: { path: ['inputs', 'bytes'], offset: 0, mask: 128, encoding: 'hex', outcome: 'IdentityPoint becomes InvalidPoint', observed: { code: 'InvalidPoint' } }, + share_c3_mutated: { path: ['inputs', 'share'], offset: 35, mask: 1, encoding: 'hex', outcome: 'Invalid becomes ContextMismatch', observed: { code: 'ContextMismatch' } }, + share_context_transplanted: { path: ['inputs', 'share'], offset: 2, mask: 1, encoding: 'hex', outcome: 'member zero changes ContextMismatch to InvalidEncoding', observed: { code: 'InvalidEncoding' } }, + share_identity_transplanted: { path: ['inputs', 'request'], offset: 210, mask: 128, encoding: 'hex', outcome: 'transport flags change ContextMismatch to InvalidPoint', observed: { code: 'InvalidPoint' } }, + fullident_u_mutated: { path: ['error'], offset: 16, mask: 1, encoding: 'utf8', outcome: 'declared error label changes and fails schema; valid-tuple semantics checked separately', observed: 'schema' }, + fullident_v_mutated: { path: ['inputs', 'ciphertext'], offset: 96, mask: 1, encoding: 'hex', outcome: 'V restored, unwrap succeeds instead of InvalidCiphertext', observed: 'success' }, + fullident_w_mutated: { path: ['inputs', 'ciphertext'], offset: 128, mask: 1, encoding: 'hex', outcome: 'W restored, unwrap succeeds instead of InvalidCiphertext', observed: 'success' }, +}; + +function runCli(value: unknown) { + return Bun.spawnSync([process.execPath, cli, '/dev/stdin'], { + env: process.env, stdin: Buffer.from(JSON.stringify(value)), stdout: 'pipe', stderr: 'pipe', timeout: 30000, + }); +} + +test('real CLI accepts the unchanged vector document through memory input', () => { + expect(runCli(fixture).exitCode).toBe(0); +}); + +for (const original of fixture.cases) { + const mutation = corruptions[original.name]; + test(`one-byte CLI decision: ${original.name}: ${mutation.outcome}`, () => { + const changed = structuredClone(fixture); + let target: object = vector(changed, original.name); + for (const component of mutation.path.slice(0, -1)) { + const next: unknown = Reflect.get(target, component); + if (!next || typeof next !== 'object') throw new Error(`not an object: ${component}`); + target = next; + } + const field = mutation.path.at(-1)!; + const value: unknown = Reflect.get(target, field); + if (typeof value !== 'string') throw new Error(`not a byte field: ${String(field)}`); + const before = Buffer.from(value, mutation.encoding); + const after = Buffer.from(before); + if (mutation.offset >= after.length) throw new Error('corruption offset outside field'); + after[mutation.offset] = after[mutation.offset]! ^ mutation.mask; + expect(after.length).toBe(before.length); + expect(after.reduce((count, byte, index) => count + Number(byte !== before[index]), 0)).toBe(1); + Reflect.set(target, field, after.toString(mutation.encoding)); + const result = runCli(changed); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain(original.name); + if (mutation.observed === 'schema') { + expect(result.stderr.toString()).toContain('FAIL schema:'); + } else if (mutation.observed !== undefined) { + const actual = mutation.observed === 'success' ? 'undefined' : JSON.stringify(mutation.observed); + expect(result.stderr.toString()).toContain(`computed error: observed ${actual};`); + } + }, 30000); +} + +test('U negative compares the actual unwrap outcome when given the valid tuple', () => { + const changed = structuredClone(fixture); + vector(changed, 'fullident_u_mutated').inputs.ciphertext = vector(changed, 'fullident_wrap_unwrap').outputs.ciphertext; + const result = runCli(changed); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain('fullident_u_mutated: computed error: observed undefined'); +}); From 31d2b2c3866483b119501e4a5041a0d0a104737c Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:42:30 +0000 Subject: [PATCH 6/6] fix(threshold): pin primitive checker constants; Details: TC-2219 --- tinycloud-threshold/vectors/check/README.md | 7 ++ .../vectors/check/constants.test.ts | 118 ++++++++++++++++++ tinycloud-threshold/vectors/check/protocol.ts | 1 + tinycloud-threshold/vectors/check/schema.ts | 4 +- tinycloud-threshold/vectors/check/vectors.ts | 8 +- 5 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 tinycloud-threshold/vectors/check/constants.test.ts diff --git a/tinycloud-threshold/vectors/check/README.md b/tinycloud-threshold/vectors/check/README.md index f8d6cde5..04588cc3 100644 --- a/tinycloud-threshold/vectors/check/README.md +++ b/tinycloud-threshold/vectors/check/README.md @@ -45,6 +45,13 @@ current named record set, field widths, literal metadata and additional-field rules. That shape is a vector-format contract, not a general test-count policy. Expected cryptographic output bytes are not part of the schema. +The Pedersen derivation message is the SPEC literal `706564657273656e2d68` +(`pedersen-h`). Hashing uses compiled SPEC DSTs, and pairing inputs are checked +against independently constructed points. `constants.test.ts` runs real-CLI +rejection cases for alternate labels with matching points, all five DSTs, +row-local DST copies, both pairing inputs, the identity prefix and the three +S0.1 tagged-hash domains, using in-memory documents. + `protocol.test.ts` uses independently chosen identities, scalar shares, transport keys and randomizers. It covers point/scalar encodings, request bindings, both share equations, duplicate members, binding precedence, diff --git a/tinycloud-threshold/vectors/check/constants.test.ts b/tinycloud-threshold/vectors/check/constants.test.ts new file mode 100644 index 00000000..e64a3ea5 --- /dev/null +++ b/tinycloud-threshold/vectors/check/constants.test.ts @@ -0,0 +1,118 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; +import { parseVectors, vector, type VectorFile } from './schema.ts'; +import { R, be, bytes, concat, encodeGT, h3, hex, scalar, tagged, xmd } from './protocol.ts'; + +const input: unknown = JSON.parse(readFileSync(new URL('../v1/primitives.json', import.meta.url), 'utf8')); +const fixture = parseVectors(input); +const cli = new URL('./cli.ts', import.meta.url).pathname; +function rejectDocument(file: VectorFile, component: string) { + const result = Bun.spawnSync([process.execPath, cli, '/dev/stdin'], { + env: process.env, stdin: Buffer.from(JSON.stringify(file)), stdout: 'pipe', stderr: 'pipe', timeout: 30000, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain(component); +} + +test('Pedersen message is pedersen-h even when another label has a matching point', () => { + const file = structuredClone(fixture), row = vector(file, 'pedersen_h'); + const message = '706564657273656e2d69'; + Reflect.set(row.inputs, 'message', message); + row.outputs.point = hex(bls.G2.hashToCurve(bytes(message), { DST: 'TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_' }).toBytes()); + rejectDocument(file, 'pedersen_h'); +}); + +const specDsts = { + h1: 'TC-THRESHOLD-V1-IBE-ID_BLS12381G1_XMD:SHA-256_SSWU_RO_', + pedersen_h: 'TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_', + h2: 'TC-THRESHOLD-V1-IBE-H2', h3: 'TC-THRESHOLD-V1-IBE-H3', h4: 'TC-THRESHOLD-V1-IBE-H4', +}; +for (const key of ['h1', 'pedersen_h', 'h2', 'h3', 'h4'] as const) { + test(`SPEC DST ${key} stays fixed with a matching alternate-domain output`, () => { + const file = structuredClone(fixture), alternate = `${specDsts[key]}X`; + file.dst[key] = alternate; + switch (key) { + case 'h1': { + const row = vector(file, 'h1_custom_dst'); + row.inputs.dst = alternate; + row.outputs.point = hex(bls.G1.hashToCurve(bytes(row.inputs.message), { DST: alternate }).toBytes()); + break; + } + case 'pedersen_h': { + const row = vector(file, 'pedersen_h'); + row.inputs.dst = alternate; + row.outputs.point = hex(bls.G2.hashToCurve(bytes(row.inputs.message), { DST: alternate }).toBytes()); + break; + } + case 'h2': { + const row = vector(file, 'h2_gt'); + row.outputs.bytes = hex(xmd(bytes(row.inputs.message), alternate, 32)); + break; + } + case 'h3': { + const row = vector(file, 'h3_scalar_r'); + row.outputs.scalar = hex(be(BigInt(`0x${hex(xmd(bytes(row.inputs.message), alternate, 48))}`) % R, 32)); + break; + } + case 'h4': { + const row = vector(file, 'h4_sigma'); + row.outputs.bytes = hex(xmd(bytes(row.inputs.message), alternate, 32)); + break; + } + } + rejectDocument(file, `dst.${key}`); + }); +} +for (const name of ['h1_custom_dst', 'pedersen_h'] as const) { + test(`row-local ${name} DST is pinned independently of metadata`, () => { + const file = structuredClone(fixture), row = vector(file, name); + row.inputs.dst += 'X'; + row.outputs.point = hex((name === 'h1_custom_dst' ? bls.G1.hashToCurve(bytes(row.inputs.message), { DST: row.inputs.dst }) : bls.G2.hashToCurve(bytes(row.inputs.message), { DST: row.inputs.dst })).toBytes()); + rejectDocument(file, name); + }); +} + +for (const name of ['pairing_generators', 'pairing_5_7'] as const) { + for (const group of ['g1', 'g2'] as const) { + test(`${name} ${group} is fixed even with a matching alternate pairing`, () => { + const file = structuredClone(fixture), row = vector(file, name); + const a = bls.G1.Point.BASE.multiply(name === 'pairing_generators' ? (group === 'g1' ? 2n : 1n) : (group === 'g1' ? 6n : 5n)); + const b = bls.G2.Point.BASE.multiply(name === 'pairing_generators' ? (group === 'g2' ? 2n : 1n) : (group === 'g2' ? 8n : 7n)); + row.inputs.g1 = hex(a.toBytes()); row.inputs.g2 = hex(b.toBytes()); + row.outputs.gt = hex(encodeGT(bls.pairing(a, b, true))); + rejectDocument(file, name); + }); + } +} + +test('identity encoding prefix is fixed rather than selected by the document', () => { + const file = structuredClone(fixture), row = vector(file, 'fullident_wrap_unwrap'); + const identity = bytes(row.inputs.identity); + identity[29] = identity[29]! ^ 1; + row.inputs.identity = hex(identity); + const q = bls.G1.hashToCurve(identity, { DST: specDsts.h1 }); + const sigma = bytes(row.inputs.sigma), message = bytes(row.inputs.message), rho = h3(concat(sigma, message)); + const mask = xmd(encodeGT(bls.pairing(q.multiply(rho), bls.G2.Point.fromBytes(bytes(row.inputs.mpk)), true)), specDsts.h2, 32); + const pad = xmd(sigma, specDsts.h4, 32); + row.outputs.ciphertext = hex(concat(bls.G2.Point.BASE.multiply(rho).toBytes(), sigma.map((v, i) => v ^ mask[i]!), message.map((v, i) => v ^ pad[i]!))); + row.outputs.derived_key = hex(q.multiply(scalar(bytes(row.inputs.master_scalar))).toBytes()); + rejectDocument(file, 'fullident_wrap_unwrap'); +}); + +for (const tag of ['TC-THRESHOLD-V1-KEY-CONTEXT', 'TC-THRESHOLD-V1-DECRYPT-REQUEST', 'TC-THRESHOLD-V1-DECRYPT-SHARE'] as const) { + test(`SPEC tagged-hash domain ${tag} is fixed`, () => { + const file = structuredClone(fixture), row = vector(file, 'threshold_2of3'); + if (tag === 'TC-THRESHOLD-V1-KEY-CONTEXT') { + row.inputs.context_digest = hex(tagged(`${tag}X`, bytes(row.inputs.context))); + rejectDocument(file, 'threshold_2of3: context digest'); + } else if (tag === 'TC-THRESHOLD-V1-DECRYPT-REQUEST') { + row.inputs.request_digest = hex(tagged(`${tag}X`, bytes(row.inputs.request))); + rejectDocument(file, 'threshold_2of3: request digest'); + } else { + const share = row.outputs.encrypted_shares[0]!; + share.signing_preimage = hex(tagged(`${tag}X`, concat(bytes(row.inputs.request_digest), bytes(row.inputs.certificate_digest), be(BigInt(share.member), 2), bytes(share.encoded)))); + rejectDocument(file, 'threshold_2of3: share 1 signing preimage'); + } + }); +} diff --git a/tinycloud-threshold/vectors/check/protocol.ts b/tinycloud-threshold/vectors/check/protocol.ts index ca739404..04453884 100644 --- a/tinycloud-threshold/vectors/check/protocol.ts +++ b/tinycloud-threshold/vectors/check/protocol.ts @@ -11,6 +11,7 @@ export const DST = { pedersen_h: 'TC-THRESHOLD-V1-PEDERSEN-H_BLS12381G2_XMD:SHA-256_SSWU_RO_', h2: 'TC-THRESHOLD-V1-IBE-H2', h3: 'TC-THRESHOLD-V1-IBE-H3', h4: 'TC-THRESHOLD-V1-IBE-H4', }; +export const PEDERSEN_H_MESSAGE = '706564657273656e2d68'; const PREFIX = 'TC-THRESHOLD-V1-IBE-ID-ENC'; const encoder = new TextEncoder(); const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); diff --git a/tinycloud-threshold/vectors/check/schema.ts b/tinycloud-threshold/vectors/check/schema.ts index b88bfdb9..9fc94293 100644 --- a/tinycloud-threshold/vectors/check/schema.ts +++ b/tinycloud-threshold/vectors/check/schema.ts @@ -1,4 +1,4 @@ -import { DST } from './protocol.ts'; +import { DST, PEDERSEN_H_MESSAGE } from './protocol.ts'; type Parser = (value: unknown, path: string) => T; type Parsed

= P extends Parser ? T : never; @@ -38,7 +38,7 @@ const thresholdInputs = object({ }); const cases = { h1_custom_dst: object({ name: literal('h1_custom_dst'), operation: literal('hash_g1'), inputs: object({ message: hexField(100), dst: literal(DST.h1) }), outputs: object({ point: h48 }) }), - pedersen_h: object({ name: literal('pedersen_h'), operation: literal('hash_g2'), inputs: object({ message: hexField(10), dst: literal(DST.pedersen_h) }), outputs: object({ point: h96 }) }), + pedersen_h: object({ name: literal('pedersen_h'), operation: literal('hash_g2'), inputs: object({ message: literal(PEDERSEN_H_MESSAGE), dst: literal(DST.pedersen_h) }), outputs: object({ point: h96 }) }), h3_scalar_r: object({ name: literal('h3_scalar_r'), operation: literal('h3'), inputs: object({ message: hexField(64) }), outputs: object({ scalar: h32 }) }), h2_gt: object({ name: literal('h2_gt'), operation: literal('h2'), inputs: object({ message: hexField(576) }), outputs: object({ bytes: h32 }) }), h4_sigma: object({ name: literal('h4_sigma'), operation: literal('h4'), inputs: object({ message: h32 }), outputs: object({ bytes: h32 }) }), diff --git a/tinycloud-threshold/vectors/check/vectors.ts b/tinycloud-threshold/vectors/check/vectors.ts index b464bf4d..a5b6e9ae 100644 --- a/tinycloud-threshold/vectors/check/vectors.ts +++ b/tinycloud-threshold/vectors/check/vectors.ts @@ -1,7 +1,7 @@ import { bls12_381 as bls } from '@noble/curves/bls12-381.js'; import { parseVectors, vector, type VectorCase } from './schema.ts'; import { - ProtocolError, DST, bytes, hex, be, scalar, g1, g2, h1, h3, xmd, pairing, encodeGT, + ProtocolError, DST, PEDERSEN_H_MESSAGE, bytes, hex, be, scalar, g1, g2, h1, h3, xmd, pairing, encodeGT, decodeIdentity, encodeIdentity, decodeContext, encodeContext, contextDigest, decodeRequest, encodeRequest, requestDigest, encodeShare, signingPreimage, verifyShare, combine, wrap, unwrap, type Keyset, @@ -80,10 +80,10 @@ export function checkVectors(input: unknown): CheckReport { try { switch (c.operation) { case 'hash_g1': - same('H1', hex(bls.G1.hashToCurve(bytes(c.inputs.message), { DST: c.inputs.dst }).toBytes()), c.outputs.point); + same('H1', hex(h1(bytes(c.inputs.message)).toBytes()), c.outputs.point); break; case 'hash_g2': - same('Pedersen h', hex(bls.G2.hashToCurve(bytes(c.inputs.message), { DST: c.inputs.dst }).toBytes()), c.outputs.point); + same('Pedersen h', hex(bls.G2.hashToCurve(bytes(PEDERSEN_H_MESSAGE), { DST: DST.pedersen_h }).toBytes()), c.outputs.point); break; case 'h3': same('H3', hex(be(h3(bytes(c.inputs.message)), 32)), c.outputs.scalar); break; case 'h2': same('H2', hex(xmd(bytes(c.inputs.message), DST.h2, 32)), c.outputs.bytes); break; @@ -93,7 +93,7 @@ export function checkVectors(input: unknown): CheckReport { const b = c.name === 'pairing_generators' ? bls.G2.Point.BASE : bls.G2.Point.BASE.multiply(7n); same('pairing input G1', hex(a.toBytes()), c.inputs.g1); same('pairing input G2', hex(b.toBytes()), c.inputs.g2); - same('reduced pairing GT', hex(encodeGT(pairing(g1(bytes(c.inputs.g1)), g2(bytes(c.inputs.g2))))), c.outputs.gt); + same('reduced pairing GT', hex(encodeGT(pairing(a, b))), c.outputs.gt); break; } case 'fullident': {