diff --git a/docs/book.toml b/docs/book.toml index d389994c..49031ca4 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -4,3 +4,6 @@ language = "en" multilingual = false src = "src" title = "Neptune Documentation" + +[output.html] +mathjax-support = true diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 3d434c92..a8ed25ff 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,9 @@ - [Reorganization](./neptune-core/reorganization.md) - [Keys and Addresses](./neptune-core/keys.md) - [Utxo Notification](./neptune-core/utxo_notification.md) +- [User Guides](./user-guides.md) + - [Installation](./user-guides/installation.md) + - [Shamir Secret Sharing](./user-guides/shamir-secret-sharing.md) - [Contributing](./contributing.md) - [Git Workflow](./contributing/git-workflow.md) - [Git Message](./contributing/git-message.md) diff --git a/docs/src/user-guides.md b/docs/src/user-guides.md new file mode 100644 index 00000000..f1de6dcd --- /dev/null +++ b/docs/src/user-guides.md @@ -0,0 +1,12 @@ +# User Guides + +Explainers and tutorials on how to use or get started using the various software packages that constitute the client. + +Building the software, or installing it using a script, yields four executables. Two of these executables are user interfaces. The executables are: + + - `neptune-core` is the daemon that runs the protocol. + - `triton-vm-prover` is a binary invoked by `neptune-core` for out-of-process proving tasks. + - `neptune-dashboard` is a terminal user interface that requires a running instance of `neptune-core`. + - `neptune-cli` is a command-line interface that might require a running instance of `neptune-core` depending on the command. + +Except for the [installation instructions](./user-guides/installation.md), the user guides in this section assume these executables are installed. diff --git a/docs/src/user-guides/installation.md b/docs/src/user-guides/installation.md new file mode 100644 index 00000000..51834fbb --- /dev/null +++ b/docs/src/user-guides/installation.md @@ -0,0 +1,34 @@ +# Installation + +## Compile from Source + +### Linux Debian/Ubuntu + + - Open a terminal to run the following commands. + - Install curl: `sudo apt install curl` + - Install the rust compiler and accessories: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y` + - Source the rust environment: `source "$HOME/.cargo/env"` + - Install build tools: `sudo apt install build-essential` + - Install LevelDB: `sudo apt install libleveldb-dev libsnappy-dev cmake` + - Download the repository: `git clone https://github.com/Neptune-Crypto/neptune-core.git` + - Enter the repository: `cd neptune-core` + - Checkout the release branch `git checkout release`. (Alternatively, for the *unstable development* branch, skip this step.) + + - Build for release and put the binaries in your local path (`~/.cargo/bin/`): `cargo install --locked --path .` (needs at least 3 GB of RAM and a few minutes) + +### Windows + +To install Rust and cargo on Windows, you can follow [these instructions](https://doc.rust-lang.org/cargo/getting-started/installation.html). +Installing cargo might require you to install Visual Studio with some C++ support but the cargo installer for Windows should handle that. +With a functioning version of cargo, compilation on Windows should just work out-of-the-box with cargo build etc. +- Download and run the CMake installer from the [website](https://cmake.org/download/). +- Open PowerShell to run the following commands. +- Download the repository: `git clone https://github.com/Neptune-Crypto/neptune-core.git` +- Enter the repository: `cd neptune-core` +- Checkout the release branch `git checkout release`. (Alternatively, for the *unstable development* branch, skip this step.) + +- Run `cargo install --locked --path .` + +## Automatic + +Go to the [releases](https://github.com/Neptune-Crypto/neptune-core/releases) page, scroll down to the section "Assets" and select and install the right package for your system. diff --git a/docs/src/user-guides/shamir-secret-sharing.md b/docs/src/user-guides/shamir-secret-sharing.md new file mode 100644 index 00000000..914048e8 --- /dev/null +++ b/docs/src/user-guides/shamir-secret-sharing.md @@ -0,0 +1,155 @@ +# Shamir Secret Sharing + +Neptune Core supports Shamir secret sharing to distribute shares in the wallet secret. + +## How It Works + +A \\(t\\)-out-of-\\(n\\) Shamir secret sharing scheme works as follows. Let \\(S \in \mathbb{F}\\) be the original secret. In the source code, we use `XFieldElement` as the field \\(\mathbb{F}\\) and `SecretKeyMaterial` as a wrapper around `XFieldElement`s when they are used for this purpose. + +Sample a univariate polynomial \\(f(X)\\) of degree at most \\(t-1\\) uniformly at random except for the constant coefficient. Choose \\(S\\) for the constant coefficient, so that \\(f(0) = S\\). + +With an implicit embedding \\(\mathbb{N} \rightarrow \mathbb{F}\\) we can associate the \\(i\\)th share with the point \\((i, f(i))\\). Note that \\(i=0\\) is disallowed since \\((0, f(0))\\) corresponds to the secret. To generate \\(n\\) shares we let \\(i\\) range from \\(1\\) to \\(n\\) (including the upper bound). + +To reconstruct the original secret it suffices to have *any* \\(t\\) secret shares. Just reconstruct the polynomial and evaluate it at \\(0\\). + +However, any selection of *fewer than \\(t\\)* secret shares contains *no information* about the original secret. + +## How to Use It + +First, make sure you have a wallet installed. + + - Whenever you run `neptune-core`, it will read the wallet file or create one if none is found. Unless you moved or removed this file, it is still there. + - To test if the wallet file is present, run `neptune-cli which-wallet`. + - To generate a wallet file without running `neptune-core`, try `neptune-cli generate-wallet`. + - To import a wallet from a seed phrase, first make sure there is no wallet file, and then run `neptune-cli import-seed-phrase`. + +To generate \\(n\\) shares in a \\(t\\)-out-of-\\(n\\) scheme, run `neptune-cli shamir-share t n` and replace `t` and `n` with the values you want. This command generates \\(n\\) seed phrases. **Note:** be sure to record the share index ("`i/n`") along with each share, as you will need this information to reconstruct the original secret. + +To reconstruct the original secret, first make sure the wallet file is absent. Then run `neptune-cli shamir-combine t` and replace `t` with the same value used earlier. This command will ask you for \\(t\\) secret shares (with index) which you can supply by writing the seed phrase words of each share. + +## Example + +`> neptune-cli shamir-share 2 3` + +``` +Wallet for beta. +Read from file `[file name redacted]`. + +Key share 1/3: +1. because +2. curtain +3. remove +4. marble +5. divide +6. what +7. early +8. tilt +9. debate +10. evidence +11. tag +12. ramp +13. acquire +14. side +15. tenant +16. cloud +17. nature +18. index + +Key share 2/3: +1. twenty +2. pretty +3. shiver +4. position +5. panda +6. frown +7. cargo +8. target +9. country +10. deliver +11. remind +12. label +13. kick +14. call +15. exchange +16. vital +17. absent +18. barely + +Key share 3/3: +1. senior +2. comfort +3. stomach +4. since +5. yard +6. dove +7. ability +8. okay +9. cloth +10. chaos +11. attack +12. enough +13. tilt +14. junk +15. risk +16. sail +17. horse +18. primary +``` + +``` +> neptune-cli shamir-combine 2 +``` + +``` +Enter share index ("i/n"): +1/3 +Enter seed phrase for key share 1/3: +1. because +2. curtain +3. remove +4. marble +5. divide +6. what +7. early +8. tilt +9. debate +10. evidence +11. tag +12. ramp +13. acquire +14. side +15. tenant +16. cloud +17. nature +18. index + +Have shares {1}/3. + +Enter share index ("i/n"): +3/3 +Enter seed phrase for key share 3/3: +1. senior +2. comfort +3. stomach +4. since +5. yard +6. dove +7. ability +8. okay +9. cloth +10. chaos +11. attack +12. enough +13. tilt +14. junk +15. risk +16. sail +17. horse +18. primary + +Have shares {1,3}/3. + +Shamir recombination successful. +Saving wallet to disk at [file name redacted] ... +Success. +``` diff --git a/src/bin/neptune-cli.rs b/src/bin/neptune-cli.rs index b7f8e2b9..f37e44e7 100644 --- a/src/bin/neptune-cli.rs +++ b/src/bin/neptune-cli.rs @@ -23,6 +23,7 @@ use neptune_cash::models::blockchain::type_scripts::neptune_coins::NeptuneCoins; use neptune_cash::models::state::wallet::address::KeyType; use neptune_cash::models::state::wallet::address::ReceivingAddress; use neptune_cash::models::state::wallet::coin_with_possible_timelock::CoinWithPossibleTimeLock; +use neptune_cash::models::state::wallet::secret_key_material::SecretKeyMaterial; use neptune_cash::models::state::wallet::utxo_notification::PrivateNotificationData; use neptune_cash::models::state::wallet::utxo_notification::UtxoNotificationMedium; use neptune_cash::models::state::wallet::wallet_status::WalletStatus; @@ -30,6 +31,9 @@ use neptune_cash::models::state::wallet::WalletSecret; use neptune_cash::rpc_auth; use neptune_cash::rpc_server::error::RpcError; use neptune_cash::rpc_server::RPCClient; +use rand::thread_rng; +use rand::Rng; +use regex::Regex; use serde::Deserialize; use serde::Serialize; use tarpc::client; @@ -335,6 +339,24 @@ enum Command { #[clap(long, default_value_t)] network: Network, }, + + /// Combine shares from a t-out-of-n Shamir secret sharing scheme; reproduce + /// the original secret and save it as a wallet secret. + ShamirCombine { + t: usize, + + #[clap(long, default_value_t)] + network: Network, + }, + + /// Share the wallet secret using a t-out-of-n Shamir secret sharing scheme. + ShamirShare { + t: usize, + n: usize, + + #[clap(long, default_value_t)] + network: Network, + }, } /// represents top-level cli args @@ -374,7 +396,8 @@ async fn main() -> Result<()> { // Get wallet object, create various wallet secret files let wallet_file = WalletSecret::wallet_secret_path(&wallet_dir); if !wallet_file.exists() { - bail!("No wallet file found at {}.", wallet_file.display()); + eprintln!("No wallet file found at {}.", wallet_file.display()); + return Ok(()); } else { println!("{}", wallet_file.display()); } @@ -416,37 +439,15 @@ async fn main() -> Result<()> { // read seed phrase from user input println!("Importing seed phrase. Please enter words:"); - let mut phrase = vec![]; - let mut i = 1; - loop { - print!("{}. ", i); - io::stdout().flush()?; - let mut buffer = "".to_string(); - std::io::stdin() - .read_line(&mut buffer) - .expect("Cannot accept user input."); - let word = buffer.trim(); - if bip39::Language::English - .wordlist() - .get_words_by_prefix("") - .iter() - .any(|s| *s == word) - { - phrase.push(word.to_string()); - i += 1; - if i > 18 { - break; - } - } else { - println!("Did not recognize word \"{}\"; please try again.", word); - } - } - let wallet_secret = match WalletSecret::from_phrase(&phrase) { - Err(_) => { - bail!("Invalid seed phrase."); + let secret_key = match enter_seed_phrase_dialog() { + Ok(k) => k, + Err(e) => { + println!("Failed to import seed phrase."); + eprintln!("Error: {e}"); + return Ok(()); } - Ok(ws) => ws, }; + let wallet_secret = WalletSecret::new(secret_key); // wallet file does not exist yet, so create it and save println!("Saving wallet to disk at {} ...", wallet_file.display()); @@ -472,7 +473,7 @@ async fn main() -> Result<()> { if !wallet_file.exists() { bail!( concat!("Cannot export seed phrase because there is no wallet.dat file to export from.\n", - "Generate one using `neptune-cli generate-wallet` or `neptune-wallet-gen`, or import a seed phrase using `neptune-cli import-seed-phrase`.") + "Generate one using `neptune-cli generate-wallet`, or import a seed phrase using `neptune-cli import-seed-phrase`.") ); } let wallet_secret = match WalletSecret::read_from_file(&wallet_file) { @@ -486,9 +487,7 @@ async fn main() -> Result<()> { }; println!("Seed phrase for {}.", network); println!("Read from file `{}`.", wallet_file.display()); - for (i, word) in wallet_secret.to_phrase().into_iter().enumerate() { - println!("{}. {word}", i + 1); - } + print_seed_phrase_dialog(wallet_secret); return Ok(()); } Command::NthReceivingAddress { network, index } => { @@ -497,6 +496,190 @@ async fn main() -> Result<()> { Command::PremineReceivingAddress { network } => { return get_nth_receiving_address(*network, args.data_dir.clone(), 0); } + Command::ShamirCombine { t, network } => { + let wallet_dir = + DataDirectory::get(args.data_dir.clone(), *network)?.wallet_directory_path(); + let wallet_file = WalletSecret::wallet_secret_path(&wallet_dir); + + // if the wallet file already exists, bail + if wallet_file.exists() { + println!( + "Cannot import wallet from Shamir secret shares; wallet file {} already exists. Move it to another location (or remove it) to perform this operation.", + wallet_file.display() + ); + return Ok(()); + } + + // prompt user for all shares + let mut shares = vec![]; + let capture_integers = Regex::new(r"^(\d+)\/(\d+)$").unwrap(); + while shares.len() != *t { + println!("Enter share index (\"i/n\"): "); + + let mut buffer = "".to_string(); + std::io::stdin() + .read_line(&mut buffer) + .expect("Cannot accept user input."); + let buffer = buffer.trim(); + + let (before_slash, after_slash) = + if let Some(captures) = capture_integers.captures(buffer) { + let before_slash = captures.get(1).unwrap().as_str(); + let after_slash = captures.get(2).unwrap().as_str(); + + (before_slash, after_slash) + } else { + println!("Could not parse index. Please try again."); + continue; + }; + + let i = match usize::from_str(before_slash) { + Ok(i) => i, + Err(_e) => { + println!("Failed to parse `{}`. Please try again.", before_slash); + continue; + } + }; + + let n = match usize::from_str(after_slash) { + Ok(i) => i, + Err(_e) => { + println!("Failed to parse `{}`. Please try again.", after_slash); + continue; + } + }; + + if i == 0 { + println!("Index i == 0 is invalid. Please try again."); + continue; + } + + if i > n { + println!("Index i = {i} > n = {n} is disallowed. Please try again."); + continue; + } + + if shares.iter().any(|(j, _)| *j == i) { + println!("Index i = {i} is a duplicate; cannot have duplicates."); + println!( + "Already have shares with indices {{{}}}/{n}", + shares.iter().map(|(j, _)| *j).sorted().join(",") + ); + println!("Please try again."); + continue; + } + + loop { + println!("Enter seed phrase for key share {i}/{n}:"); + let key = match enter_seed_phrase_dialog() { + Ok(key) => key, + Err(e) => { + println!("Failed to process seed phrase."); + eprintln!("Error: {e}"); + println!("Please try again."); + continue; + } + }; + shares.push((i, key)); + break; + } + println!(); + println!( + "Have shares {{{}}}/{n}.\n", + shares.iter().map(|(j, _)| *j).sorted().join(",") + ); + } + + let original_secret = match SecretKeyMaterial::combine_shamir(*t, shares) { + Ok(key) => { + println!("Shamir recombination successful."); + key + } + Err(e) => { + println!("Could not recombine Shamir secret shares."); + eprintln!("Error: {e}"); + return Ok(()); + } + }; + + // create wallet and save to disk + let wallet_secret = WalletSecret::new(original_secret); + + // wallet file does not exist yet (we verified that upstairs) so + // create it and save + println!("Saving wallet to disk at {} ...", wallet_file.display()); + DataDirectory::create_dir_if_not_exists(&wallet_dir).await?; + match wallet_secret.save_to_disk(&wallet_file) { + Err(e) => { + bail!("Could not save wallet to disk. {e}"); + } + Ok(_) => { + println!("Success."); + } + } + + return Ok(()); + } + Command::ShamirShare { t, n, network } => { + if *n < 1 { + println!("Share count n must be larger than 1."); + return Ok(()); + } + if *t >= *n { + println!("Cannot split secret into fewer shares than would be required to reproduce the original secret. Try setting t < n."); + return Ok(()); + } + if *t <= 1 { + println!( + "Quorum t must be larger than 1, otherwise Shamir secret sharing is moot." + ); + return Ok(()); + } + + // The root path is where both the wallet and all databases are stored + let wallet_dir = + DataDirectory::get(args.data_dir.clone(), *network)?.wallet_directory_path(); + + // Get wallet object, create various wallet secret files + let wallet_file = WalletSecret::wallet_secret_path(&wallet_dir); + if !wallet_file.exists() { + println!( + concat!("Cannot Shamir-secret-share wallet secret because there is no wallet.dat file to read from.\n \ + Generate one using `neptune-cli generate-wallet`, or import a seed phrase using `neptune-cli import-seed-phrase`.") + ); + return Ok(()); + } + let wallet_secret = match WalletSecret::read_from_file(&wallet_file) { + Err(e) => { + println!("Could not read from wallet file."); + eprintln!("Error: {e}"); + return Ok(()); + } + Ok(result) => result, + }; + println!("Wallet for {}.", network); + println!("Read from file `{}`.\n", wallet_file.display()); + + let mut rng = thread_rng(); + let shamir_shares = match wallet_secret.share_shamir(*t, *n, rng.gen()) { + Ok(shares) => shares, + Err(e) => { + println!("Could not Shamir secret share wallet secret."); + eprintln!("Error: {e}"); + return Ok(()); + } + }; + + let n = shamir_shares.len(); + for (i, secret_key) in shamir_shares { + println!("Key share {i}/{}:", n); + let wallet_secret = WalletSecret::new(secret_key); + print_seed_phrase_dialog(wallet_secret); + println!(); + } + + return Ok(()); + } _ => {} } @@ -538,6 +721,8 @@ async fn main() -> Result<()> { | Command::WhichWallet { .. } | Command::ExportSeedPhrase { .. } | Command::ImportSeedPhrase { .. } + | Command::ShamirCombine { .. } + | Command::ShamirShare { .. } | Command::NthReceivingAddress { .. } | Command::PremineReceivingAddress { .. } => { unreachable!("Case should be handled earlier.") @@ -1083,3 +1268,41 @@ or use equivalent claim functionality of your chosen wallet software. Ok(()) } + +fn enter_seed_phrase_dialog() -> Result { + let mut phrase = vec![]; + let mut i = 1; + loop { + print!("{}. ", i); + io::stdout().flush()?; + let mut buffer = "".to_string(); + std::io::stdin() + .read_line(&mut buffer) + .expect("Cannot accept user input."); + let word = buffer.trim(); + if bip39::Language::English + .wordlist() + .get_words_by_prefix("") + .iter() + .any(|s| *s == word) + { + phrase.push(word.to_string()); + i += 1; + if i > 18 { + break; + } + } else { + println!("Did not recognize word \"{}\"; please try again.", word); + } + } + match SecretKeyMaterial::from_phrase(&phrase) { + Ok(key) => Ok(key), + Err(_) => bail!("invalid seed phrase"), + } +} + +fn print_seed_phrase_dialog(wallet_secret: WalletSecret) { + for (i, word) in wallet_secret.to_phrase().into_iter().enumerate() { + println!("{}. {word}", i + 1); + } +} diff --git a/src/models/state/wallet/mod.rs b/src/models/state/wallet/mod.rs index ccbad35b..e1b238dd 100644 --- a/src/models/state/wallet/mod.rs +++ b/src/models/state/wallet/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod expected_utxo; pub(crate) mod incoming_utxo; pub(crate) mod monitored_utxo; pub(crate) mod rusty_wallet_database; +pub mod secret_key_material; pub(crate) mod transaction_output; pub(crate) mod unlocked_utxo; pub mod utxo_notification; @@ -21,11 +22,12 @@ use anyhow::Context; use anyhow::Result; use bip39::Mnemonic; use itertools::Itertools; -use num_traits::Zero; use rand::rngs::StdRng; use rand::thread_rng; use rand::Rng; use rand::SeedableRng; +use secret_key_material::SecretKeyMaterial; +use secret_key_material::ShamirSecretSharingError; use serde::Deserialize; use serde::Serialize; use tracing::info; @@ -33,7 +35,6 @@ use twenty_first::math::b_field_element::BFieldElement; use twenty_first::math::bfield_codec::BFieldCodec; use twenty_first::math::digest::Digest; use twenty_first::math::x_field_element::XFieldElement; -use zeroize::Zeroize; use zeroize::ZeroizeOnDrop; use crate::models::blockchain::block::block_height::BlockHeight; @@ -49,15 +50,6 @@ const STANDARD_WALLET_VERSION: u8 = 0; pub const WALLET_DB_NAME: &str = "wallet"; pub const WALLET_OUTPUT_COUNT_DB_NAME: &str = "wallout_output_count_db"; -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -struct SecretKeyMaterial(XFieldElement); - -impl Zeroize for SecretKeyMaterial { - fn zeroize(&mut self) { - self.0 = XFieldElement::zero(); - } -} - /// Wallet contains the wallet-related data we want to store in a JSON file, /// and that is not updated during regular program execution. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, ZeroizeOnDrop)] @@ -90,7 +82,7 @@ impl WalletSecret { } /// Create new `Wallet` given a `secret` key. - fn new(secret_seed: SecretKeyMaterial) -> Self { + pub fn new(secret_seed: SecretKeyMaterial) -> Self { Self { name: STANDARD_WALLET_NAME.to_string(), secret_seed, @@ -405,20 +397,25 @@ impl WalletSecret { .collect_vec() } - /// Convert a secret seed phrase (list of 18 valid BIP-39 words) to a WalletSecret + /// Convert a secret seed phrase (list of 18 valid BIP-39 words) to a + /// [`WalletSecret`] pub fn from_phrase(phrase: &[String]) -> Result { - let mnemonic = Mnemonic::from_phrase(&phrase.iter().join(" "), bip39::Language::English)?; - let secret_seed: [u8; 24] = mnemonic.entropy().try_into().unwrap(); - let xfe = XFieldElement::new( - secret_seed - .chunks(8) - .map(|ch| u64::from_le_bytes(ch.try_into().unwrap())) - .map(BFieldElement::new) - .collect_vec() - .try_into() - .unwrap(), - ); - Ok(Self::new(SecretKeyMaterial(xfe))) + let key = SecretKeyMaterial::from_phrase(phrase)?; + Ok(Self::new(key)) + } + + /// Split the secret across n shares such that combining any t of them + /// yields the secret again. + /// + /// Calls [`SecretKeyMaterial::share_shamir`] on the `secret_seed` field. + /// See that method for documentation. + pub fn share_shamir( + &self, + t: usize, + n: usize, + seed: [u8; 32], + ) -> Result, ShamirSecretSharingError> { + self.secret_seed.share_shamir(t, n, seed) } } @@ -426,6 +423,7 @@ impl WalletSecret { mod wallet_tests { use expected_utxo::ExpectedUtxo; use num_traits::CheckedSub; + use num_traits::Zero; use rand::random; use strum::IntoEnumIterator; use tracing_test::traced_test; diff --git a/src/models/state/wallet/secret_key_material.rs b/src/models/state/wallet/secret_key_material.rs new file mode 100644 index 00000000..73ba24fe --- /dev/null +++ b/src/models/state/wallet/secret_key_material.rs @@ -0,0 +1,391 @@ +use anyhow::Result; +use bip39::Mnemonic; +use itertools::Itertools; +use num_traits::ConstZero; +use num_traits::Zero; +use rand::rngs::StdRng; +use rand::Rng; +use rand::SeedableRng; +use serde::Deserialize; +use serde::Serialize; +use strum::Display; +use tasm_lib::triton_vm::prelude::BFieldElement; +use tasm_lib::triton_vm::prelude::XFieldElement; +use tasm_lib::twenty_first::prelude::Polynomial; +use tasm_lib::twenty_first::xfe; +use zeroize::Zeroize; + +/// Holds the secret seed of a wallet. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct SecretKeyMaterial(pub(crate) XFieldElement); + +impl Zeroize for SecretKeyMaterial { + fn zeroize(&mut self) { + self.0 = XFieldElement::zero(); + } +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Display)] +pub enum ShamirSecretSharingError { + /// When t = 0 or t = 1, Shamir secret sharing is disallowed because (t=0) + /// it is impossible or (t=1) all shares contain all of the information + /// about the secret being shared, undermining the security benefits. + QuorumTooSmall, + + /// When t > n, Shamir secret sharing is disallowed because it would be + /// impossible to reconstruct the original secret from *all* of the shares, + /// let alone a smaller quorum. + ImpossibleRecombination, + + /// When n < 1, there are no shares to hand out, so Shamir secret sharing + /// is disallowed. + NotEnoughSharesToSplit, + + /// Attempting to combine =t + /// shares, it is important to guarantee that all shares have distinct + /// indices. Otherwise there is either a duplicate share (both coordinates + /// are the same) and redundant information is provided, or else there is + /// a pair of inconsistent shares (having the same x-coordinate and + /// different y-coordinates). + DuplicateIndex, + + /// When combining >t shares from a t-out-of-n Shamir secret sharing scheme, + /// the reconstructed sharing polynomial should be of degree (at most) t. + /// If this not the case, at least one of the shares is corrupt. + InconsistentShares, +} + +impl SecretKeyMaterial { + /// Split the secret across n shares such that combining any t of them + /// yields the secret again. + /// + /// A t-out-of-n Shamir secret sharing scheme defines a polynomial p(X) of + /// degree at most t with uniformly random coefficients except the constant + /// coefficient, which is equal to the secret S being shared, *i.e.*, + /// p(0) = S . The shares are then (i, p(i)) for i in 1..=n. + /// + /// Upon combining t (or more) shares, one reproduces the original secret S + /// by first interpolating the polynomial through the t points, and then by + /// evaluating this polynomial in zero. + /// + /// This function is responsible for the splitting part. + /// [`combine_shamir`](Self::combine_shamir) does the recombination. + pub fn share_shamir( + &self, + t: usize, + n: usize, + seed: [u8; 32], + ) -> Result, ShamirSecretSharingError> { + if n < 1 { + return Err(ShamirSecretSharingError::NotEnoughSharesToSplit); + } + if t < 2 { + return Err(ShamirSecretSharingError::QuorumTooSmall); + } + if t > n { + return Err(ShamirSecretSharingError::ImpossibleRecombination); + } + let mut rng = StdRng::from_seed(seed); + + let polynomial_coefficients = (0..t) + .map(|i| if i == 0 { self.0 } else { rng.gen() }) + .collect_vec(); + + let evaluation_indices = (1..=n).collect_vec(); + let evaluation_points = evaluation_indices.iter().map(|i| xfe!(*i)).collect_vec(); + let secret_shares = + Polynomial::new(polynomial_coefficients).batch_evaluate(&evaluation_points); + Ok(evaluation_indices + .into_iter() + .zip(secret_shares.into_iter().map(SecretKeyMaterial)) + .collect_vec()) + } + + /// Combine a quorum of Shamir secret shares into one. + /// + /// See [`share_shamir`](Self::share_shamir). + pub fn combine_shamir( + t: usize, + shares: Vec<(usize, SecretKeyMaterial)>, + ) -> Result { + if shares.len() < t { + return Err(ShamirSecretSharingError::TooFewSharesToRecombine); + } + + let mut indices = shares.iter().map(|(i, _)| *i).collect_vec(); + + let ordinates = indices.iter().map(|i| xfe!(*i)).collect_vec(); + indices.sort(); + indices.dedup(); + if indices.len() != ordinates.len() { + return Err(ShamirSecretSharingError::DuplicateIndex); + } + if ordinates.contains(&XFieldElement::ZERO) { + return Err(ShamirSecretSharingError::InvalidShare); + } + + let abscissae = shares.into_iter().map(|(_, y)| y.0).collect_vec(); + let polynomial = Polynomial::interpolate(&ordinates, &abscissae); + if polynomial.degree() > 0 && polynomial.degree() as usize >= t { + return Err(ShamirSecretSharingError::InconsistentShares); + } + + let p0 = polynomial.evaluate(XFieldElement::ZERO); + Ok(SecretKeyMaterial(p0)) + } + + /// Convert a seed phrase into [`SecretKeyMaterial`]. + /// + /// The returned secret key material is wrapped in a `Result`, which is + /// `Err` if the words are not 18 valid BIP-39 words. + pub fn from_phrase(phrase: &[String]) -> Result { + let mnemonic = Mnemonic::from_phrase(&phrase.iter().join(" "), bip39::Language::English)?; + let secret_seed: [u8; 24] = mnemonic.entropy().try_into()?; + let xfe = XFieldElement::new( + secret_seed + .chunks(8) + .map(|ch| u64::from_le_bytes(ch.try_into().unwrap())) + .map(BFieldElement::new) + .collect_vec() + .try_into() + .unwrap(), + ); + Ok(Self(xfe)) + } +} + +#[cfg(test)] +mod test { + use super::*; + + mod shamir { + use proptest::{prelude::Just, prop_assert_eq}; + use proptest_arbitrary_interop::arb; + use test_strategy::proptest; + + use super::*; + + #[proptest] + fn happy_path_all_shares( + #[strategy(2usize..20)] n: usize, + #[strategy(2usize..=#n)] t: usize, + #[strategy(arb())] s: XFieldElement, + #[strategy([arb(); 32])] seed: [u8; 32], + ) { + let secret_key = SecretKeyMaterial(s); + let shares = secret_key + .share_shamir(t, n, seed) + .expect("sharing on happy path should succeed"); + let recombination = SecretKeyMaterial::combine_shamir(t, shares) + .expect("recombining on happy path should succeed"); + + prop_assert_eq!(secret_key, recombination); + } + + #[proptest] + fn happy_path_t_shares( + #[strategy(2usize..20)] n: usize, + #[strategy(2usize..=#n)] t: usize, + #[strategy(arb())] s: XFieldElement, + #[strategy([arb(); 32])] seed: [u8; 32], + ) { + let mut rng = StdRng::from_seed(seed); + let secret_key = SecretKeyMaterial(s); + let mut shares = secret_key + .share_shamir(t, n, rng.gen()) + .expect("sharing on happy path should succeed"); + let selected_shares = (0..t) + .map(|_| shares.swap_remove(rng.gen_range(0..shares.len()))) + .collect_vec(); + let recombination = SecretKeyMaterial::combine_shamir(t, selected_shares) + .expect("recombining on happy path should succeed"); + + prop_assert_eq!(secret_key, recombination); + } + + #[proptest] + fn catch_quorum_too_small( + #[strategy(2usize..20)] n: usize, + #[strategy(0usize..=1)] t: usize, + #[strategy(arb())] s: XFieldElement, + #[strategy([arb(); 32])] seed: [u8; 32], + ) { + let secret_key = SecretKeyMaterial(s); + prop_assert_eq!( + secret_key.share_shamir(t, n, seed), + Err(ShamirSecretSharingError::QuorumTooSmall) + ); + } + + #[proptest] + fn catch_impossible_recombination( + #[strategy(2usize..20)] n: usize, + #[strategy(#n+1..30)] t: usize, + #[strategy(arb())] s: XFieldElement, + #[strategy([arb(); 32])] seed: [u8; 32], + ) { + let secret_key = SecretKeyMaterial(s); + prop_assert_eq!( + secret_key.share_shamir(t, n, seed), + Err(ShamirSecretSharingError::ImpossibleRecombination) + ); + } + + #[proptest] + fn catch_not_enough_shares_to_split( + #[strategy(Just(0usize))] n: usize, + #[strategy(2usize..10)] t: usize, + #[strategy(arb())] s: XFieldElement, + #[strategy([arb(); 32])] seed: [u8; 32], + ) { + let secret_key = SecretKeyMaterial(s); + prop_assert_eq!( + secret_key.share_shamir(t, n, seed), + Err(ShamirSecretSharingError::NotEnoughSharesToSplit) + ); + } + + #[proptest] + fn catch_too_few_shares_to_recombine( + #[strategy(2usize..20)] n: usize, + #[strategy(2usize..=#n)] t: usize, + #[strategy(arb())] s: XFieldElement, + #[strategy([arb(); 32])] seed: [u8; 32], + ) { + let mut rng = StdRng::from_seed(seed); + let secret_key = SecretKeyMaterial(s); + let mut shares = secret_key + .share_shamir(t, n, rng.gen()) + .expect("sharing on happy path should succeed"); + let selected_shares = (0..t - 1) + .map(|_| shares.swap_remove(rng.gen_range(0..shares.len()))) + .collect_vec(); + prop_assert_eq!( + SecretKeyMaterial::combine_shamir(t, selected_shares), + Err(ShamirSecretSharingError::TooFewSharesToRecombine) + ); + } + + #[proptest] + fn catch_invalid_share( + #[strategy(2usize..20)] n: usize, + #[strategy(2usize..=#n)] t: usize, + #[strategy(arb())] s: XFieldElement, + #[strategy([arb(); 32])] seed: [u8; 32], + ) { + let mut rng = StdRng::from_seed(seed); + let secret_key = SecretKeyMaterial(s); + let mut shares = secret_key + .share_shamir(t, n, rng.gen()) + .expect("sharing on happy path should succeed"); + let mut selected_shares = (0..t - 1) + .map(|_| shares.swap_remove(rng.gen_range(0..shares.len()))) + .collect_vec(); + let invalid_share = (0, secret_key); + selected_shares.push(invalid_share); + prop_assert_eq!( + SecretKeyMaterial::combine_shamir(t, selected_shares), + Err(ShamirSecretSharingError::InvalidShare) + ); + } + + #[proptest] + fn catch_duplicate_index( + #[strategy(2usize..20)] n: usize, + #[strategy(2usize..=#n)] t: usize, + #[strategy(arb())] s: XFieldElement, + #[strategy([arb(); 32])] seed: [u8; 32], + ) { + let mut rng = StdRng::from_seed(seed); + let secret_key = SecretKeyMaterial(s); + let mut shares = secret_key + .share_shamir(t, n, rng.gen()) + .expect("sharing on happy path should succeed"); + let mut selected_shares = (0..t - 1) + .map(|_| shares.swap_remove(rng.gen_range(0..shares.len()))) + .collect_vec(); + let duplicate_share = selected_shares[rng.gen_range(0..selected_shares.len())].clone(); + selected_shares.push(duplicate_share); + println!("selected shares: {:?}", selected_shares); + prop_assert_eq!( + SecretKeyMaterial::combine_shamir(t, selected_shares), + Err(ShamirSecretSharingError::DuplicateIndex) + ); + } + + #[proptest] + fn catch_inconsistent_shares( + #[strategy(3usize..20)] n: usize, + #[strategy(2usize..#n)] t: usize, + #[strategy(arb())] s: XFieldElement, + #[strategy([arb(); 32])] seed: [u8; 32], + ) { + let mut rng = StdRng::from_seed(seed); + let secret_key = SecretKeyMaterial(s); + let mut shares_a = secret_key + .share_shamir(t, n, rng.gen()) + .expect("sharing on happy path should succeed"); + let mut shares_b = secret_key + .share_shamir(t, n, rng.gen()) + .expect("sharing on happy path should succeed"); + + // Make a random selection of t+1 shares such that both sharings are + // represented. There can be no duplicate indices so n > t. + let mut selected_shares = vec![]; + let insert_unique_index = + |collection: &mut Vec<_>, share: (usize, SecretKeyMaterial)| { + if !collection.iter().any(|(i, _)| *i == share.0) { + collection.push(share); + false + } else { + true + } + }; + + // add one share a, randomly selected + insert_unique_index( + &mut selected_shares, + shares_a.swap_remove(rng.gen_range(0..shares_a.len())), + ); + + // add one from b, randomly selected, and make sure it gets added + // even if we get an index collision on the first guess + while insert_unique_index( + &mut selected_shares, + shares_b.swap_remove(rng.gen_range(0..shares_b.len())), + ) {} + + // complete the collection by drawing randomly from a or b when + // possible + while selected_shares.len() < t + 1 { + let next_share = if shares_a.is_empty() && shares_b.is_empty() { + panic!("cannot happen: both were populated with more than 2 elements"); + } else if !shares_a.is_empty() && shares_b.is_empty() { + shares_a.swap_remove(rng.gen_range(0..shares_a.len())) + } else if shares_a.is_empty() && !shares_b.is_empty() { + shares_b.swap_remove(rng.gen_range(0..shares_b.len())) + } else if rng.gen() { + shares_a.swap_remove(rng.gen_range(0..shares_a.len())) + } else { + shares_b.swap_remove(rng.gen_range(0..shares_b.len())) + }; + insert_unique_index(&mut selected_shares, next_share); + } + + prop_assert_eq!( + SecretKeyMaterial::combine_shamir(t, selected_shares), + Err(ShamirSecretSharingError::InconsistentShares) + ); + } + } +}