diff --git a/ironfish-rust-nodejs/index.d.ts b/ironfish-rust-nodejs/index.d.ts index 35812ac3d0..3b5b81ba62 100644 --- a/ironfish-rust-nodejs/index.d.ts +++ b/ironfish-rust-nodejs/index.d.ts @@ -264,6 +264,16 @@ export namespace multisig { encryptedSecretPackage: string publicPackages: Array } + export function dkgRound3(secret: ParticipantSecret, round2SecretPackage: string, round1PublicPackages: Array, round2PublicPackages: Array): DkgRound3Packages + export interface DkgRound3Packages { + publicAddress: string + keyPackage: string + publicKeyPackage: string + viewKey: string + incomingViewKey: string + outgoingViewKey: string + proofAuthorizingKey: string + } export function aggregateSignatureShares(publicKeyPackageStr: string, signingPackageStr: string, signatureSharesArr: Array): Buffer export class ParticipantSecret { constructor(jsBytes: Buffer) diff --git a/ironfish-rust-nodejs/src/multisig.rs b/ironfish-rust-nodejs/src/multisig.rs index 2f3d7c6c17..307d9d9f2f 100644 --- a/ironfish-rust-nodejs/src/multisig.rs +++ b/ironfish-rust-nodejs/src/multisig.rs @@ -5,19 +5,22 @@ use crate::{structs::NativeUnsignedTransaction, to_napi_err}; use ironfish::{ frost::{keys::KeyPackage, round2, Randomizer}, - frost_utils::{signing_package::SigningPackage, split_spender_key::split_spender_key}, + frost_utils::{ + account_keys::derive_account_keys, signing_package::SigningPackage, + split_spender_key::split_spender_key, + }, participant::{Identity, Secret}, serializing::{bytes_to_hex, fr::FrSerializable, hex_to_vec_bytes}, SaplingKey, }; use ironfish_frost::{ - dkg::round1::PublicPackage, keys::PublicKeyPackage, multienc, - nonces::deterministic_signing_nonces, signature_share::SignatureShare, - signing_commitment::SigningCommitment, + dkg, keys::PublicKeyPackage, multienc, nonces::deterministic_signing_nonces, + signature_share::SignatureShare, signing_commitment::SigningCommitment, }; use napi::{bindgen_prelude::*, JsBuffer}; use napi_derive::napi; use rand::thread_rng; +use std::io; use std::ops::Deref; #[napi(namespace = "multisig")] @@ -26,41 +29,32 @@ pub const IDENTITY_LEN: u32 = ironfish::frost_utils::IDENTITY_LEN as u32; #[napi(namespace = "multisig")] pub const SECRET_LEN: u32 = ironfish_frost::participant::SECRET_LEN as u32; -fn try_deserialize_identities(signers: I) -> Result> +fn try_deserialize(items: I, deserialize_item: F) -> Result> where I: IntoIterator, S: Deref, + F: for<'a> Fn(&'a [u8]) -> io::Result, { - signers + items .into_iter() - .try_fold(Vec::new(), |mut signers, serialized_identity| { - let serialized_identity = - hex_to_vec_bytes(&serialized_identity).map_err(to_napi_err)?; - Identity::deserialize_from(&serialized_identity[..]) - .map(|identity| { - signers.push(identity); - signers + .try_fold(Vec::new(), |mut items, serialized_item| { + let serialized_item = hex_to_vec_bytes(&serialized_item).map_err(to_napi_err)?; + deserialize_item(&serialized_item[..]) + .map(|item| { + items.push(item); + items }) .map_err(to_napi_err) }) } -fn try_deserialize_public_packages(public_packages: I) -> Result> +#[inline] +fn try_deserialize_identities(signers: I) -> Result> where I: IntoIterator, S: Deref, { - public_packages - .into_iter() - .try_fold(Vec::new(), |mut public_packages, serialized_package| { - let serialized_package = hex_to_vec_bytes(&serialized_package).map_err(to_napi_err)?; - PublicPackage::deserialize_from(&serialized_package[..]) - .map(|public_package| { - public_packages.push(public_package); - public_packages - }) - .map_err(to_napi_err) - }) + try_deserialize(signers, |bytes| Identity::deserialize_from(bytes)) } #[napi(namespace = "multisig")] @@ -383,7 +377,7 @@ pub fn dkg_round1( Identity::deserialize_from(&hex_to_vec_bytes(&self_identity).map_err(to_napi_err)?[..])?; let participant_identities = try_deserialize_identities(participant_identities)?; - let (encrypted_secret_package, public_package) = ironfish_frost::dkg::round1::round1( + let (encrypted_secret_package, public_package) = dkg::round1::round1( &self_identity, min_signers, &participant_identities, @@ -410,11 +404,13 @@ pub fn dkg_round2( public_packages: Vec, ) -> Result { let secret = Secret::deserialize_from(&hex_to_vec_bytes(&secret).map_err(to_napi_err)?[..])?; - let public_packages = try_deserialize_public_packages(public_packages)?; + let public_packages = try_deserialize(public_packages, |bytes| { + dkg::round1::PublicPackage::deserialize_from(bytes) + })?; let encrypted_secret_package = hex_to_vec_bytes(&encrypted_secret_package).map_err(to_napi_err)?; - let (encrypted_secret_package, public_packages) = ironfish_frost::dkg::round2::round2( + let (encrypted_secret_package, public_packages) = dkg::round2::round2( &secret, &encrypted_secret_package, &public_packages, @@ -447,3 +443,50 @@ pub struct DkgRound2Packages { pub encrypted_secret_package: String, pub public_packages: Vec, } + +#[napi(object, namespace = "multisig")] +pub fn dkg_round3( + secret: &ParticipantSecret, + round2_secret_package: String, + round1_public_packages: Vec, + round2_public_packages: Vec, +) -> Result { + let round2_secret_package = hex_to_vec_bytes(&round2_secret_package).map_err(to_napi_err)?; + let round1_public_packages = try_deserialize(round1_public_packages, |bytes| { + dkg::round1::PublicPackage::deserialize_from(bytes) + })?; + let round2_public_packages = try_deserialize(round2_public_packages, |bytes| { + dkg::round2::PublicPackage::deserialize_from(bytes) + })?; + + let (key_package, public_key_package, group_secret_key) = dkg::round3::round3( + &secret.secret, + &round2_secret_package, + round1_public_packages.iter(), + round2_public_packages.iter(), + ) + .map_err(to_napi_err)?; + + let account_keys = derive_account_keys(public_key_package.verifying_key(), &group_secret_key); + + Ok(DkgRound3Packages { + public_address: account_keys.public_address.hex_public_address(), + key_package: bytes_to_hex(&key_package.serialize().map_err(to_napi_err)?), + public_key_package: bytes_to_hex(&public_key_package.serialize()), + view_key: account_keys.view_key.hex_key(), + incoming_view_key: account_keys.incoming_viewing_key.hex_key(), + outgoing_view_key: account_keys.outgoing_viewing_key.hex_key(), + proof_authorizing_key: account_keys.proof_authorizing_key.hex_key(), + }) +} + +#[napi(object, namespace = "multisig")] +pub struct DkgRound3Packages { + pub public_address: String, + pub key_package: String, + pub public_key_package: String, + pub view_key: String, + pub incoming_view_key: String, + pub outgoing_view_key: String, + pub proof_authorizing_key: String, +} diff --git a/ironfish-rust/src/frost_utils/account_keys.rs b/ironfish-rust/src/frost_utils/account_keys.rs new file mode 100644 index 0000000000..bb5380ce39 --- /dev/null +++ b/ironfish-rust/src/frost_utils/account_keys.rs @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use crate::{IncomingViewKey, OutgoingViewKey, PublicAddress, SaplingKey, ViewKey}; +use group::GroupEncoding; +use ironfish_frost::frost::VerifyingKey; +use ironfish_zkp::constants::PROOF_GENERATION_KEY_GENERATOR; +use jubjub::SubgroupPoint; + +pub struct MultisigAccountKeys { + /// Equivalent to [`crate::keys::SaplingKey::proof_authorizing_key`] + pub proof_authorizing_key: jubjub::Fr, + /// Equivalent to [`crate::keys::SaplingKey::outgoing_viewing_key`] + pub outgoing_viewing_key: OutgoingViewKey, + /// Equivalent to [`crate::keys::SaplingKey::view_key`] + pub view_key: ViewKey, + /// Equivalent to [`crate::keys::SaplingKey::incoming_viewing_key`] + pub incoming_viewing_key: IncomingViewKey, + /// Equivalent to [`crate::keys::SaplingKey::public_address`] + pub public_address: PublicAddress, +} + +/// Derives the account keys for a multisig account, realizing the following key hierarchy: +/// +/// ``` +/// ak ─┐ +/// ├─ ivk ── pk +/// gsk ── nsk ── nk ─┘ +/// ``` +pub fn derive_account_keys( + authorizing_key: &VerifyingKey, + group_secret_key: &[u8; 32], +) -> MultisigAccountKeys { + // Group secret key (gsk), obtained from the multisig setup process + let group_secret_key = + SaplingKey::new(*group_secret_key).expect("failed to derive group secret key"); + + // Authorization key (ak), obtained from the multisig setup process + let authorizing_key = Option::from(SubgroupPoint::from_bytes(&authorizing_key.serialize())) + .expect("failied to derive authorizing key"); + + // Nullifier keys (nsk and nk), derived from the gsk + let proof_authorizing_key = group_secret_key.sapling_proof_generation_key().nsk; + let nullifier_deriving_key = *PROOF_GENERATION_KEY_GENERATOR * proof_authorizing_key; + + // Incoming view key (ivk), derived from the ak and the nk + let view_key = ViewKey { + authorizing_key, + nullifier_deriving_key, + }; + let incoming_viewing_key = IncomingViewKey { + view_key: SaplingKey::hash_viewing_key(&authorizing_key, &nullifier_deriving_key) + .expect("failed to derive view key"), + }; + + // Outgoing view key (ovk), derived from the gsk + let outgoing_viewing_key = group_secret_key.outgoing_view_key().clone(); + + // Public address (pk), derived from the ivk + let public_address = incoming_viewing_key.public_address(); + + MultisigAccountKeys { + proof_authorizing_key, + outgoing_viewing_key, + view_key, + incoming_viewing_key, + public_address, + } +} diff --git a/ironfish-rust/src/frost_utils/mod.rs b/ironfish-rust/src/frost_utils/mod.rs index 707dcb945e..f35df12885 100644 --- a/ironfish-rust/src/frost_utils/mod.rs +++ b/ironfish-rust/src/frost_utils/mod.rs @@ -2,8 +2,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +pub mod account_keys; pub mod signing_package; pub mod split_secret; pub mod split_spender_key; + pub use ironfish_frost::keys::PublicKeyPackage; pub use ironfish_frost::participant::IDENTITY_LEN; diff --git a/ironfish/src/rpc/clients/client.ts b/ironfish/src/rpc/clients/client.ts index 2e8abab174..6e683a7670 100644 --- a/ironfish/src/rpc/clients/client.ts +++ b/ironfish/src/rpc/clients/client.ts @@ -37,6 +37,8 @@ import type { DkgRound1Response, DkgRound2Request, DkgRound2Response, + DkgRound3Request, + DkgRound3Response, EstimateFeeRateRequest, EstimateFeeRateResponse, EstimateFeeRatesRequest, @@ -290,6 +292,13 @@ export abstract class RpcClient { params, ).waitForEnd() }, + + round3: (params: DkgRound3Request): Promise> => { + return this.request( + `${ApiNamespace.wallet}/multisig/dkg/round3`, + params, + ).waitForEnd() + }, }, }, diff --git a/ironfish/src/rpc/routes/wallet/multisig/dkg/index.ts b/ironfish/src/rpc/routes/wallet/multisig/dkg/index.ts index eaa1f3a468..e1c096e1df 100644 --- a/ironfish/src/rpc/routes/wallet/multisig/dkg/index.ts +++ b/ironfish/src/rpc/routes/wallet/multisig/dkg/index.ts @@ -3,3 +3,4 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ export * from './round1' export * from './round2' +export * from './round3' diff --git a/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.test.ts b/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.test.ts new file mode 100644 index 0000000000..371aff6694 --- /dev/null +++ b/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.test.ts @@ -0,0 +1,237 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { createRouteTest } from '../../../../../testUtilities/routeTest' + +function removeOneElement(array: Array): Array { + const newArray = [...array] + const removeIndex = Math.floor(Math.random() * array.length) + newArray.splice(removeIndex, 1) + return newArray +} + +describe('Route multisig/dkg/round3', () => { + const routeTest = createRouteTest() + + it('should create round 3 packages', async () => { + const secretNames = ['secret-0', 'secret-1', 'secret-2'] + + // Create participants and retrieve their identities + await Promise.all( + secretNames.map((name) => routeTest.client.wallet.multisig.createParticipant({ name })), + ) + const participants = await Promise.all( + secretNames.map( + async (name) => (await routeTest.client.wallet.multisig.getIdentity({ name })).content, + ), + ) + + // Perform DKG round 1 + const round1Packages = await Promise.all( + secretNames.map((secretName) => + routeTest.client.wallet.multisig.dkg.round1({ + secretName, + minSigners: 2, + participants, + }), + ), + ) + + // Perform DKG round 2 + const round2Packages = await Promise.all( + secretNames.map((secretName, index) => + routeTest.client.wallet.multisig.dkg.round2({ + secretName, + encryptedSecretPackage: round1Packages[index].content.encryptedSecretPackage, + publicPackages: round1Packages.map((pkg) => pkg.content.publicPackage), + }), + ), + ) + + // Perform DKG round 3 + await Promise.all( + secretNames.map((secretName, index) => + routeTest.client.wallet.multisig.dkg.round3({ + secretName, + round2SecretPackage: round2Packages[index].content.encryptedSecretPackage, + round1PublicPackages: round1Packages.map((pkg) => pkg.content.publicPackage), + round2PublicPackages: round2Packages.flatMap((pkg) => + pkg.content.publicPackages + .filter( + ({ recipientIdentity }) => recipientIdentity === participants[index].identity, + ) + .map(({ publicPackage }) => publicPackage), + ), + }), + ), + ) + + // Check that all accounts that got imported after round 3 have the same public address + const publicKeys = await Promise.all( + secretNames.map( + async (account) => + ( + await routeTest.client.wallet.getAccountPublicKey({ account }) + ).content.publicKey, + ), + ) + const expectedPublicKey = publicKeys[0] + for (const publicKey of publicKeys) { + expect(publicKey).toBe(expectedPublicKey) + } + }) + + it('should fail if not all round 1 packages are passed as an input', async () => { + const secretNames = ['secret-0', 'secret-1', 'secret-2'] + + // Create participants and retrieve their identities + await Promise.all( + secretNames.map((name) => routeTest.client.wallet.multisig.createParticipant({ name })), + ) + const participants = await Promise.all( + secretNames.map( + async (name) => (await routeTest.client.wallet.multisig.getIdentity({ name })).content, + ), + ) + + // Perform DKG round 1 + const round1Packages = await Promise.all( + secretNames.map((secretName) => + routeTest.client.wallet.multisig.dkg.round1({ + secretName, + minSigners: 2, + participants, + }), + ), + ) + + // Perform DKG round 2 + const round2Packages = await Promise.all( + secretNames.map((secretName, index) => + routeTest.client.wallet.multisig.dkg.round2({ + secretName, + encryptedSecretPackage: round1Packages[index].content.encryptedSecretPackage, + publicPackages: round1Packages.map((pkg) => pkg.content.publicPackage), + }), + ), + ) + + // Perform DKG round 3 + await expect( + routeTest.client.wallet.multisig.dkg.round3({ + secretName: secretNames[0], + round2SecretPackage: round2Packages[0].content.encryptedSecretPackage, + round1PublicPackages: removeOneElement( + round1Packages.map((pkg) => pkg.content.publicPackage), + ), + round2PublicPackages: round2Packages.flatMap((pkg) => + pkg.content.publicPackages + .filter(({ recipientIdentity }) => recipientIdentity === participants[0].identity) + .map(({ publicPackage }) => publicPackage), + ), + }), + ).rejects.toThrow('invalid input: expected 3 round 1 public packages, got 2') + }) + + it('should fail if not all round 2 packages are passed as an input', async () => { + const secretNames = ['secret-0', 'secret-1', 'secret-2'] + + // Create participants and retrieve their identities + await Promise.all( + secretNames.map((name) => routeTest.client.wallet.multisig.createParticipant({ name })), + ) + const participants = await Promise.all( + secretNames.map( + async (name) => (await routeTest.client.wallet.multisig.getIdentity({ name })).content, + ), + ) + + // Perform DKG round 1 + const round1Packages = await Promise.all( + secretNames.map((secretName) => + routeTest.client.wallet.multisig.dkg.round1({ + secretName, + minSigners: 2, + participants, + }), + ), + ) + + // Perform DKG round 2 + const round2Packages = await Promise.all( + secretNames.map((secretName, index) => + routeTest.client.wallet.multisig.dkg.round2({ + secretName, + encryptedSecretPackage: round1Packages[index].content.encryptedSecretPackage, + publicPackages: round1Packages.map((pkg) => pkg.content.publicPackage), + }), + ), + ) + + // Perform DKG round 3 + await expect( + routeTest.client.wallet.multisig.dkg.round3({ + secretName: secretNames[0], + round2SecretPackage: round2Packages[0].content.encryptedSecretPackage, + round1PublicPackages: round1Packages.map((pkg) => pkg.content.publicPackage), + round2PublicPackages: removeOneElement( + round2Packages.flatMap((pkg) => + pkg.content.publicPackages + .filter(({ recipientIdentity }) => recipientIdentity === participants[0].identity) + .map(({ publicPackage }) => publicPackage), + ), + ), + }), + ).rejects.toThrow('invalid input: expected 2 round 2 public packages, got 1') + }) + + it('should fail passing the wrong round 2 secret package', async () => { + const secretNames = ['secret-0', 'secret-1', 'secret-2'] + + // Create participants and retrieve their identities + await Promise.all( + secretNames.map((name) => routeTest.client.wallet.multisig.createParticipant({ name })), + ) + const participants = await Promise.all( + secretNames.map( + async (name) => (await routeTest.client.wallet.multisig.getIdentity({ name })).content, + ), + ) + + // Perform DKG round 1 + const round1Packages = await Promise.all( + secretNames.map((secretName) => + routeTest.client.wallet.multisig.dkg.round1({ + secretName, + minSigners: 2, + participants, + }), + ), + ) + + // Perform DKG round 2 + const round2Packages = await Promise.all( + secretNames.map((secretName, index) => + routeTest.client.wallet.multisig.dkg.round2({ + secretName, + encryptedSecretPackage: round1Packages[index].content.encryptedSecretPackage, + publicPackages: round1Packages.map((pkg) => pkg.content.publicPackage), + }), + ), + ) + + // Perform DKG round 3 + await expect( + routeTest.client.wallet.multisig.dkg.round3({ + secretName: secretNames[0], + round2SecretPackage: round2Packages[1].content.encryptedSecretPackage, + round1PublicPackages: round1Packages.map((pkg) => pkg.content.publicPackage), + round2PublicPackages: round2Packages.flatMap((pkg) => + pkg.content.publicPackages + .filter(({ recipientIdentity }) => recipientIdentity === participants[0].identity) + .map(({ publicPackage }) => publicPackage), + ), + }), + ).rejects.toThrow('decryption error: aead::Error') + }) +}) diff --git a/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.ts b/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.ts new file mode 100644 index 0000000000..dddcc52126 --- /dev/null +++ b/ironfish/src/rpc/routes/wallet/multisig/dkg/round3.ts @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { multisig } from '@ironfish/rust-nodejs' +import * as yup from 'yup' +import { Assert } from '../../../../../assert' +import { FullNode } from '../../../../../node' +import { ACCOUNT_SCHEMA_VERSION } from '../../../../../wallet' +import { RPC_ERROR_CODES, RpcValidationError } from '../../../../adapters' +import { ApiNamespace } from '../../../namespaces' +import { routes } from '../../../router' + +export type DkgRound3Request = { + secretName: string + round2SecretPackage: string + round1PublicPackages: Array + round2PublicPackages: Array +} + +export type DkgRound3Response = Record + +export const DkgRound3RequestSchema: yup.ObjectSchema = yup + .object({ + secretName: yup.string().defined(), + round2SecretPackage: yup.string().defined(), + round1PublicPackages: yup.array().of(yup.string().defined()).defined(), + round2PublicPackages: yup.array().of(yup.string().defined()).defined(), + }) + .defined() + +export const DkgRound3ResponseSchema: yup.ObjectSchema = yup + .object>({}) + .defined() + +routes.register( + `${ApiNamespace.wallet}/multisig/dkg/round3`, + DkgRound3RequestSchema, + async (request, node): Promise => { + Assert.isInstanceOf(node, FullNode) + + const { secretName } = request.data + const multisigSecret = await node.wallet.walletDb.getMultisigSecretByName(secretName) + + if (!multisigSecret) { + throw new RpcValidationError( + `Multisig secret with name '${secretName}' not found`, + 400, + RPC_ERROR_CODES.MULTISIG_SECRET_NOT_FOUND, + ) + } + + const secret = new multisig.ParticipantSecret(multisigSecret.secret) + const identity = secret.toIdentity().serialize().toString('hex') + + const { + publicAddress, + keyPackage, + publicKeyPackage, + viewKey, + incomingViewKey, + outgoingViewKey, + proofAuthorizingKey, + } = multisig.dkgRound3( + secret, + request.data.round2SecretPackage, + request.data.round1PublicPackages, + request.data.round2PublicPackages, + ) + + const accountImport = { + name: secretName, + version: ACCOUNT_SCHEMA_VERSION, + createdAt: null, + spendingKey: null, + viewKey, + incomingViewKey, + outgoingViewKey, + publicAddress, + proofAuthorizingKey, + multisigKeys: { + identity, + keyPackage, + publicKeyPackage, + }, + } + + await node.wallet.importAccount(accountImport) + + // TODO: add an option to skip rescan + + request.end({}) + }, +)