diff --git a/Cargo.lock b/Cargo.lock index 887dbff..4222ebd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1700,6 +1700,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "debugless-unwrap" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f400d0750c0c069e8493f2256cb4da6f604b6d2eeb69a0ca8863acde352f8400" + [[package]] name = "der" version = "0.7.10" @@ -1763,6 +1769,17 @@ dependencies = [ "void", ] +[[package]] +name = "derive-getters" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2c35ab6e03642397cdda1dd58abbc05d418aef8e36297f336d5aba060fe8df" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_builder_core_fork_arti" version = "0.11.2" @@ -2378,6 +2395,35 @@ dependencies = [ "num-traits", ] +[[package]] +name = "frost-core" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b73d2895e352c7942537e47c9ec5899b89704228b837d4375c5071cf721152" +dependencies = [ + "byteorder", + "debugless-unwrap", + "derive-getters", + "hex", + "itertools 0.11.0", + "rand_core 0.6.4", + "serde", + "serdect", + "thiserror 1.0.69", + "visibility 0.0.1", + "zeroize", +] + +[[package]] +name = "frost-rerandomized" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c47b3c1c902d8c30630c652a1839f0414eef07d63551730f627ad716febc136a" +dependencies = [ + "frost-core", + "rand_core 0.6.4", +] + [[package]] name = "fs-mistrust" version = "0.12.0" @@ -2758,6 +2804,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hkdf" @@ -3232,6 +3281,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -4043,7 +4101,7 @@ dependencies = [ "sinsemilla", "subtle", "tracing", - "visibility", + "visibility 0.1.1", "zcash_note_encryption", "zcash_spec", "zip32", @@ -4945,6 +5003,7 @@ checksum = "78a5191930e84973293aa5f532b513404460cd2216c1cfb76d08748c15b40b02" dependencies = [ "blake2b_simd", "byteorder", + "frost-rerandomized", "group", "hex", "jubjub", @@ -5703,6 +5762,16 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -7004,7 +7073,7 @@ dependencies = [ "tor-llcrypto", "tor-persist", "tracing", - "visibility", + "visibility 0.1.1", "walkdir", "zeroize", ] @@ -7071,7 +7140,7 @@ dependencies = [ "thiserror 2.0.17", "tor-error", "tor-memquota", - "visibility", + "visibility 0.1.1", "x25519-dalek", "zeroize", ] @@ -7278,7 +7347,7 @@ dependencies = [ "tor-units", "tracing", "typenum", - "visibility", + "visibility 0.1.1", "void", "zeroize", ] @@ -7769,6 +7838,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8881d5cc0ae34e3db2f1de5af81e5117a420d2f937506c2dc20d6f4cfb069051" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "visibility" version = "0.1.1" @@ -8459,6 +8539,8 @@ dependencies = [ "crossterm", "ed25519-zebra", "equihash", + "frost-core", + "frost-rerandomized", "futures-util", "group", "hex", @@ -8478,6 +8560,7 @@ dependencies = [ "rand 0.8.5", "ratatui", "rayon", + "reddsa", "ripemd 0.1.3", "roaring", "rpassword", @@ -8516,6 +8599,7 @@ dependencies = [ "zcash_protocol", "zcash_script", "zcash_transparent", + "zeroize", "zip32", "zip321", ] diff --git a/Cargo.toml b/Cargo.toml index c2ff34e..f9c2e90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,12 @@ uuid = "1" # Zcash orchard = { version = "0.12", default-features = false } + +# FROST threshold signing +frost-core = { version = "0.6.0", features = ["serde"], optional = true } +frost-rerandomized = { version = "0.6.0", optional = true } +reddsa = { version = "0.5.1", features = ["frost"], optional = true } +zeroize = { version = "1", optional = true } pczt = "0.5" sapling = { package = "sapling-crypto", version = "0.6" } transparent = { package = "zcash_transparent", version = "0.6", features = ["test-dependencies"] } @@ -105,6 +111,13 @@ pczt-qr = ["dep:image", "dep:minicbor", "dep:nokhwa", "dep:qrcode", "dep:rqrr", transparent-inputs = [ "zcash_client_sqlite/transparent-inputs", ] +frost = [ + "dep:frost-core", + "dep:frost-rerandomized", + "dep:reddsa", + "dep:zeroize", + "orchard/unstable-frost", +] tui = [ "dep:crossterm", "dep:ratatui", diff --git a/README.md b/README.md index 6a6bdab..f70767a 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ The code developed in this demo resulted in [this](https://github.com/zcash/zcas For a step-by-step guide for how to get started using these tools, see [this walkthrough](doc/walkthrough.md). +For a guide to setting up a FROST threshold-signing wallet (multi-party +Orchard spending), see the [FROST walkthrough](doc/frost-walkthrough.md). + ## License All code in this workspace is licensed under either of diff --git a/doc/frost-walkthrough.md b/doc/frost-walkthrough.md new file mode 100644 index 0000000..1e893ef --- /dev/null +++ b/doc/frost-walkthrough.md @@ -0,0 +1,480 @@ +FROST Threshold Signing Walkthrough +==================================== + +This document walks through using `zcash-devtool` to set up a FROST +threshold-signing wallet for Zcash Orchard. FROST (Flexible Round-Optimized +Schnorr Threshold Signatures) allows a group of participants to jointly control +a shielded wallet such that any `t` out of `n` participants can authorize a +spend, but no fewer than `t` can do so alone. + +This walkthrough demonstrates a 2-of-3 setup: three participants share control +of a wallet, and any two of them can cooperate to sign a transaction. + +Prerequisites +------------- + +You will need `zcash-devtool` built with the `frost` feature: + +```bash +cargo build --release --features frost +``` + +For brevity, the examples below use `zcash-devtool` as the binary name. If +running from the source tree, substitute `cargo run --release --features frost --`. + +Each participant needs an `age` identity file for encrypting their FROST key +material at rest. If you don't already have one, generate it: + +```bash +age-keygen -o ~/frost-identity.txt +``` + +Overview +-------- + +The FROST workflow has two phases: + +1. **Distributed Key Generation (DKG)** -- A one-time interactive ceremony + where all participants cooperate to generate a shared Orchard spending key. + Each participant ends up with a key share and a common viewing key. This + produces a standard Unified Address that anyone can send funds to. + +2. **Threshold Signing** -- When spending funds, a coordinator and at least + `min_signers` participants perform a two-round signing ceremony over a PCZT + (Partially Created Zcash Transaction). + +In both phases, participants exchange JSON messages. The current implementation +uses copy-paste over a secure out-of-band channel (e.g. an encrypted group +chat). No direct network connection between participants is required. + +Key Architecture +---------------- + +An Orchard Full Viewing Key (FVK) consists of three 32-byte components: + +| Component | Purpose | Source | +|-----------|---------|--------| +| `ak` | Spend validating key | FROST DKG group public key | +| `nk` | Nullifier deriving key | Random, generated by coordinator | +| `rivk` | Commit-ivk randomness | Random, generated by coordinator | + +Only `ak` is threshold-distributed via the DKG. The `nk` and `rivk` components +are generated by the coordinator and shared with all participants in plaintext. +This means **every participant has full viewing capability** -- they can all +derive addresses, scan the chain, and see balances. Spending is the only +operation that requires threshold cooperation. + +The resulting FVK is wrapped in a Unified Full Viewing Key (UFVK) and imported +into each participant's wallet as a view-only account. Unified Addresses are +derived from this UFVK in the standard way. + +Phase 1: Distributed Key Generation +------------------------------------ + +The DKG is a three-round interactive protocol. One participant is designated +the coordinator (responsible for generating `nk` and `rivk`); all other +participants are non-coordinators. All participants run the same command with +different flags. + +### Setup + +Decide on: +- **Threshold** (`min_signers`): minimum number of signers needed to spend + (e.g. 2) +- **Total participants** (`max_signers`): total number of key holders (e.g. 3) +- **Participant indices**: each participant gets a unique 1-based index + (1, 2, or 3) +- **Birthday height**: a recent block height for wallet scanning (avoids + scanning the entire chain). You can find the current tip height from a + block explorer or by querying a lightwalletd server. +- **Lightwalletd server**: the `-s` flag specifies a lightwalletd server + alias. The examples below use `-s zecrocks` for `zec.rocks`; see + `zcash-devtool wallet --help` for other options. + +### Coordinator (participant 1) + +```bash +zcash-devtool wallet -w ~/frost-wallet frost-dkg \ + --name "treasury" \ + --min-signers 2 \ + --max-signers 3 \ + --participant-index 1 \ + --identity ~/frost-identity.txt \ + --birthday 2700000 \ + --coordinator \ + -s zecrocks +``` + +### Other participants (participants 2 and 3) + +```bash +zcash-devtool wallet -w ~/frost-wallet frost-dkg \ + --name "treasury" \ + --min-signers 2 \ + --max-signers 3 \ + --participant-index 2 \ + --identity ~/frost-identity.txt \ + --birthday 2700000 \ + -s zecrocks +``` + +(Participant 3 uses `--participant-index 3`.) + +### Interactive rounds + +The DKG proceeds through three rounds of message exchange. The CLI guides +each participant through the process with prompts. + +**Round 1 -- Commitments:** + +Each participant's CLI outputs a Round 1 JSON message. Every participant sends +their message to all other participants. Each participant then pastes the +messages received from the others (one per line). + +``` +=== DKG Round 1: Generating commitments === + +Your Round 1 package (send to all other participants): +{"identifier":"0100000000000000...","package":{"commitment":["abcd..."],"proof_of_knowledge":"ef01..."}} + +Paste 2 Round 1 package(s) from other participants (one per line): +``` + +**Round 2 -- Secret shares:** + +After processing Round 1, each participant's CLI outputs one Round 2 message +per other participant. These are point-to-point: the message for participant 2 +should only be sent to participant 2. + +``` +=== DKG Round 2: Computing secret shares === + +Round 2 package for participant (send privately): +{"from":"...","to":"...","package":{"secret_share":"..."}} + +Paste 2 Round 2 package(s) addressed to you (one per line): +``` + +**Round 3 -- Finalization and FVK exchange:** + +Round 3 runs automatically. The coordinator's CLI then generates random `nk` +and `rivk` values and outputs an FVK share message: + +``` +=== Coordinator: Generating shared nk and rivk === + +FVK share message (send to all participants via secure channel): +{"nk_hex":"...","rivk_hex":"..."} +``` + +Non-coordinator participants paste this message when prompted. + +### What happens at the end + +After the DKG completes, each participant's wallet has: + +1. A **view-only account** in the `zcash_client_sqlite` database, imported from + the UFVK constructed from `ak || nk || rivk`. +2. A `frost.toml` file in the wallet directory containing: + - The participant's FROST key package (encrypted with their `age` identity) + - The group's public key package (unencrypted) + - The shared `nk` and `rivk` values + - The participant's identifier and threshold parameters + +The CLI prints the account UUID, which you'll need for subsequent commands. + +Receiving Funds +--------------- + +After the DKG, generate a Unified Address from the FROST account: + +```bash +zcash-devtool wallet -w ~/frost-wallet generate-address +``` + +This produces a standard Unified Address. Senders do not need to know that it +is backed by a FROST threshold key -- they send to it like any other Zcash +address. + +Every participant can independently generate the same addresses from their +copy of the UFVK. + +Scanning and Balance +-------------------- + +Sync the wallet to detect incoming payments: + +```bash +zcash-devtool wallet -w ~/frost-wallet sync -s zecrocks +``` + +Check the balance: + +```bash +zcash-devtool wallet -w ~/frost-wallet balance +``` + +Every participant can sync and check balances independently, since they all +hold the full viewing key. + +Phase 2: Spending with FROST Threshold Signing +----------------------------------------------- + +Spending from a FROST wallet is a multi-step process involving PCZT creation, +a two-round signing ceremony, and broadcast. + +### Step 1: Create a PCZT + +Any participant with a synced wallet can create the unsigned transaction: + +```bash +zcash-devtool pczt -w ~/frost-wallet create \ + --address \ + --value \ + --memo "Payment from FROST wallet" \ + > tx.pczt +``` + +This outputs a binary PCZT file containing Orchard actions ready for signing. + +### Step 2: Coordinator initiates signing + +The coordinator (who can be any participant -- not necessarily the same one +from the DKG) reads the PCZT from a file and orchestrates the signing ceremony: + +```bash +zcash-devtool pczt -w ~/frost-wallet frost-sign tx.pczt \ + --num-signers 2 \ + > tx_signed.pczt +``` + +The PCZT file is a positional argument. Stdin is reserved for interactive JSON +exchange during the signing ceremony. If you have multiple FROST accounts in +`frost.toml`, specify which one to sign with using `--account `. + +The coordinator's CLI: + +1. Extracts the sighash and spend auth randomizers (`alpha`) from the PCZT +2. Outputs a **Signing Request** (JSON) to send to all participants +3. Waits for Round 1 responses (commitments) from each participant +4. Outputs a **Round 2 Request** (JSON) to send to all participants +5. Waits for Round 2 responses (signature shares) from each participant +6. Aggregates signature shares into final signatures +7. Writes the signed PCZT to stdout + +### Step 3: Participants sign + +Each participating signer runs: + +```bash +zcash-devtool pczt -w ~/frost-wallet frost-participate \ + --identity ~/frost-identity.txt \ + [ACCOUNT_UUID] +``` + +As with `frost-sign`, the `ACCOUNT_UUID` is optional if only one FROST account +exists. + +The participant's CLI: + +1. Loads their encrypted FROST key package from `frost.toml` in the wallet + directory +2. Waits for the Signing Request (paste from coordinator) +3. Generates nonce commitments and outputs a **Round 1 Response** (JSON) +4. Waits for the Round 2 Request (paste from coordinator) +5. Computes signature shares and outputs a **Round 2 Response** (JSON) + +### Step 4: Broadcast + +After the coordinator has the signed PCZT, send it to the network: + +```bash +cat tx_signed.pczt | zcash-devtool pczt -w ~/frost-wallet send -s zecrocks +``` + +Signing Ceremony Message Flow +----------------------------- + +For a 2-of-3 setup with participant A as coordinator and participants B and C +as signers: + +``` + A (coordinator) B (signer) C (signer) + + PCZT (file)-> frost-sign frost-participate frost-participate + | + |--- Signing Request -----> paste paste + | | | + | paste <--- Round 1 Resp -+ | + | paste <--- Round 1 Resp --------------------+ + | + |--- Round 2 Request -----> paste paste + | | | + | paste <--- Round 2 Resp -+ | + | paste <--- Round 2 Resp --------------------+ + | + v + Signed PCZT +``` + +All messages are JSON and exchanged by copy-paste over a secure channel. + +File Layout +----------- + +After completing the DKG, the wallet directory contains: + +``` +~/frost-wallet/ + blockmeta.sqlite # Block metadata (standard) + blocks/ # Compact blocks (standard) + data.sqlite # Wallet database with FROST view-only account + frost.toml # FROST key material and configuration +``` + +The `frost.toml` file stores per-account FROST configuration: + +```toml +[[accounts]] +name = "treasury" +account_uuid = "..." +min_signers = 2 +max_signers = 3 +key_package = """ +-----BEGIN AGE ENCRYPTED FILE----- +... +-----END AGE ENCRYPTED FILE----- +""" +public_key_package = '{"verifying_key":"...","signer_pubkeys":{"...":"..."}}' +nk_bytes = "..." +rivk_bytes = "..." +identifier = "..." +``` + +The `key_package` field contains the participant's secret signing share, +encrypted with their `age` identity. The `public_key_package` field is +unencrypted since it contains only public information. + +Security Considerations +----------------------- + +- **Viewing key is fully shared.** All participants can see the wallet's + complete transaction history. If you need to restrict viewing to a subset + of signers, a different design would be required. + +- **Round 2 DKG messages are secret shares.** These must be sent privately + to their intended recipients. Do not broadcast them. + +- **The `nk`/`rivk` FVK share from the coordinator is sensitive.** Anyone + who obtains these values along with the group public key can derive the + full viewing key and see all transactions. Send it over an encrypted + channel. + +- **Key packages are encrypted at rest.** Each participant's signing share + is encrypted with their `age` identity and stored in `frost.toml`. Protect + both the `frost.toml` and the `age` identity file. + +- **This is experimental software.** As with all of `zcash-devtool`, do not + use this for production wallets or significant funds. + +Testing +------- + +The FROST implementation has an automated test suite covering the cryptographic +protocol, serialization layer, and the bridge into the Zcash wallet. Run all +FROST tests with: + +```bash +cargo test --features frost -- frost +``` + +This runs 20 tests organized in three categories: + +### Protocol and serde round-trips + +These tests verify that the DKG and signing ceremonies work correctly and that +every serialized message type survives a JSON round-trip: + +| Test | What it covers | +|------|----------------| +| `dkg_full_ceremony_2_of_3` | Full 2-of-3 DKG through all three rounds | +| `frost_signing_with_rerandomization` | Complete signing ceremony with spend auth rerandomization | +| `identifier_hex_round_trip` | `IdHex` serialization | +| `key_package_store_round_trip` | `KeyPackageStore` serialization | +| `public_key_package_store_round_trip` | `PublicKeyPackageStore` serialization | +| `signing_commitments_store_round_trip` | `SigningCommitmentsStore` serialization | +| `signing_package_store_round_trip` | `SigningPackageStore` serialization | +| `dkg_round1_package_store_round_trip` | `DkgRound1PackageStore` serialization | +| `dkg_round2_package_store_round_trip` | `DkgRound2PackageStore` serialization | +| `frost_config_round_trip` | `FrostConfig` TOML serialization | +| `encrypt_decrypt_round_trip` | age encryption/decryption of key material | + +### Error handling + +| Test | What it covers | +|------|----------------| +| `id_hex_rejects_invalid_hex` | Bad hex strings are rejected | +| `id_hex_rejects_wrong_length` | Wrong-length identifiers are rejected | +| `key_package_store_rejects_truncated_fields` | Truncated fields are rejected | +| `signing_commitments_store_rejects_bad_hex` | Bad hex in commitments is rejected | + +### Zcash wallet bridge + +These tests verify the two critical integration points between the FROST +protocol and the Zcash wallet -- the boundaries that the protocol and serde +tests alone do not cover: + +| Test | What it covers | +|------|----------------| +| `frost_dkg_to_orchard_fvk_and_address` | DKG group public key (`ak`) combined with coordinator-generated `nk`/`rivk` produces a valid Orchard `FullViewingKey`, derives an address, wraps in a `UnifiedFullViewingKey`, and round-trips the orchard component | +| `frost_signature_to_orchard_spendauth` | FROST aggregate signature serialized to `[u8; 64]` converts to `orchard_redpallas::Signature` (the type consumed by `Signer::apply_orchard_signature()`) and round-trips correctly | +| `frost_signing_sighash_mismatch_detected` | Verifies participants can detect a coordinator changing the sighash between Round 1 and Round 2 (the exact check from `frost_participate.rs`) | +| `frost_aggregate_wrong_signer_set_fails` | Verifies FROST aggregation rejects signature shares from a signer not in the original commitment set | +| `frost_production_fvk_path` | Tests the exact FVK construction path used in production (`frost_dkg.rs`): ak sign bit check, nk/rivk high-bit clearing, age encrypt/decrypt round-trip, and key package store serialization round-trip | + +The FVK construction test includes retry logic for the `ak` sign bit +constraint: `SpendValidatingKey::from_bytes()` requires `b[31] & 0x80 == 0`, +but the FROST DKG has no negation logic, so roughly half of DKG runs produce +an `ak` that would be rejected. In production, the coordinator would need to +handle this; the test retries the DKG until a valid `ak` is produced. + +Troubleshooting +--------------- + +**"No FROST accounts found in frost.toml"** + +Make sure you pass `-w ` to the `wallet` or `pczt` parent command, +not to the subcommand itself. The wallet directory must be the same one used +during the DKG. For example: +```bash +# Correct -- -w is on the pczt parent command: +zcash-devtool pczt -w ~/frost-wallet frost-participate ... + +# Wrong -- -w is missing, so frost.toml won't be found: +zcash-devtool pczt frost-participate ... +``` + +**"Multiple FROST accounts found; please specify account UUID"** + +If you have run the DKG more than once into the same wallet directory, your +`frost.toml` will contain multiple `[[accounts]]` entries. Pass the account +UUID as a positional argument to `frost-sign` or `frost-participate`. + +**"Failed to parse Round N package/response"** + +Check that you are pasting a complete, single-line JSON message. Avoid +trailing whitespace or line breaks within the JSON. Each message must be +pasted as exactly one line. + +**"Received our own Round 1 package"** + +During the DKG, do not paste your own output back to yourself. Only paste +messages from the *other* participants. + +**Commands not found (`frost-dkg`, `frost-sign`, `frost-participate`)** + +These commands are only available when the binary is built with the `frost` +feature flag. Rebuild with: +```bash +cargo build --release --features frost +``` diff --git a/src/commands/pczt.rs b/src/commands/pczt.rs index 1a67dbd..87538db 100644 --- a/src/commands/pczt.rs +++ b/src/commands/pczt.rs @@ -4,6 +4,10 @@ pub(crate) mod combine; pub(crate) mod create; pub(crate) mod create_manual; pub(crate) mod create_max; +#[cfg(feature = "frost")] +pub(crate) mod frost_participate; +#[cfg(feature = "frost")] +pub(crate) mod frost_sign; pub(crate) mod inspect; pub(crate) mod pay_manual; pub(crate) mod prove; @@ -42,6 +46,12 @@ pub(crate) enum Command { Sign(sign::Command), /// Combine two PCZTs Combine(combine::Command), + /// Coordinate FROST threshold signing for Orchard spends in a PCZT + #[cfg(feature = "frost")] + FrostSign(frost_sign::Command), + /// Participate in a FROST threshold signing ceremony + #[cfg(feature = "frost")] + FrostParticipate(frost_participate::Command), /// Extract a finished transaction and send it Send(send::Command), /// Extract a finished transaction and send it, without storing in the wallet. diff --git a/src/commands/pczt/frost_participate.rs b/src/commands/pczt/frost_participate.rs new file mode 100644 index 0000000..aed9beb --- /dev/null +++ b/src/commands/pczt/frost_participate.rs @@ -0,0 +1,164 @@ +use std::io::{self, BufRead}; + +use anyhow::anyhow; +use clap::Args; +use rand::rngs::OsRng; +use uuid::Uuid; + +use frost_core::Group; +use reddsa::frost::redpallas::{round1, round2, PallasGroup}; + +use crate::frost_config::{self, FrostConfig}; +use crate::frost_serde::{ + IdHex, KeyPackageStore, SignRound1Response, SignRound2Request, SignRound2Response, + SigningCommitmentsStore, SigningRequest, +}; + +// Options accepted for the `pczt frost-participate` command +#[derive(Debug, Args)] +pub(crate) struct Command { + /// age identity file to decrypt FROST key material + #[arg(short, long)] + identity: String, + + /// Account UUID to sign with (optional if only one FROST account exists) + account: Option, +} + +impl Command { + pub(crate) fn run(self, wallet_dir: Option) -> Result<(), anyhow::Error> { + // Load FROST config + let frost_config = FrostConfig::read(wallet_dir.as_ref())?; + + let account_config = frost_config.resolve_account( + self.account.as_ref().map(|u| u.to_string()).as_deref(), + )?; + + // Decrypt key package + let identities = age::IdentityFile::from_file(self.identity)?.into_identities()?; + let kp_json = frost_config::decrypt_string( + identities.iter().map(|i| i.as_ref() as &dyn age::Identity), + &account_config.key_package, + )?; + let kp_store: KeyPackageStore = serde_json::from_str(&kp_json)?; + let key_package = kp_store.to_key_package()?; + + let my_id_hex = IdHex(account_config.identifier.clone()); + + eprintln!( + "FROST participant ready (account '{}', {}-of-{})", + account_config.name, + account_config.min_signers, + account_config.max_signers, + ); + + // === Round 1: Receive signing request, generate commitments === + eprintln!("\n=== Round 1: Paste the signing request from the coordinator ==="); + + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + let signing_request: SigningRequest = serde_json::from_str(line.trim()) + .map_err(|e| anyhow!("Failed to parse signing request: {e}"))?; + + let num_actions = signing_request.actions.len(); + eprintln!( + "Signing request received for {} action(s)", + num_actions + ); + + // Generate nonces and commitments for each action + let mut nonces: Vec = Vec::new(); + let mut commitments: Vec = Vec::new(); + + for _ in &signing_request.actions { + let (nonce, commitment) = + round1::commit(key_package.secret_share(), &mut OsRng); + nonces.push(nonce); + commitments.push(SigningCommitmentsStore::from_commitments(&commitment)); + } + + let round1_response = SignRound1Response { + identifier: my_id_hex.clone(), + commitments, + }; + + let response_json = serde_json::to_string(&round1_response)?; + eprintln!("\nYour Round 1 response (send to coordinator):"); + println!("{response_json}"); + + // === Round 2: Receive signing packages, generate signature shares === + eprintln!("\n=== Round 2: Paste the Round 2 package from the coordinator ==="); + + let mut line = String::new(); + io::stdin().lock().read_line(&mut line)?; + let round2_request: SignRound2Request = serde_json::from_str(line.trim()) + .map_err(|e| anyhow!("Failed to parse Round 2 request: {e}"))?; + + if round2_request.packages.len() != num_actions { + return Err(anyhow!( + "Mismatch: {} signing packages vs {} actions", + round2_request.packages.len(), + num_actions, + )); + } + + // Verify that round 2 signing packages contain the expected sighash + let expected_sighash = hex::decode(&signing_request.sighash_hex) + .map_err(|e| anyhow!("Invalid sighash hex in signing request: {e}"))?; + for (i, action_pkg) in round2_request.packages.iter().enumerate() { + let sp = action_pkg.signing_package.to_signing_package()?; + if sp.message() != expected_sighash.as_slice() { + return Err(anyhow!( + "Round 2 signing package for action {} contains a different sighash than Round 1. \ + This may indicate a malicious coordinator.", + i + )); + } + } + + let mut shares: Vec = Vec::new(); + + for (i, action_pkg) in round2_request.packages.iter().enumerate() { + // Deserialize the signing package + let signing_package = action_pkg.signing_package.to_signing_package().map_err(|e| { + anyhow!("Failed to parse signing package for action {}: {e}", i) + })?; + + // Parse the randomizer point + let rp_bytes: [u8; 32] = hex::decode(&action_pkg.randomizer_point_hex)? + .try_into() + .map_err(|_| anyhow!("Invalid randomizer point length"))?; + let randomizer_point = PallasGroup::deserialize(&rp_bytes) + .map_err(|_| anyhow!("Invalid randomizer point"))?; + + let share = round2::sign( + &signing_package, + &nonces[i], + &key_package, + &randomizer_point, + ) + .map_err(|e| { + anyhow!( + "Failed to generate signature share for action {}: {:?}", + i, + e + ) + })?; + + shares.push(hex::encode(share.serialize())); + } + + let round2_response = SignRound2Response { + identifier: my_id_hex, + shares, + }; + + let response_json = serde_json::to_string(&round2_response)?; + eprintln!("\nYour Round 2 response (send to coordinator):"); + println!("{response_json}"); + + eprintln!("\nDone! The coordinator will aggregate your signature share."); + + Ok(()) + } +} diff --git a/src/commands/pczt/frost_sign.rs b/src/commands/pczt/frost_sign.rs new file mode 100644 index 0000000..21ed9f6 --- /dev/null +++ b/src/commands/pczt/frost_sign.rs @@ -0,0 +1,379 @@ +use std::collections::{BTreeMap, HashMap}; +use std::io::{self, BufRead}; +use std::path::PathBuf; + +use anyhow::anyhow; +use clap::Args; +use tokio::io::{stdout, AsyncWriteExt}; +use uuid::Uuid; + +use frost_rerandomized::RandomizedParams; +use reddsa::frost::redpallas::{self, PallasBlake2b512, PallasGroup}; + +use orchard::primitives::redpallas as orchard_redpallas; +use pczt::roles::signer::Signer; +use pczt::Pczt; + +use crate::frost_config::FrostConfig; +use crate::frost_serde::{ + ActionSigningData, ActionSigningPackageMsg, PublicKeyPackageStore, SignRound1Response, + SignRound2Request, SignRound2Response, SigningPackageStore, + SigningRequest, +}; + +// Options accepted for the `pczt frost-sign` command +#[derive(Debug, Args)] +pub(crate) struct Command { + /// Path to the PCZT file to sign (stdin is reserved for interactive JSON) + pczt_file: PathBuf, + + /// Number of signers participating in this ceremony + #[arg(long)] + num_signers: u16, + + /// Account UUID to sign with (optional if only one FROST account exists) + #[arg(long)] + account: Option, +} + +impl Command { + pub(crate) async fn run(self, wallet_dir: Option) -> Result<(), anyhow::Error> { + // Read PCZT from file (stdin is reserved for interactive JSON exchange) + let buf = std::fs::read(&self.pczt_file).map_err(|e| { + anyhow!("Failed to read PCZT file '{}': {e}", self.pczt_file.display()) + })?; + let pczt = Pczt::parse(&buf).map_err(|e| anyhow!("Failed to parse PCZT: {:?}", e))?; + + // Load FROST config + let frost_config = FrostConfig::read(wallet_dir.as_ref())?; + + let account_config = frost_config.resolve_account( + self.account.as_ref().map(|u| u.to_string()).as_deref(), + )?; + + let pkp_store: PublicKeyPackageStore = + serde_json::from_str(&account_config.public_key_package)?; + let public_key_package = pkp_store.to_public_key_package()?; + + if self.num_signers < account_config.min_signers { + return Err(anyhow!( + "num_signers ({}) is less than threshold ({})", + self.num_signers, + account_config.min_signers, + )); + } + + if self.num_signers > account_config.max_signers { + return Err(anyhow!( + "num_signers ({}) exceeds max_signers ({})", + self.num_signers, + account_config.max_signers, + )); + } + + // Extract alpha values from the Orchard actions before consuming the PCZT into a Signer. + // This avoids re-parsing the PCZT just to read action fields. + let action_alphas: Vec<(usize, [u8; 32])> = { + let actions = pczt.orchard().actions(); + let mut alphas = Vec::new(); + // HACK: The pczt crate's Spend type stores alpha as `pub(crate)` with no + // public getter (only nullifier, rk, and proprietary have #[getset(get = "pub")]). + // We work around this by serializing to serde_json::Value and extracting the + // "alpha" key. This breaks if the pczt crate changes its serde representation. + // Upstream fix: add #[getset(get = "pub")] to the `alpha` field in pczt::orchard::Spend. + for (i, action) in actions.iter().enumerate() { + let spend_value = serde_json::to_value(action.spend())?; + let alpha_bytes: [u8; 32] = match spend_value.get("alpha") { + Some(serde_json::Value::Array(arr)) => { + let bytes: Vec = arr + .iter() + .map(|v| { + v.as_u64() + .and_then(|n| u8::try_from(n).ok()) + .ok_or_else(|| anyhow!("Orchard action {} alpha contains non-u8 value", i)) + }) + .collect::, _>>()?; + bytes + .try_into() + .map_err(|_| anyhow!("Orchard action {} alpha has wrong length", i))? + } + Some(serde_json::Value::Null) | None => { + return Err(anyhow!( + "Orchard action {} missing alpha (spend_auth_randomizer)", + i + )); + } + other => { + return Err(anyhow!( + "Orchard action {} unexpected alpha format: {:?}", + i, + other + )); + } + }; + alphas.push((i, alpha_bytes)); + } + alphas + }; + + // Now consume the PCZT into a Signer to extract the sighash + let signer = + Signer::new(pczt).map_err(|e| anyhow!("Failed to initialize Signer: {:?}", e))?; + + let sighash = signer.shielded_sighash(); + let sighash_hex = hex::encode(sighash); + + let num_actions = action_alphas.len(); + + if num_actions == 0 { + eprintln!("No Orchard actions to sign."); + let pczt = signer.finish(); + stdout().write_all(&pczt.serialize()).await?; + return Ok(()); + } + + eprintln!( + "FROST signing ceremony for {} Orchard action(s) with {}-of-{} threshold", + num_actions, account_config.min_signers, account_config.max_signers, + ); + + // Build and output the signing request + let signing_request = SigningRequest { + sighash_hex, + actions: action_alphas + .iter() + .map(|(idx, _)| ActionSigningData { + action_index: *idx, + }) + .collect(), + }; + + let request_json = serde_json::to_string(&signing_request)?; + eprintln!("\n=== Round 1: Collect commitments ==="); + eprintln!( + "Send this signing request to all {} participants:", + self.num_signers + ); + eprintln!("{request_json}"); + + // Collect Round 1 commitments from participants + let num_signers = self.num_signers as usize; + eprintln!( + "\nPaste {} Round 1 response(s) from participants (one per line):", + num_signers + ); + + // Per-action: BTreeMap of identifier -> commitment (BTreeMap required by frost-core) + let mut all_commitments: Vec< + BTreeMap< + frost_core::frost::Identifier, + redpallas::round1::SigningCommitments, + >, + > = vec![BTreeMap::new(); num_actions]; + + let mut received_r1 = 0usize; + let io_stdin = io::stdin(); + for line in io_stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + + let response: SignRound1Response = serde_json::from_str(line.trim()) + .map_err(|e| anyhow!("Failed to parse Round 1 response: {e}"))?; + + let their_id = response.identifier.to_id()?; + + if response.commitments.len() != num_actions { + return Err(anyhow!( + "Participant provided {} commitments, expected {}", + response.commitments.len(), + num_actions + )); + } + + for (action_idx, commitment_store) in response.commitments.iter().enumerate() { + let commitment = commitment_store.to_commitments().map_err(|e| { + anyhow!("Failed to parse commitment for action {}: {e}", action_idx) + })?; + if all_commitments[action_idx].insert(their_id, commitment).is_some() { + return Err(anyhow!("Duplicate Round 1 commitment from participant {}", response.identifier.0)); + } + } + + eprintln!(" Received commitments from participant {}", response.identifier.0); + received_r1 += 1; + if received_r1 >= num_signers { + break; + } + } + + // Build signing packages for each action + eprintln!("\n=== Round 2: Generate signing packages ==="); + + let mut signing_packages: Vec = Vec::new(); + let mut randomized_params_list: Vec> = Vec::new(); + + for (action_idx, (_, alpha_bytes)) in action_alphas.iter().enumerate() { + let commitments = &all_commitments[action_idx]; + + let signing_package = frost_core::frost::SigningPackage::new( + commitments.clone(), + &sighash[..], + ); + signing_packages.push(signing_package); + + // Create randomized params from alpha + let alpha_scalar = + ::deserialize( + alpha_bytes, + ) + .map_err(|_| anyhow!("Invalid alpha scalar in action {}", action_idx))?; + + let randomized_params = + RandomizedParams::from_randomizer(&public_key_package, alpha_scalar); + randomized_params_list.push(randomized_params); + } + + let round2_request = SignRound2Request { + packages: signing_packages + .iter() + .zip(action_alphas.iter()) + .zip(randomized_params_list.iter()) + .map(|((sp, (idx, _)), rp)| { + let rp_bytes: [u8; 32] = + ::serialize(rp.randomizer_point()); + ActionSigningPackageMsg { + action_index: *idx, + signing_package: SigningPackageStore::from_signing_package(sp), + randomizer_point_hex: hex::encode(rp_bytes), + } + }) + .collect(), + }; + + let round2_json = serde_json::to_string(&round2_request)?; + eprintln!("Send this Round 2 package to all participants:"); + eprintln!("{round2_json}"); + + // Collect Round 2 signature shares + eprintln!( + "\nPaste {} Round 2 response(s) from participants (one per line):", + num_signers + ); + + let mut all_shares: Vec< + HashMap< + frost_core::frost::Identifier, + redpallas::round2::SignatureShare, + >, + > = vec![HashMap::new(); num_actions]; + + let mut received_r2 = 0usize; + let io_stdin = io::stdin(); + for line in io_stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + + let response: SignRound2Response = serde_json::from_str(line.trim()) + .map_err(|e| anyhow!("Failed to parse Round 2 response: {e}"))?; + + let their_id = response.identifier.to_id()?; + + if response.shares.len() != num_actions { + return Err(anyhow!( + "Participant provided {} shares, expected {}", + response.shares.len(), + num_actions + )); + } + + for (action_idx, share_hex) in response.shares.iter().enumerate() { + let share_bytes: [u8; 32] = hex::decode(share_hex)? + .try_into() + .map_err(|_| anyhow!("Invalid share length for action {}", action_idx))?; + let share = + redpallas::round2::SignatureShare::deserialize(share_bytes).map_err(|e| { + anyhow!("Failed to parse share for action {}: {:?}", action_idx, e) + })?; + if all_shares[action_idx].insert(their_id, share).is_some() { + return Err(anyhow!("Duplicate Round 2 share from participant {}", response.identifier.0)); + } + } + + eprintln!(" Received shares from participant {}", response.identifier.0); + received_r2 += 1; + if received_r2 >= num_signers { + break; + } + } + + // Cross-round participant validation + for (action_idx, (commitments, shares)) in all_commitments.iter().zip(all_shares.iter()).enumerate() { + let commit_ids: std::collections::HashSet<_> = commitments.keys().collect(); + let share_ids: std::collections::HashSet<_> = shares.keys().collect(); + if commit_ids != share_ids { + return Err(anyhow!( + "Participant mismatch in action {}: Round 1 and Round 2 have different signers", + action_idx + )); + } + } + + // Aggregate signatures and apply to PCZT + eprintln!("\n=== Aggregating signatures ==="); + + // Re-parse PCZT for applying signatures + let pczt = + Pczt::parse(&buf).map_err(|e| anyhow!("PCZT re-parse failed: {:?}", e))?; + let mut signer = + Signer::new(pczt).map_err(|e| anyhow!("Failed to initialize Signer: {:?}", e))?; + + for (action_idx, ((signing_package, randomized_params), shares)) in signing_packages + .iter() + .zip(randomized_params_list.iter()) + .zip(all_shares.iter()) + .enumerate() + { + let frost_signature = redpallas::aggregate( + signing_package, + shares, + &public_key_package, + randomized_params, + ) + .map_err(|e| { + anyhow!( + "Failed to aggregate signature for action {}: {:?}", + action_idx, + e + ) + })?; + + // Convert FROST Signature to redpallas::Signature + // Both are [R (32 bytes) || z (32 bytes)] = 64 bytes + let sig_bytes: [u8; 64] = frost_signature.serialize(); + let orchard_sig = orchard_redpallas::Signature::::from(sig_bytes); + + signer + .apply_orchard_signature(action_idx, orchard_sig) + .map_err(|e| { + anyhow!( + "Failed to apply FROST signature to action {}: {:?}", + action_idx, + e + ) + })?; + + eprintln!(" Action {} signed successfully", action_idx); + } + + let pczt = signer.finish(); + stdout().write_all(&pczt.serialize()).await?; + + eprintln!("\nFROST signing complete!"); + + Ok(()) + } +} diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index 45d5610..5797f8b 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -4,6 +4,8 @@ pub(crate) mod balance; pub(crate) mod derive_path; pub(crate) mod display_mnemonic; pub(crate) mod enhance; +#[cfg(feature = "frost")] +pub(crate) mod frost_dkg; pub(crate) mod gen_account; pub(crate) mod gen_addr; pub(crate) mod import_ufvk; @@ -87,4 +89,8 @@ pub(crate) enum Command { /// Commands that operate directly on the note commitment trees #[command(subcommand)] Tree(tree::Command), + + /// Run FROST distributed key generation to create a threshold signing account + #[cfg(feature = "frost")] + FrostDkg(frost_dkg::Command), } diff --git a/src/commands/wallet/frost_dkg.rs b/src/commands/wallet/frost_dkg.rs new file mode 100644 index 0000000..c8fbfb6 --- /dev/null +++ b/src/commands/wallet/frost_dkg.rs @@ -0,0 +1,411 @@ +use std::collections::HashMap; +use std::io::{self, BufRead}; + +use anyhow::anyhow; +use clap::Args; +use rand::rngs::OsRng; +use rand::RngCore; + +use frost_core::frost::keys::dkg; +use reddsa::frost::redpallas::{Identifier, PallasBlake2b512}; + +use orchard::keys::FullViewingKey; +use zcash_client_backend::{ + data_api::{Account as _, AccountBirthday, AccountPurpose, WalletWrite}, + proto::service, +}; +use zcash_client_sqlite::{util::SystemClock, WalletDb}; +use zcash_keys::keys::UnifiedFullViewingKey; + +use crate::{ + data::get_db_paths, + error, + frost_config::{self, FrostAccountConfig, FrostConfig}, + frost_serde::{ + DkgRound1Msg, DkgRound1PackageStore, DkgRound2Msg, DkgRound2PackageStore, FvkShareMsg, + IdHex, KeyPackageStore, PublicKeyPackageStore, + }, + remote::ConnectionArgs, +}; + +// Options accepted for the `wallet frost-dkg` command +#[derive(Debug, Args)] +pub(crate) struct Command { + /// A name for the FROST account + #[arg(long)] + name: String, + + /// Minimum number of signers (threshold) + #[arg(long)] + min_signers: u16, + + /// Maximum number of signers (total participants) + #[arg(long)] + max_signers: u16, + + /// age identity file for encrypting FROST key material + #[arg(short, long)] + identity: String, + + /// This participant's 1-based index (1..=max_signers) + #[arg(long)] + participant_index: u16, + + /// Whether this participant is the coordinator (generates nk/rivk) + #[arg(long)] + coordinator: bool, + + /// The wallet birthday height + #[arg(long)] + birthday: u32, + + #[command(flatten)] + connection: ConnectionArgs, +} + +impl Command { + pub(crate) async fn run(self, wallet_dir: Option) -> Result<(), anyhow::Error> { + if self.min_signers < 2 { + return Err(anyhow!("min_signers must be at least 2")); + } + if self.max_signers < self.min_signers { + return Err(anyhow!("max_signers must be >= min_signers")); + } + if self.participant_index < 1 || self.participant_index > self.max_signers { + return Err(anyhow!( + "participant_index must be between 1 and max_signers" + )); + } + if self.birthday == 0 { + return Err(anyhow!("birthday must be at least 1")); + } + + let my_identifier = Identifier::try_from(self.participant_index)?; + let my_id_hex = IdHex::from_id(&my_identifier); + + eprintln!( + "Starting FROST DKG for '{}' (threshold {}-of-{}), participant #{}{}", + self.name, + self.min_signers, + self.max_signers, + self.participant_index, + if self.coordinator { + " [coordinator]" + } else { + "" + }, + ); + + // === DKG Round 1 === + eprintln!("\n=== DKG Round 1: Generating commitments ==="); + + let (round1_secret, round1_package) = dkg::part1::( + my_identifier, + self.max_signers, + self.min_signers, + OsRng, + )?; + + let my_round1_msg = DkgRound1Msg { + identifier: my_id_hex.clone(), + package: DkgRound1PackageStore::from_package(&round1_package), + }; + let my_round1_json = serde_json::to_string(&my_round1_msg)?; + eprintln!("\nYour Round 1 package (send to all other participants):"); + println!("{my_round1_json}"); + + // Collect Round 1 packages from other participants + let other_count = (self.max_signers - 1) as usize; + eprintln!( + "\nPaste {other_count} Round 1 package(s) from other participants (one per line):" + ); + let mut round1_packages: HashMap< + frost_core::frost::Identifier, + dkg::round1::Package, + > = HashMap::new(); + + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let msg: DkgRound1Msg = serde_json::from_str(line.trim()) + .map_err(|e| anyhow!("Failed to parse Round 1 package: {e}"))?; + let their_id = msg.identifier.to_id()?; + if their_id == my_identifier { + return Err(anyhow!("Received our own Round 1 package")); + } + let pkg = msg + .package + .to_package() + .map_err(|e| anyhow!("Failed to parse Round 1 package data: {e}"))?; + eprintln!( + " Received Round 1 package from participant {}", + msg.identifier.0 + ); + if round1_packages.insert(their_id, pkg).is_some() { + return Err(anyhow!( + "Duplicate Round 1 package from participant {}", + msg.identifier.0 + )); + } + if round1_packages.len() >= other_count { + break; + } + } + + if round1_packages.len() != other_count { + return Err(anyhow!( + "Expected {} Round 1 packages, got {}", + other_count, + round1_packages.len() + )); + } + + // === DKG Round 2 === + eprintln!("\n=== DKG Round 2: Computing secret shares ==="); + + let (round2_secret, round2_packages) = dkg::part2(round1_secret, &round1_packages)?; + + // Output Round 2 packages (each is for a specific recipient) + eprintln!("\nWARNING: Each Round 2 package below contains a SECRET SHARE."); + eprintln!("Send each package ONLY to its intended recipient via a private channel."); + eprintln!("Do NOT broadcast these -- a recipient's share must not be seen by others.\n"); + for (recipient_id, package) in &round2_packages { + let msg = DkgRound2Msg { + from: my_id_hex.clone(), + to: IdHex::from_id(recipient_id), + package: DkgRound2PackageStore::from_package(package), + }; + let json = serde_json::to_string(&msg)?; + eprintln!( + "--- SECRET: Round 2 package for participant {} ONLY ---", + IdHex::from_id(recipient_id).0 + ); + println!("{json}"); + } + + // Collect Round 2 packages addressed to us + eprintln!( + "\nPaste {other_count} Round 2 package(s) addressed to you (one per line):" + ); + let mut received_round2: HashMap< + frost_core::frost::Identifier, + dkg::round2::Package, + > = HashMap::new(); + + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let msg: DkgRound2Msg = serde_json::from_str(line.trim()) + .map_err(|e| anyhow!("Failed to parse Round 2 package: {e}"))?; + let to_id = msg.to.to_id()?; + if to_id != my_identifier { + return Err(anyhow!("Round 2 package not addressed to us")); + } + let from_id = msg.from.to_id()?; + if !round1_packages.contains_key(&from_id) { + return Err(anyhow!( + "Round 2 package from unknown participant {}", + msg.from.0 + )); + } + let pkg = msg + .package + .to_package() + .map_err(|e| anyhow!("Failed to parse Round 2 package data: {e}"))?; + eprintln!( + " Received Round 2 package from participant {}", + msg.from.0 + ); + if received_round2.insert(from_id, pkg).is_some() { + return Err(anyhow!( + "Duplicate Round 2 package from participant {}", + msg.from.0 + )); + } + if received_round2.len() >= other_count { + break; + } + } + + if received_round2.len() != other_count { + return Err(anyhow!( + "Expected {} Round 2 packages, got {}", + other_count, + received_round2.len() + )); + } + + // === DKG Round 3: Finalize === + eprintln!("\n=== DKG Round 3: Finalizing key generation ==="); + + let (key_package, public_key_package) = + dkg::part3(&round2_secret, &round1_packages, &received_round2)?; + + // Get the group verifying key (this is the Orchard spend validating key / ak) + let ak_bytes: [u8; 32] = public_key_package.group_public().serialize(); + + if ak_bytes[31] & 0x80 != 0 { + return Err(anyhow!( + "DKG produced an ak with invalid sign bit (b[31] & 0x80 != 0). \ + The FROST DKG has no negation logic for this case; please re-run the DKG ceremony." + )); + } + + eprintln!( + "DKG complete. Group public key (ak): {}", + hex::encode(ak_bytes) + ); + + // === Exchange nk/rivk === + let (nk_bytes, rivk_bytes) = if self.coordinator { + eprintln!("\n=== Coordinator: Generating shared nk and rivk ==="); + + let mut nk = [0u8; 32]; + let mut rivk = [0u8; 32]; + OsRng.fill_bytes(&mut nk); + OsRng.fill_bytes(&mut rivk); + nk[31] &= 0x7f; + rivk[31] &= 0x7f; + + let fvk_share = FvkShareMsg { + nk_hex: hex::encode(nk), + rivk_hex: hex::encode(rivk), + }; + let json = serde_json::to_string(&fvk_share)?; + eprintln!("\nFVK share message (send to all participants via secure channel):"); + println!("{json}"); + + (nk, rivk) + } else { + eprintln!("\n=== Waiting for FVK share from coordinator ==="); + eprintln!("Paste the FVK share message from the coordinator:"); + + let mut line = String::new(); + loop { + line.clear(); + io::stdin().lock().read_line(&mut line)?; + if !line.trim().is_empty() { + break; + } + } + let msg: FvkShareMsg = serde_json::from_str(line.trim()) + .map_err(|e| anyhow!("Failed to parse FVK share: {e}"))?; + + let nk: [u8; 32] = hex::decode(&msg.nk_hex)? + .try_into() + .map_err(|_| anyhow!("Invalid nk length"))?; + let rivk: [u8; 32] = hex::decode(&msg.rivk_hex)? + .try_into() + .map_err(|_| anyhow!("Invalid rivk length"))?; + + eprintln!("Received FVK share from coordinator."); + (nk, rivk) + }; + + // === Construct Orchard FullViewingKey === + let mut fvk_bytes = [0u8; 96]; + fvk_bytes[..32].copy_from_slice(&ak_bytes); + fvk_bytes[32..64].copy_from_slice(&nk_bytes); + fvk_bytes[64..96].copy_from_slice(&rivk_bytes); + + let orchard_fvk = FullViewingKey::from_bytes(&fvk_bytes) + .ok_or_else(|| anyhow!("Failed to construct Orchard FVK from DKG output"))?; + + let ufvk = UnifiedFullViewingKey::from_orchard_fvk(orchard_fvk) + .map_err(|e| anyhow!("Failed to create UFVK: {e:?}"))?; + + eprintln!("\n=== Importing wallet account ==="); + + let params = crate::config::get_wallet_network(wallet_dir.as_ref())?; + let (_, db_data) = get_db_paths(wallet_dir.as_ref()); + let mut db_data = WalletDb::for_path(db_data, params, SystemClock, OsRng)?; + + let birthday = { + let mut client = self.connection.connect(params, wallet_dir.as_ref()).await?; + let tip_height = client + .get_latest_block(service::ChainSpec::default()) + .await? + .get_ref() + .height + .try_into() + .map_err(|_| anyhow!("block height from server exceeds u32 range"))?; + + let request = service::BlockId { + height: (self.birthday - 1).into(), + ..Default::default() + }; + let treestate = client.get_tree_state(request).await?.into_inner(); + AccountBirthday::from_treestate(treestate, Some(tip_height)) + .map_err(error::Error::from)? + }; + + // AccountPurpose only has Spending and ViewOnly variants; ViewOnly is the + // correct choice for FROST accounts since spending requires threshold cooperation + // via the FROST signing ceremony rather than a local spending key. + let purpose = AccountPurpose::ViewOnly; + let account = + db_data.import_account_ufvk(&self.name, &ufvk, &birthday, purpose, Some("frost"))?; + let account_uuid = account.id().expose_uuid().to_string(); + + eprintln!("Account '{}' imported (UUID: {})", self.name, account_uuid); + + // === Store FROST key material === + let age_recipients = frost_config::load_age_recipients(&self.identity)?; + + let kp_store = KeyPackageStore::from_key_package(&key_package); + let kp_json = serde_json::to_string(&kp_store)?; + let encrypted_kp = frost_config::encrypt_string( + age_recipients.iter().map(|r| r as &dyn age::Recipient), + &kp_json, + )?; + + let encrypted_nk = frost_config::encrypt_string( + age_recipients.iter().map(|r| r as &dyn age::Recipient), + &hex::encode(nk_bytes), + )?; + + let encrypted_rivk = frost_config::encrypt_string( + age_recipients.iter().map(|r| r as &dyn age::Recipient), + &hex::encode(rivk_bytes), + )?; + + let pkp_store = PublicKeyPackageStore::from_public_key_package(&public_key_package); + let pkp_json = serde_json::to_string(&pkp_store)?; + + let frost_account = FrostAccountConfig { + name: self.name.clone(), + account_uuid: account_uuid.clone(), + min_signers: self.min_signers, + max_signers: self.max_signers, + key_package: encrypted_kp, + public_key_package: pkp_json, + nk_bytes: encrypted_nk, + rivk_bytes: encrypted_rivk, + identifier: my_id_hex.0, + }; + + let mut frost_config_data = FrostConfig::read(wallet_dir.as_ref())?; + if frost_config_data.has_account(&account_uuid) { + return Err(anyhow!( + "FROST account with UUID {} already exists in frost.toml", + account_uuid + )); + } + frost_config_data.accounts.push(frost_account); + frost_config_data.write(wallet_dir.as_ref())?; + + eprintln!("\nFROST key material saved to frost.toml"); + eprintln!( + "DKG complete! Account '{}' is ready for chain scanning.", + self.name + ); + + Ok(()) + } +} diff --git a/src/frost_config.rs b/src/frost_config.rs new file mode 100644 index 0000000..698d5e7 --- /dev/null +++ b/src/frost_config.rs @@ -0,0 +1,233 @@ +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::Path; + +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; + +const FROST_FILE: &str = "frost.toml"; + +/// Per-account FROST configuration stored in frost.toml. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct FrostAccountConfig { + /// Wallet account name + pub name: String, + /// Links to zcash_client_sqlite account UUID + pub account_uuid: String, + /// Threshold (minimum signers required) + pub min_signers: u16, + /// Total number of signers + pub max_signers: u16, + /// KeyPackage serialized as hex bytes (age-encrypted JSON of the raw byte fields) + pub key_package: String, + /// PublicKeyPackage serialized as hex (JSON of raw byte fields) + pub public_key_package: String, + /// Shared nullifier key bytes (age-encrypted hex, 32 bytes) + pub nk_bytes: String, + /// Shared commit-ivk randomness bytes (age-encrypted hex, 32 bytes) + pub rivk_bytes: String, + /// This participant's FROST identifier (hex, 32 bytes scalar serialization) + pub identifier: String, +} + +/// Top-level FROST configuration file. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub(crate) struct FrostConfig { + #[serde(default)] + pub accounts: Vec, +} + +impl FrostConfig { + /// Read the FROST config from the wallet directory. + pub fn read>(wallet_dir: Option

) -> Result { + let path = frost_file_path(wallet_dir); + match File::open(&path) { + Ok(mut file) => { + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let config: FrostConfig = toml::from_str(&contents)?; + Ok(config) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FrostConfig::default()), + Err(e) => Err(e.into()), + } + } + + /// Write the FROST config to the wallet directory. + pub fn write>(&self, wallet_dir: Option

) -> Result<(), anyhow::Error> { + let path = frost_file_path(wallet_dir); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let contents = + toml::to_string(self).map_err(|e| anyhow!("error serializing frost config: {e}"))?; + + let mut options = fs::OpenOptions::new(); + options.create(true).write(true).truncate(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(&path)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + Ok(()) + } + + /// Find an account config by UUID. + pub fn find_account(&self, uuid: &str) -> Option<&FrostAccountConfig> { + self.accounts.iter().find(|a| a.account_uuid == uuid) + } + + /// Check whether an account with the given UUID already exists. + pub fn has_account(&self, uuid: &str) -> bool { + self.accounts.iter().any(|a| a.account_uuid == uuid) + } + + /// Resolve which FROST account to use. + /// + /// If `account_uuid` is Some, looks it up by UUID. Otherwise, returns the + /// sole account or errors if there are zero or multiple accounts. + pub fn resolve_account( + &self, + account_uuid: Option<&str>, + ) -> Result<&FrostAccountConfig, anyhow::Error> { + match account_uuid { + Some(uuid) => self + .find_account(uuid) + .ok_or_else(|| anyhow!("No FROST account found for UUID {}", uuid)), + None => { + if self.accounts.len() == 1 { + Ok(&self.accounts[0]) + } else if self.accounts.is_empty() { + Err(anyhow!("No FROST accounts found in frost.toml")) + } else { + Err(anyhow!( + "Multiple FROST accounts found; please specify account UUID" + )) + } + } + } + } +} + +fn frost_file_path>(wallet_dir: Option

) -> std::path::PathBuf { + let dir = wallet_dir + .as_ref() + .map(|p| p.as_ref()) + .unwrap_or(crate::data::DEFAULT_WALLET_DIR.as_ref()); + dir.join(FROST_FILE) +} + +pub(crate) fn encrypt_string<'a>( + recipients: impl Iterator, + plaintext: &str, +) -> Result { + let encryptor = age::Encryptor::with_recipients(recipients)?; + let mut ciphertext = vec![]; + let mut writer = encryptor.wrap_output(age::armor::ArmoredWriter::wrap_output( + &mut ciphertext, + age::armor::Format::AsciiArmor, + )?)?; + writer.write_all(plaintext.as_bytes())?; + writer.finish().and_then(|armor| armor.finish())?; + String::from_utf8(ciphertext).map_err(|e| anyhow!("age armor produced invalid UTF-8: {e}")) +} + +pub(crate) fn decrypt_string<'a>( + identities: impl Iterator, + ciphertext: &str, +) -> Result { + let decryptor = age::Decryptor::new(age::armor::ArmoredReader::new(ciphertext.as_bytes()))?; + let mut buf = vec![]; + decryptor.decrypt(identities)?.read_to_end(&mut buf)?; + Ok(String::from_utf8(buf)?) +} + +/// Read age x25519 identities from a file and return their public keys as recipients. +pub(crate) fn load_age_recipients( + identity_path: &str, +) -> Result, anyhow::Error> { + let contents = std::fs::read_to_string(identity_path) + .map_err(|e| anyhow!("Failed to read identity file '{}': {e}", identity_path))?; + let mut identities = Vec::new(); + for line in contents.lines() { + if line.starts_with('#') || line.is_empty() { + continue; + } + let id: age::x25519::Identity = line + .parse() + .map_err(|e| anyhow!("Failed to parse age identity line: {e}"))?; + identities.push(id); + } + + if identities.is_empty() { + return Err(anyhow!( + "No age x25519 identities found in '{}'", + identity_path + )); + } + + Ok(identities.iter().map(|id| id.to_public()).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frost_config_round_trip() { + let config = FrostConfig { + accounts: vec![FrostAccountConfig { + name: "test-frost".to_string(), + account_uuid: "12345678-1234-1234-1234-123456789012".to_string(), + min_signers: 2, + max_signers: 3, + key_package: "encrypted-key-package-placeholder".to_string(), + public_key_package: "public-key-package-hex".to_string(), + nk_bytes: "aa".repeat(32), + rivk_bytes: "bb".repeat(32), + identifier: "01".repeat(32), + }], + }; + + let serialized = toml::to_string(&config).unwrap(); + let deserialized: FrostConfig = toml::from_str(&serialized).unwrap(); + + assert_eq!(deserialized.accounts.len(), 1); + let a = &deserialized.accounts[0]; + assert_eq!(a.name, "test-frost"); + assert_eq!(a.account_uuid, "12345678-1234-1234-1234-123456789012"); + assert_eq!(a.min_signers, 2); + assert_eq!(a.max_signers, 3); + assert_eq!(a.key_package, "encrypted-key-package-placeholder"); + assert_eq!(a.public_key_package, "public-key-package-hex"); + assert_eq!(a.nk_bytes, "aa".repeat(32)); + assert_eq!(a.rivk_bytes, "bb".repeat(32)); + assert_eq!(a.identifier, "01".repeat(32)); + } + + #[test] + fn encrypt_decrypt_round_trip() { + let key = age::x25519::Identity::generate(); + let pubkey = key.to_public(); + + let plaintext = "secret key package data"; + let encrypted = encrypt_string( + std::iter::once(&pubkey as &dyn age::Recipient), + plaintext, + ) + .unwrap(); + + let decrypted = decrypt_string( + std::iter::once(&key as &dyn age::Identity), + &encrypted, + ) + .unwrap(); + + assert_eq!(plaintext, decrypted); + } +} diff --git a/src/frost_serde.rs b/src/frost_serde.rs new file mode 100644 index 0000000..cee505a --- /dev/null +++ b/src/frost_serde.rs @@ -0,0 +1,1307 @@ +//! Serialization helpers for FROST types that don't directly implement serde. +//! +//! The `PallasBlake2b512` ciphersuite type doesn't implement Serialize/Deserialize, +//! so we serialize FROST types via their byte representations wrapped in hex strings. + +use std::collections::{BTreeMap, HashMap}; + +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; + +use reddsa::frost::redpallas::{keys, Identifier, PallasBlake2b512}; + +type P = PallasBlake2b512; + +/// Hex-serializable wrapper for a FROST Identifier. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct IdHex(pub(crate) String); + +impl IdHex { + pub fn from_id(id: &Identifier) -> Self { + let bytes = id.serialize(); + IdHex(hex::encode(bytes)) + } + + pub fn to_id(&self) -> Result { + let bytes: [u8; 32] = hex::decode(&self.0)? + .try_into() + .map_err(|_| anyhow!("Invalid identifier length"))?; + Identifier::deserialize(&bytes).map_err(|e| anyhow!("Invalid identifier: {e:?}")) + } +} + +/// Hex-serializable wrapper for a DKG round1::Package. +#[derive(Serialize, Deserialize)] +pub(crate) struct DkgRound1PackageStore { + /// Coefficient commitments as hex-encoded group elements + pub commitment: Vec, + /// Proof of knowledge signature (R || z) as hex + pub proof_of_knowledge: String, +} + +impl DkgRound1PackageStore { + pub fn from_package( + pkg: &frost_core::frost::keys::dkg::round1::Package

, + ) -> Self { + let commitment: Vec = pkg + .commitment() + .serialize() + .iter() + .map(|bytes| hex::encode(bytes)) + .collect(); + let proof = pkg.proof_of_knowledge().serialize(); + DkgRound1PackageStore { + commitment, + proof_of_knowledge: hex::encode(proof), + } + } + + pub fn to_package( + &self, + ) -> Result, anyhow::Error> { + use frost_core::frost::keys::VerifiableSecretSharingCommitment; + use frost_core::Signature; + + let coeff_bytes: Vec<[u8; 32]> = self + .commitment + .iter() + .map(|h| { + hex::decode(h) + .map_err(|e| anyhow!("Invalid commitment hex: {e}")) + .and_then(|b| { + b.try_into() + .map_err(|_| anyhow!("Invalid commitment length")) + }) + }) + .collect::, _>>()?; + + let commitment = VerifiableSecretSharingCommitment::

::deserialize(coeff_bytes) + .map_err(|e| anyhow!("Invalid commitment: {e:?}"))?; + + let sig_bytes: [u8; 64] = hex::decode(&self.proof_of_knowledge)? + .try_into() + .map_err(|_| anyhow!("Invalid proof of knowledge length"))?; + let proof = Signature::

::deserialize(sig_bytes) + .map_err(|e| anyhow!("Invalid proof of knowledge: {e:?}"))?; + + Ok(frost_core::frost::keys::dkg::round1::Package::new( + commitment, proof, + )) + } +} + +/// Hex-serializable DKG Round 1 broadcast message. +#[derive(Serialize, Deserialize)] +pub(crate) struct DkgRound1Msg { + pub identifier: IdHex, + pub package: DkgRound1PackageStore, +} + +/// Hex-serializable wrapper for a DKG round2::Package. +#[derive(Serialize, Deserialize)] +pub(crate) struct DkgRound2PackageStore { + /// Secret share as hex-encoded scalar bytes + pub secret_share: String, +} + +impl DkgRound2PackageStore { + pub fn from_package( + pkg: &frost_core::frost::keys::dkg::round2::Package

, + ) -> Self { + DkgRound2PackageStore { + secret_share: hex::encode(pkg.secret_share().serialize()), + } + } + + pub fn to_package( + &self, + ) -> Result, anyhow::Error> { + use frost_core::frost::keys::SigningShare; + + let ss_bytes: [u8; 32] = hex::decode(&self.secret_share)? + .try_into() + .map_err(|_| anyhow!("Invalid secret share length"))?; + let signing_share = SigningShare::

::deserialize(ss_bytes) + .map_err(|e| anyhow!("Invalid secret share: {e:?}"))?; + Ok(frost_core::frost::keys::dkg::round2::Package::new( + signing_share, + )) + } +} + +/// Hex-serializable DKG Round 2 point-to-point message. +#[derive(Serialize, Deserialize)] +pub(crate) struct DkgRound2Msg { + pub from: IdHex, + pub to: IdHex, + pub package: DkgRound2PackageStore, +} + +/// Message from coordinator sharing nk and rivk with participants. +#[derive(Serialize, Deserialize)] +pub(crate) struct FvkShareMsg { + pub nk_hex: String, + pub rivk_hex: String, +} + +/// Signing request from coordinator to participants. +#[derive(Serialize, Deserialize)] +pub(crate) struct SigningRequest { + pub sighash_hex: String, + pub actions: Vec, +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct ActionSigningData { + pub action_index: usize, +} + +/// Hex-serializable wrapper for SigningCommitments (hiding + binding nonce commitments). +#[derive(Serialize, Deserialize)] +pub(crate) struct SigningCommitmentsStore { + pub hiding: String, + pub binding: String, +} + +impl SigningCommitmentsStore { + pub fn from_commitments( + c: &frost_core::frost::round1::SigningCommitments

, + ) -> Self { + SigningCommitmentsStore { + hiding: hex::encode(c.hiding().serialize()), + binding: hex::encode(c.binding().serialize()), + } + } + + pub fn to_commitments( + &self, + ) -> Result, anyhow::Error> { + use frost_core::frost::round1::{NonceCommitment, SigningCommitments}; + + let hiding_bytes: [u8; 32] = hex::decode(&self.hiding)? + .try_into() + .map_err(|_| anyhow!("Invalid hiding commitment length"))?; + let hiding = NonceCommitment::

::deserialize(hiding_bytes) + .map_err(|e| anyhow!("Invalid hiding commitment: {e:?}"))?; + + let binding_bytes: [u8; 32] = hex::decode(&self.binding)? + .try_into() + .map_err(|_| anyhow!("Invalid binding commitment length"))?; + let binding = NonceCommitment::

::deserialize(binding_bytes) + .map_err(|e| anyhow!("Invalid binding commitment: {e:?}"))?; + + Ok(SigningCommitments::new(hiding, binding)) + } +} + +/// Hex-serializable wrapper for a SigningPackage (commitments map + message). +#[derive(Serialize, Deserialize)] +pub(crate) struct SigningPackageStore { + /// Map from identifier hex to commitment store + pub commitments: BTreeMap, + /// The message (sighash) as hex + pub message: String, +} + +impl SigningPackageStore { + pub fn from_signing_package( + sp: &frost_core::frost::SigningPackage

, + ) -> Self { + let mut commitments = BTreeMap::new(); + for (id, c) in sp.signing_commitments() { + let id_hex = hex::encode(id.serialize()); + commitments.insert(id_hex, SigningCommitmentsStore::from_commitments(c)); + } + SigningPackageStore { + commitments, + message: hex::encode(sp.message()), + } + } + + pub fn to_signing_package( + &self, + ) -> Result, anyhow::Error> { + let mut map = BTreeMap::new(); + for (id_hex, c_store) in &self.commitments { + let id_bytes: [u8; 32] = hex::decode(id_hex)? + .try_into() + .map_err(|_| anyhow!("Invalid identifier length"))?; + let id = Identifier::deserialize(&id_bytes) + .map_err(|e| anyhow!("Invalid identifier: {e:?}"))?; + let commitment = c_store.to_commitments()?; + map.insert(id, commitment); + } + let message = hex::decode(&self.message)?; + Ok(frost_core::frost::SigningPackage::new(map, &message)) + } +} + +/// Participant's Round 1 response (nonce commitments). +#[derive(Serialize, Deserialize)] +pub(crate) struct SignRound1Response { + pub identifier: IdHex, + /// One commitment per action, serialized as SigningCommitmentsStore JSON + pub commitments: Vec, +} + +/// Coordinator's Round 2 package (signing packages for each action). +#[derive(Serialize, Deserialize)] +pub(crate) struct SignRound2Request { + pub packages: Vec, +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct ActionSigningPackageMsg { + pub action_index: usize, + /// The signing package serialized as SigningPackageStore JSON + pub signing_package: SigningPackageStore, + /// The randomizer point (alpha * G) as hex bytes + pub randomizer_point_hex: String, +} + +/// Participant's Round 2 response (signature shares). +#[derive(Serialize, Deserialize)] +pub(crate) struct SignRound2Response { + pub identifier: IdHex, + /// One signature share per action as hex bytes + pub shares: Vec, +} + +// === Serialization helpers for KeyPackage and PublicKeyPackage === + +/// A serializable representation of a KeyPackage. +#[derive(Serialize, Deserialize)] +pub(crate) struct KeyPackageStore { + pub identifier: String, + pub signing_share: String, + pub verifying_share: String, + pub verifying_key: String, +} + +impl Drop for KeyPackageStore { + fn drop(&mut self) { + use zeroize::Zeroize; + self.signing_share.zeroize(); + } +} + +impl KeyPackageStore { + pub fn from_key_package(kp: &keys::KeyPackage) -> Self { + KeyPackageStore { + identifier: hex::encode(kp.identifier().serialize()), + signing_share: hex::encode(kp.secret_share().serialize()), + verifying_share: hex::encode(kp.public().serialize()), + verifying_key: hex::encode(kp.group_public().serialize()), + } + } + + pub fn to_key_package(&self) -> Result { + use frost_core::frost::keys::{KeyPackage, SigningShare, VerifyingShare}; + use frost_core::VerifyingKey; + + let id_bytes: [u8; 32] = hex::decode(&self.identifier)? + .try_into() + .map_err(|_| anyhow!("Invalid identifier length"))?; + let identifier = + Identifier::deserialize(&id_bytes).map_err(|e| anyhow!("Invalid identifier: {e:?}"))?; + + let ss_bytes: [u8; 32] = hex::decode(&self.signing_share)? + .try_into() + .map_err(|_| anyhow!("Invalid signing share length"))?; + let signing_share = SigningShare::

::deserialize(ss_bytes) + .map_err(|e| anyhow!("Invalid signing share: {e:?}"))?; + + let vs_bytes: [u8; 32] = hex::decode(&self.verifying_share)? + .try_into() + .map_err(|_| anyhow!("Invalid verifying share length"))?; + let verifying_share = VerifyingShare::

::deserialize(vs_bytes) + .map_err(|e| anyhow!("Invalid verifying share: {e:?}"))?; + + let vk_bytes: [u8; 32] = hex::decode(&self.verifying_key)? + .try_into() + .map_err(|_| anyhow!("Invalid verifying key length"))?; + let verifying_key = VerifyingKey::

::deserialize(vk_bytes) + .map_err(|e| anyhow!("Invalid verifying key: {e:?}"))?; + + Ok(KeyPackage::new( + identifier, + signing_share, + verifying_share, + verifying_key, + )) + } +} + +/// A serializable representation of a PublicKeyPackage. +#[derive(Serialize, Deserialize)] +pub(crate) struct PublicKeyPackageStore { + pub verifying_key: String, + pub signer_pubkeys: BTreeMap, +} + +impl PublicKeyPackageStore { + pub fn from_public_key_package(pkp: &keys::PublicKeyPackage) -> Self { + let mut signer_pubkeys = BTreeMap::new(); + for (id, share) in pkp.signer_pubkeys() { + let id_hex = hex::encode(id.serialize()); + let share_hex = hex::encode(share.serialize()); + signer_pubkeys.insert(id_hex, share_hex); + } + PublicKeyPackageStore { + verifying_key: hex::encode(pkp.group_public().serialize()), + signer_pubkeys, + } + } + + pub fn to_public_key_package(&self) -> Result { + use frost_core::frost::keys::VerifyingShare; + use frost_core::VerifyingKey; + + let vk_bytes: [u8; 32] = hex::decode(&self.verifying_key)? + .try_into() + .map_err(|_| anyhow!("Invalid verifying key length"))?; + let verifying_key = VerifyingKey::

::deserialize(vk_bytes) + .map_err(|e| anyhow!("Invalid verifying key: {e:?}"))?; + + let mut signer_pubkeys = HashMap::new(); + for (id_hex, share_hex) in &self.signer_pubkeys { + let id_bytes: [u8; 32] = hex::decode(id_hex)? + .try_into() + .map_err(|_| anyhow!("Invalid signer id length"))?; + let identifier = Identifier::deserialize(&id_bytes) + .map_err(|e| anyhow!("Invalid signer id: {e:?}"))?; + + let share_bytes: [u8; 32] = hex::decode(share_hex)? + .try_into() + .map_err(|_| anyhow!("Invalid signer pubkey length"))?; + let verifying_share = VerifyingShare::

::deserialize(share_bytes) + .map_err(|e| anyhow!("Invalid signer pubkey: {e:?}"))?; + signer_pubkeys.insert(identifier, verifying_share); + } + + Ok(keys::PublicKeyPackage::new(signer_pubkeys, verifying_key)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use frost_core::frost::keys::dkg; + use rand::rngs::OsRng; + use reddsa::frost::redpallas::{self, round1, round2}; + + /// Run a full 2-of-3 DKG ceremony, serializing all messages through our + /// serde types (exactly as the CLI commands do). Returns per-participant + /// key packages and the shared public key package. + fn run_dkg_2_of_3() -> ( + HashMap, + redpallas::keys::PublicKeyPackage, + ) { + let min_signers = 2u16; + let max_signers = 3u16; + + let ids: Vec = (1..=max_signers) + .map(|i| Identifier::try_from(i).unwrap()) + .collect(); + + // === Round 1 === + let mut round1_secrets = HashMap::new(); + let mut round1_packages = HashMap::new(); + + for &id in &ids { + let (secret, package) = + dkg::part1::(id, max_signers, min_signers, OsRng).unwrap(); + + // Serialize through our serde types (round-trip) + let store = DkgRound1PackageStore::from_package(&package); + let json = serde_json::to_string(&DkgRound1Msg { + identifier: IdHex::from_id(&id), + package: store, + }) + .unwrap(); + let msg: DkgRound1Msg = serde_json::from_str(&json).unwrap(); + let deserialized_package = msg.package.to_package().unwrap(); + + round1_secrets.insert(id, secret); + round1_packages.insert(id, deserialized_package); + } + + // === Round 2 === + let mut round2_secrets = HashMap::new(); + // per-recipient packages: recipient -> (sender -> package) + let mut round2_per_recipient: HashMap< + Identifier, + HashMap>, + > = HashMap::new(); + + for &sender_id in &ids { + let others: HashMap<_, _> = round1_packages + .iter() + .filter(|(&k, _)| k != sender_id) + .map(|(&k, v)| (k, v.clone())) + .collect(); + + let (secret, packages) = + dkg::part2(round1_secrets.remove(&sender_id).unwrap(), &others).unwrap(); + + round2_secrets.insert(sender_id, secret); + + for (recipient_id, pkg) in packages { + // Serialize through our serde types (round-trip) + let store = DkgRound2PackageStore::from_package(&pkg); + let json = serde_json::to_string(&DkgRound2Msg { + from: IdHex::from_id(&sender_id), + to: IdHex::from_id(&recipient_id), + package: store, + }) + .unwrap(); + let msg: DkgRound2Msg = serde_json::from_str(&json).unwrap(); + let deserialized_pkg = msg.package.to_package().unwrap(); + + round2_per_recipient + .entry(recipient_id) + .or_default() + .insert(sender_id, deserialized_pkg); + } + } + + // === Round 3 === + let mut key_packages = HashMap::new(); + let mut public_key_package = None; + + for &id in &ids { + let others_round1: HashMap<_, _> = round1_packages + .iter() + .filter(|(&k, _)| k != id) + .map(|(&k, v)| (k, v.clone())) + .collect(); + + let received_round2 = round2_per_recipient.remove(&id).unwrap(); + + let (kp, pkp) = + dkg::part3(&round2_secrets[&id], &others_round1, &received_round2).unwrap(); + + key_packages.insert(id, kp); + public_key_package = Some(pkp); + } + + (key_packages, public_key_package.unwrap()) + } + + #[test] + fn dkg_full_ceremony_2_of_3() { + let (key_packages, public_key_package) = run_dkg_2_of_3(); + + // All participants should have the same group public key + let group_key = public_key_package.group_public().serialize(); + for kp in key_packages.values() { + assert_eq!( + kp.group_public().serialize(), + group_key, + "Group public key mismatch across participants" + ); + } + + assert_eq!(key_packages.len(), 3, "Expected 3 key packages from DKG"); + + // KeyPackageStore round-trips are tested in key_package_store_round_trip; + // here we just verify the DKG produced valid, distinct per-participant shares. + let shares: Vec<_> = key_packages + .values() + .map(|kp| kp.secret_share().serialize()) + .collect(); + assert_ne!(shares[0], shares[1], "Signing shares should be distinct"); + assert_ne!(shares[1], shares[2], "Signing shares should be distinct"); + + // Verify PublicKeyPackageStore round-trip + let pkp_store = PublicKeyPackageStore::from_public_key_package(&public_key_package); + let json = serde_json::to_string(&pkp_store).unwrap(); + let restored_store: PublicKeyPackageStore = serde_json::from_str(&json).unwrap(); + let restored = restored_store.to_public_key_package().unwrap(); + + assert_eq!( + public_key_package.group_public().serialize(), + restored.group_public().serialize() + ); + let orig_signers = public_key_package.signer_pubkeys(); + let restored_signers = restored.signer_pubkeys(); + assert_eq!(orig_signers.len(), restored_signers.len()); + for (id, share) in orig_signers { + let restored_share = restored_signers.get(id).expect("Missing signer pubkey"); + assert_eq!(share.serialize(), restored_share.serialize()); + } + } + + #[test] + fn frost_signing_with_rerandomization() { + use frost_core::{Field, Group}; + use frost_rerandomized::RandomizedParams; + use reddsa::frost::redpallas::{PallasGroup, PallasScalarField}; + + let (key_packages, public_key_package) = run_dkg_2_of_3(); + + // Use participants 1 and 2 as signers + let signer_ids: Vec = (1..=2u16) + .map(|i| Identifier::try_from(i).unwrap()) + .collect(); + + // Fake sighash (32 bytes) + let sighash = [0x42u8; 32]; + + // Create a random alpha scalar for the spend auth randomizer + let alpha_scalar_orig = ::random(&mut OsRng); + let alpha_bytes = ::serialize(&alpha_scalar_orig); + + // === Coordinator builds SigningRequest === + let signing_request = SigningRequest { + sighash_hex: hex::encode(sighash), + actions: vec![ActionSigningData { + action_index: 0, + }], + }; + + // Serialize round-trip + let request_json = serde_json::to_string(&signing_request).unwrap(); + let parsed_request: SigningRequest = serde_json::from_str(&request_json).unwrap(); + assert_eq!(parsed_request.sighash_hex, signing_request.sighash_hex); + assert_eq!(parsed_request.actions.len(), 1); + + // === Participants: Round 1 (commit) === + let mut nonces_map: HashMap = HashMap::new(); + // BTreeMap required: frost_core::SigningPackage::new expects BTreeMap + let mut commitments_map: BTreeMap< + Identifier, + frost_core::frost::round1::SigningCommitments

, + > = BTreeMap::new(); + + for &signer_id in &signer_ids { + let kp = &key_packages[&signer_id]; + let (nonce, commitment) = round1::commit(kp.secret_share(), &mut OsRng); + + // Serialize commitment through SignRound1Response round-trip + let response = SignRound1Response { + identifier: IdHex::from_id(&signer_id), + commitments: vec![SigningCommitmentsStore::from_commitments(&commitment)], + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: SignRound1Response = serde_json::from_str(&json).unwrap(); + + let restored_commitment = parsed.commitments[0].to_commitments().unwrap(); + + nonces_map.insert(signer_id, nonce); + commitments_map.insert(signer_id, restored_commitment); + } + + // === Coordinator: Build SigningPackage === + let signing_package = frost_core::frost::SigningPackage::new( + commitments_map.clone(), + &sighash[..], + ); + + // Create RandomizedParams from alpha + let alpha_scalar = + ::deserialize(&alpha_bytes).unwrap(); + let randomized_params = + RandomizedParams::from_randomizer(&public_key_package, alpha_scalar); + let rp_bytes: [u8; 32] = PallasGroup::serialize(randomized_params.randomizer_point()); + + // Build SignRound2Request and round-trip + let round2_request = SignRound2Request { + packages: vec![ActionSigningPackageMsg { + action_index: 0, + signing_package: SigningPackageStore::from_signing_package(&signing_package), + randomizer_point_hex: hex::encode(rp_bytes), + }], + }; + let round2_json = serde_json::to_string(&round2_request).unwrap(); + let parsed_round2: SignRound2Request = serde_json::from_str(&round2_json).unwrap(); + + // === Participants: Round 2 (sign) === + // HashMap here: redpallas::aggregate accepts HashMap for signature shares + let mut signature_shares: HashMap = HashMap::new(); + + for &signer_id in &signer_ids { + let action_pkg = &parsed_round2.packages[0]; + let restored_signing_pkg = action_pkg.signing_package.to_signing_package().unwrap(); + + let rp_bytes_parsed: [u8; 32] = hex::decode(&action_pkg.randomizer_point_hex) + .unwrap() + .try_into() + .unwrap(); + let randomizer_point = PallasGroup::deserialize(&rp_bytes_parsed).unwrap(); + + let share = round2::sign( + &restored_signing_pkg, + &nonces_map[&signer_id], + &key_packages[&signer_id], + &randomizer_point, + ) + .unwrap(); + + // Round-trip through SignRound2Response + let response = SignRound2Response { + identifier: IdHex::from_id(&signer_id), + shares: vec![hex::encode(share.serialize())], + }; + let json = serde_json::to_string(&response).unwrap(); + let parsed: SignRound2Response = serde_json::from_str(&json).unwrap(); + + let share_bytes: [u8; 32] = hex::decode(&parsed.shares[0]) + .unwrap() + .try_into() + .unwrap(); + let restored_share = round2::SignatureShare::deserialize(share_bytes).unwrap(); + + signature_shares.insert(signer_id, restored_share); + } + + // === Coordinator: Aggregate === + // `redpallas::aggregate` internally verifies the resulting signature + // against the randomized group public key, so a successful unwrap here + // already proves cryptographic correctness. + let frost_signature = redpallas::aggregate( + &signing_package, + &signature_shares, + &public_key_package, + &randomized_params, + ) + .expect("Aggregate should succeed and produce a valid signature"); + + let sig_bytes: [u8; 64] = frost_signature.serialize(); + assert_eq!(sig_bytes.len(), 64); + assert!( + sig_bytes.iter().any(|&b| b != 0), + "Signature bytes should be non-zero" + ); + } + + #[test] + fn identifier_hex_round_trip() { + for i in 1..=5u16 { + let id = Identifier::try_from(i).unwrap(); + let hex_id = IdHex::from_id(&id); + let restored = hex_id.to_id().unwrap(); + assert_eq!(id.serialize(), restored.serialize()); + } + } + + #[test] + fn key_package_store_round_trip() { + let (key_packages, _) = run_dkg_2_of_3(); + let kp = key_packages.values().next().unwrap(); + + let store = KeyPackageStore::from_key_package(kp); + let json = serde_json::to_string(&store).unwrap(); + let restored: KeyPackageStore = serde_json::from_str(&json).unwrap(); + let restored_kp = restored.to_key_package().unwrap(); + + assert_eq!(kp.identifier().serialize(), restored_kp.identifier().serialize()); + assert_eq!( + kp.secret_share().serialize(), + restored_kp.secret_share().serialize() + ); + assert_eq!(kp.public().serialize(), restored_kp.public().serialize()); + assert_eq!( + kp.group_public().serialize(), + restored_kp.group_public().serialize() + ); + } + + #[test] + fn signing_commitments_store_round_trip() { + let (key_packages, _) = run_dkg_2_of_3(); + let kp = key_packages.values().next().unwrap(); + + // Nonce intentionally discarded; only testing commitment serialization + let (_nonce, commitment) = round1::commit(kp.secret_share(), &mut OsRng); + let store = SigningCommitmentsStore::from_commitments(&commitment); + let json = serde_json::to_string(&store).unwrap(); + let restored: SigningCommitmentsStore = serde_json::from_str(&json).unwrap(); + let restored_commitment = restored.to_commitments().unwrap(); + + assert_eq!( + commitment.hiding().serialize(), + restored_commitment.hiding().serialize() + ); + assert_eq!( + commitment.binding().serialize(), + restored_commitment.binding().serialize() + ); + } + + #[test] + fn signing_package_store_round_trip() { + let (key_packages, _) = run_dkg_2_of_3(); + + let signer_ids: Vec = (1..=2u16) + .map(|i| Identifier::try_from(i).unwrap()) + .collect(); + + let mut commitments_map = BTreeMap::new(); + for &id in &signer_ids { + let kp = &key_packages[&id]; + // Nonce intentionally discarded; only testing package serialization + let (_nonce, commitment) = round1::commit(kp.secret_share(), &mut OsRng); + commitments_map.insert(id, commitment); + } + + let message = [0xABu8; 32]; + let signing_package = + frost_core::frost::SigningPackage::new(commitments_map, &message[..]); + + let store = SigningPackageStore::from_signing_package(&signing_package); + let json = serde_json::to_string(&store).unwrap(); + let restored: SigningPackageStore = serde_json::from_str(&json).unwrap(); + let restored_pkg = restored.to_signing_package().unwrap(); + + assert_eq!(signing_package.message(), restored_pkg.message()); + let orig_commitments = signing_package.signing_commitments(); + let restored_commitments = restored_pkg.signing_commitments(); + assert_eq!(orig_commitments.len(), restored_commitments.len()); + for (id, c) in orig_commitments { + let rc = restored_commitments.get(id).expect("Missing commitment"); + assert_eq!(c.hiding().serialize(), rc.hiding().serialize()); + assert_eq!(c.binding().serialize(), rc.binding().serialize()); + } + } + + #[test] + fn public_key_package_store_round_trip() { + let (_, public_key_package) = run_dkg_2_of_3(); + + let store = PublicKeyPackageStore::from_public_key_package(&public_key_package); + let json = serde_json::to_string(&store).unwrap(); + let restored: PublicKeyPackageStore = serde_json::from_str(&json).unwrap(); + let restored_pkp = restored.to_public_key_package().unwrap(); + + assert_eq!( + public_key_package.group_public().serialize(), + restored_pkp.group_public().serialize() + ); + let orig_signers = public_key_package.signer_pubkeys(); + let restored_signers = restored_pkp.signer_pubkeys(); + assert_eq!(orig_signers.len(), restored_signers.len()); + for (id, share) in orig_signers { + let restored_share = restored_signers.get(id).expect("Missing signer pubkey"); + assert_eq!(share.serialize(), restored_share.serialize()); + } + } + + #[test] + fn dkg_round1_package_store_round_trip() { + let id = Identifier::try_from(1u16).unwrap(); + let (_secret, package) = dkg::part1::(id, 3, 2, OsRng).unwrap(); + + let store = DkgRound1PackageStore::from_package(&package); + let json = serde_json::to_string(&store).unwrap(); + let restored: DkgRound1PackageStore = serde_json::from_str(&json).unwrap(); + let restored_pkg = restored.to_package().unwrap(); + + // Verify commitment coefficients match + let orig_commitment: Vec<_> = package + .commitment() + .serialize() + .iter() + .map(|b| hex::encode(b)) + .collect(); + let restored_commitment: Vec<_> = restored_pkg + .commitment() + .serialize() + .iter() + .map(|b| hex::encode(b)) + .collect(); + assert_eq!(orig_commitment, restored_commitment); + + // Verify proof of knowledge matches + assert_eq!( + package.proof_of_knowledge().serialize(), + restored_pkg.proof_of_knowledge().serialize() + ); + } + + #[test] + fn dkg_round2_package_store_round_trip() { + // Run a partial DKG to get a round2 package + let ids: Vec = (1..=3u16) + .map(|i| Identifier::try_from(i).unwrap()) + .collect(); + + let mut round1_secrets = HashMap::new(); + let mut round1_packages = HashMap::new(); + for &id in &ids { + let (secret, package) = dkg::part1::(id, 3, 2, OsRng).unwrap(); + round1_secrets.insert(id, secret); + round1_packages.insert(id, package); + } + + let sender = ids[0]; + let others: HashMap<_, _> = round1_packages + .iter() + .filter(|(&k, _)| k != sender) + .map(|(&k, v)| (k, v.clone())) + .collect(); + let (_secret, packages) = + dkg::part2(round1_secrets.remove(&sender).unwrap(), &others).unwrap(); + + let pkg = packages.values().next().unwrap(); + let store = DkgRound2PackageStore::from_package(pkg); + let json = serde_json::to_string(&store).unwrap(); + let restored: DkgRound2PackageStore = serde_json::from_str(&json).unwrap(); + let restored_pkg = restored.to_package().unwrap(); + + assert_eq!( + pkg.secret_share().serialize(), + restored_pkg.secret_share().serialize() + ); + } + + #[test] + fn id_hex_rejects_invalid_hex() { + let bad = IdHex("not_valid_hex".to_string()); + assert!(bad.to_id().is_err()); + } + + #[test] + fn id_hex_rejects_wrong_length() { + // 31 bytes instead of 32 + let short = IdHex("00".repeat(31)); + assert!(short.to_id().is_err()); + + // 33 bytes instead of 32 + let long = IdHex("00".repeat(33)); + assert!(long.to_id().is_err()); + } + + #[test] + fn key_package_store_rejects_truncated_fields() { + let store = KeyPackageStore { + identifier: "00".repeat(32), + signing_share: "ab".repeat(16), // 16 bytes, should be 32 + verifying_share: "00".repeat(32), + verifying_key: "00".repeat(32), + }; + assert!(store.to_key_package().is_err()); + } + + #[test] + fn signing_commitments_store_rejects_bad_hex() { + let store = SigningCommitmentsStore { + hiding: "not_hex".to_string(), + binding: "00".repeat(32), + }; + assert!(store.to_commitments().is_err()); + } + + #[test] + fn frost_dkg_to_orchard_fvk_and_address() { + use orchard::keys::{FullViewingKey, Scope}; + use rand::RngCore; + use zcash_keys::keys::{UnifiedAddressRequest, UnifiedFullViewingKey}; + use zip32::DiversifierIndex; + + // The FVK construction can fail for two reasons: + // 1. DKG produces ak with wrong sign bit (~50% chance) + // SpendValidatingKey::from_bytes requires b[31] & 0x80 == 0 + // 2. The derived ivk is zero or bottom (rare but possible) + // We retry the entire construction, matching how a real coordinator would + // regenerate nk/rivk if FVK construction fails. + let max_attempts = 50; + let mut fvk = None; + let mut fvk_bytes_out = [0u8; 96]; + + for _ in 0..max_attempts { + let (_, public_key_package) = run_dkg_2_of_3(); + let ak_bytes: [u8; 32] = public_key_package.group_public().serialize(); + + // Skip ak with wrong sign bit + if ak_bytes[31] & 0x80 != 0 { + continue; + } + + // Generate random nk and rivk bytes, clearing the top bit to reduce + // the probability of exceeding the Pallas field moduli (~2^254). + // Values can still exceed the modulus; the retry loop handles that. + let mut nk_bytes = [0u8; 32]; + let mut rivk_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut nk_bytes); + OsRng.fill_bytes(&mut rivk_bytes); + nk_bytes[31] &= 0x7f; + rivk_bytes[31] &= 0x7f; + + let mut fvk_bytes = [0u8; 96]; + fvk_bytes[..32].copy_from_slice(&ak_bytes); + fvk_bytes[32..64].copy_from_slice(&nk_bytes); + fvk_bytes[64..96].copy_from_slice(&rivk_bytes); + + if let Some(f) = FullViewingKey::from_bytes(&fvk_bytes) { + fvk = Some(f); + fvk_bytes_out = fvk_bytes; + break; + } + // ivk was zero or bottom; retry with fresh nk/rivk + } + + let fvk = fvk.expect("Failed to construct valid FVK after max attempts"); + + // Derive an address and verify it is 43 bytes + let address = fvk.address_at(DiversifierIndex::new(), Scope::External); + assert_eq!( + address.to_raw_address_bytes().len(), + 43, + "Orchard address should be 43 bytes" + ); + + // Wrap in UnifiedFullViewingKey + let ufvk = UnifiedFullViewingKey::from_orchard_fvk(fvk) + .expect("from_orchard_fvk should succeed"); + + // Derive default unified address + let (_ua, _di) = ufvk + .default_address(UnifiedAddressRequest::AllAvailableKeys) + .expect("default_address should succeed"); + + // Verify the orchard component round-trips + let recovered_fvk = ufvk.orchard().expect("UFVK should contain orchard FVK"); + assert_eq!( + recovered_fvk.to_bytes(), + fvk_bytes_out, + "Orchard FVK bytes should round-trip through UFVK" + ); + } + + #[test] + fn frost_signature_to_orchard_spendauth() { + use frost_core::Field; + use frost_rerandomized::RandomizedParams; + use orchard::primitives::redpallas as orchard_redpallas; + use reddsa::frost::redpallas::PallasScalarField; + + let (key_packages, public_key_package) = run_dkg_2_of_3(); + + // Use participants 1 and 2 as signers + let signer_ids: Vec = (1..=2u16) + .map(|i| Identifier::try_from(i).unwrap()) + .collect(); + + let sighash = [0x42u8; 32]; + + let alpha_scalar = ::random(&mut OsRng); + + // Round 1: commit + let mut nonces_map: HashMap = HashMap::new(); + let mut commitments_map: BTreeMap< + Identifier, + frost_core::frost::round1::SigningCommitments

, + > = BTreeMap::new(); + + for &signer_id in &signer_ids { + let kp = &key_packages[&signer_id]; + let (nonce, commitment) = round1::commit(kp.secret_share(), &mut OsRng); + nonces_map.insert(signer_id, nonce); + commitments_map.insert(signer_id, commitment); + } + + // Build signing package + let signing_package = + frost_core::frost::SigningPackage::new(commitments_map, &sighash[..]); + + let randomized_params = + RandomizedParams::from_randomizer(&public_key_package, alpha_scalar); + let randomizer_point = randomized_params.randomizer_point(); + + // Round 2: sign + let mut signature_shares: HashMap = HashMap::new(); + + for &signer_id in &signer_ids { + let share = round2::sign( + &signing_package, + &nonces_map[&signer_id], + &key_packages[&signer_id], + randomizer_point, + ) + .unwrap(); + signature_shares.insert(signer_id, share); + } + + // Aggregate + let frost_signature = redpallas::aggregate( + &signing_package, + &signature_shares, + &public_key_package, + &randomized_params, + ) + .expect("Aggregate should succeed"); + + // Convert to orchard_redpallas::Signature -- the exact conversion + // used in frost_sign.rs before apply_orchard_signature() + let sig_bytes: [u8; 64] = frost_signature.serialize(); + let orchard_sig = + orchard_redpallas::Signature::::from(sig_bytes); + + // Verify round-trip: extract bytes back and compare + let recovered_bytes: [u8; 64] = (&orchard_sig).into(); + assert_eq!( + sig_bytes, recovered_bytes, + "FROST signature bytes should round-trip through orchard_redpallas::Signature" + ); + } + + #[test] + fn frost_signing_sighash_mismatch_detected() { + use frost_core::{Field, Group}; + use frost_rerandomized::RandomizedParams; + use reddsa::frost::redpallas::{PallasGroup, PallasScalarField}; + + let (key_packages, public_key_package) = run_dkg_2_of_3(); + + let signer_ids: Vec = (1..=2u16) + .map(|i| Identifier::try_from(i).unwrap()) + .collect(); + + // Sighash used in Round 1 (the one the participant trusts) + let sighash_a = [0x42u8; 32]; + + let alpha_scalar = ::random(&mut OsRng); + + // Round 1: generate commitments against sighash_a + let mut nonces_map: HashMap = HashMap::new(); + let mut commitments_map: BTreeMap< + Identifier, + frost_core::frost::round1::SigningCommitments

, + > = BTreeMap::new(); + + for &signer_id in &signer_ids { + let kp = &key_packages[&signer_id]; + let (nonce, commitment) = round1::commit(kp.secret_share(), &mut OsRng); + nonces_map.insert(signer_id, nonce); + commitments_map.insert(signer_id, commitment); + } + + // Coordinator builds Round 2 signing package with a DIFFERENT sighash (sighash_b) + let sighash_b = [0xFFu8; 32]; + let signing_package = + frost_core::frost::SigningPackage::new(commitments_map.clone(), &sighash_b[..]); + + let randomized_params = + RandomizedParams::from_randomizer(&public_key_package, alpha_scalar); + let rp_bytes: [u8; 32] = PallasGroup::serialize(randomized_params.randomizer_point()); + + // Serialize through our serde types (as the coordinator would) + let round2_request = SignRound2Request { + packages: vec![ActionSigningPackageMsg { + action_index: 0, + signing_package: SigningPackageStore::from_signing_package(&signing_package), + randomizer_point_hex: hex::encode(rp_bytes), + }], + }; + + // Participant receives Round 2 and checks sighash matches Round 1 + let action_pkg = &round2_request.packages[0]; + let sp = action_pkg.signing_package.to_signing_package().unwrap(); + + // This is the exact check from frost_participate.rs lines 119-130: + // the participant compares the signing package's message against the expected sighash + assert_ne!( + sp.message(), + &sighash_a[..], + "Participant should detect that the Round 2 sighash differs from the Round 1 sighash" + ); + assert_eq!( + sp.message(), + &sighash_b[..], + "Signing package should contain the coordinator's (potentially malicious) sighash" + ); + } + + #[test] + #[should_panic(expected = "no entry found for key")] + fn frost_aggregate_wrong_signer_set_fails() { + use frost_core::Field; + use frost_rerandomized::RandomizedParams; + use reddsa::frost::redpallas::PallasScalarField; + + let (key_packages, public_key_package) = run_dkg_2_of_3(); + + // Signers 1 and 2 generate commitments (the intended signing set) + let committed_ids: Vec = (1..=2u16) + .map(|i| Identifier::try_from(i).unwrap()) + .collect(); + let signer3_id = Identifier::try_from(3u16).unwrap(); + + let sighash = [0x42u8; 32]; + let alpha_scalar = ::random(&mut OsRng); + + // Round 1: signers 1 and 2 commit + let mut nonces_map: HashMap = HashMap::new(); + let mut commitments_map: BTreeMap< + Identifier, + frost_core::frost::round1::SigningCommitments

, + > = BTreeMap::new(); + + for &signer_id in &committed_ids { + let kp = &key_packages[&signer_id]; + let (nonce, commitment) = round1::commit(kp.secret_share(), &mut OsRng); + nonces_map.insert(signer_id, nonce); + commitments_map.insert(signer_id, commitment); + } + + // Build signing package from signers 1 and 2's commitments + let signing_package = + frost_core::frost::SigningPackage::new(commitments_map.clone(), &sighash[..]); + + let randomized_params = + RandomizedParams::from_randomizer(&public_key_package, alpha_scalar); + + // Signer 1 produces a valid share + let share1 = round2::sign( + &signing_package, + &nonces_map[&committed_ids[0]], + &key_packages[&committed_ids[0]], + randomized_params.randomizer_point(), + ) + .unwrap(); + + // Signer 3 (NOT in the commitment set) produces a share + // They need their own nonce since they weren't part of Round 1 + let (nonce3, _commitment3) = + round1::commit(key_packages[&signer3_id].secret_share(), &mut OsRng); + let share3 = round2::sign( + &signing_package, + &nonce3, + &key_packages[&signer3_id], + randomized_params.randomizer_point(), + ) + .unwrap(); + + // Attempt aggregation with signer 1's share + signer 3's share + // (signer 3 was not in the commitment set) + // The frost-rerandomized library panics when it encounters a signer ID + // not present in the signing package's commitments map. + let mut bad_shares: HashMap = HashMap::new(); + bad_shares.insert(committed_ids[0], share1); + bad_shares.insert(signer3_id, share3); + + let _result = redpallas::aggregate( + &signing_package, + &bad_shares, + &public_key_package, + &randomized_params, + ); + } + + #[test] + fn frost_production_fvk_path() { + use orchard::keys::FullViewingKey; + use rand::RngCore; + + use crate::frost_config::{decrypt_string, encrypt_string}; + + let max_attempts = 50; + let mut fvk = None; + + for _ in 0..max_attempts { + let (key_packages, public_key_package) = run_dkg_2_of_3(); + + // Step 1: Extract ak and check sign bit (matches frost_dkg.rs line 238) + let ak_bytes: [u8; 32] = public_key_package.group_public().serialize(); + if ak_bytes[31] & 0x80 != 0 { + continue; + } + + // Step 2: Generate nk/rivk with OsRng + clear high bits + // (matches frost_dkg.rs lines 254-259) + let mut nk_bytes = [0u8; 32]; + let mut rivk_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut nk_bytes); + OsRng.fill_bytes(&mut rivk_bytes); + nk_bytes[31] &= 0x7f; + rivk_bytes[31] &= 0x7f; + + // Step 3: Construct 96-byte FVK (matches frost_dkg.rs lines 291-294) + let mut fvk_bytes = [0u8; 96]; + fvk_bytes[..32].copy_from_slice(&ak_bytes); + fvk_bytes[32..64].copy_from_slice(&nk_bytes); + fvk_bytes[64..96].copy_from_slice(&rivk_bytes); + + if let Some(f) = FullViewingKey::from_bytes(&fvk_bytes) { + // Step 4: Encrypt nk and rivk with age, decrypt, verify round-trip + let age_key = age::x25519::Identity::generate(); + let age_pubkey = age_key.to_public(); + + let nk_encrypted = encrypt_string( + std::iter::once(&age_pubkey as &dyn age::Recipient), + &hex::encode(nk_bytes), + ) + .unwrap(); + let rivk_encrypted = encrypt_string( + std::iter::once(&age_pubkey as &dyn age::Recipient), + &hex::encode(rivk_bytes), + ) + .unwrap(); + + let nk_decrypted = decrypt_string( + std::iter::once(&age_key as &dyn age::Identity), + &nk_encrypted, + ) + .unwrap(); + let rivk_decrypted = decrypt_string( + std::iter::once(&age_key as &dyn age::Identity), + &rivk_encrypted, + ) + .unwrap(); + + assert_eq!(hex::encode(nk_bytes), nk_decrypted); + assert_eq!(hex::encode(rivk_bytes), rivk_decrypted); + + // Step 5: Serialize key_package and public_key_package through stores, verify round-trip + let kp = key_packages.values().next().unwrap(); + let kp_store = KeyPackageStore::from_key_package(kp); + let kp_json = serde_json::to_string(&kp_store).unwrap(); + + // Encrypt and decrypt the key package (as production does) + let kp_encrypted = encrypt_string( + std::iter::once(&age_pubkey as &dyn age::Recipient), + &kp_json, + ) + .unwrap(); + let kp_decrypted = decrypt_string( + std::iter::once(&age_key as &dyn age::Identity), + &kp_encrypted, + ) + .unwrap(); + let kp_restored: KeyPackageStore = + serde_json::from_str(&kp_decrypted).unwrap(); + let kp_restored = kp_restored.to_key_package().unwrap(); + assert_eq!( + kp.identifier().serialize(), + kp_restored.identifier().serialize() + ); + assert_eq!( + kp.secret_share().serialize(), + kp_restored.secret_share().serialize() + ); + + let pkp_store = + PublicKeyPackageStore::from_public_key_package(&public_key_package); + let pkp_json = serde_json::to_string(&pkp_store).unwrap(); + let pkp_restored: PublicKeyPackageStore = + serde_json::from_str(&pkp_json).unwrap(); + let pkp_restored = pkp_restored.to_public_key_package().unwrap(); + assert_eq!( + public_key_package.group_public().serialize(), + pkp_restored.group_public().serialize() + ); + + fvk = Some(f); + break; + } + } + + assert!( + fvk.is_some(), + "Failed to construct valid FVK via production path after {} attempts", + max_attempts + ); + } +} diff --git a/src/main.rs b/src/main.rs index 1b14527..b94b00d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,10 @@ mod commands; mod config; mod data; mod error; +#[cfg(feature = "frost")] +mod frost_config; +#[cfg(feature = "frost")] +mod frost_serde; mod helpers; mod remote; mod socks; @@ -173,6 +177,10 @@ fn main() -> Result<(), anyhow::Error> { } commands::wallet::tree::Command::Fix(command) => command.run(wallet_dir).await, }, + #[cfg(feature = "frost")] + commands::wallet::Command::FrostDkg(command) => { + command.run(wallet_dir).await + } }, Command::Zip48(commands::Zip48 { wallet_dir, @@ -200,6 +208,12 @@ fn main() -> Result<(), anyhow::Error> { commands::pczt::Command::Prove(command) => command.run(wallet_dir).await, commands::pczt::Command::Sign(command) => command.run(wallet_dir).await, commands::pczt::Command::Combine(command) => command.run().await, + #[cfg(feature = "frost")] + commands::pczt::Command::FrostSign(command) => command.run(wallet_dir).await, + #[cfg(feature = "frost")] + commands::pczt::Command::FrostParticipate(command) => { + command.run(wallet_dir) + } commands::pczt::Command::Send(command) => command.run(wallet_dir).await, commands::pczt::Command::SendWithoutStoring(command) => { command.run(wallet_dir).await