diff --git a/cmd/waku/main.go b/cmd/waku/main.go index b84c66a9..a4192839 100644 --- a/cmd/waku/main.go +++ b/cmd/waku/main.go @@ -6,6 +6,7 @@ import ( cli "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "github.com/waku-org/go-waku/cmd/waku/keygen" + "github.com/waku-org/go-waku/cmd/waku/rlngenerate" "github.com/waku-org/go-waku/waku/v2/node" ) @@ -114,6 +115,7 @@ func main() { }, Commands: []*cli.Command{ &keygen.Command, + &rlngenerate.Command, }, } diff --git a/cmd/waku/rlngenerate/command_no_rln.go b/cmd/waku/rlngenerate/command_no_rln.go new file mode 100644 index 00000000..edd287f6 --- /dev/null +++ b/cmd/waku/rlngenerate/command_no_rln.go @@ -0,0 +1,19 @@ +//go:build !gowaku_rln +// +build !gowaku_rln + +package rlngenerate + +import ( + "errors" + + cli "github.com/urfave/cli/v2" +) + +// Command generates a key file used to generate the node's peerID, encrypted with an optional password +var Command = cli.Command{ + Name: "generate-rln-credentials", + Usage: "Generate credentials for usage with RLN", + Action: func(cCtx *cli.Context) error { + return errors.New("not available. Execute `make RLN=true` to add RLN support to go-waku") + }, +} diff --git a/cmd/waku/rlngenerate/command_rln.go b/cmd/waku/rlngenerate/command_rln.go new file mode 100644 index 00000000..95f0ad67 --- /dev/null +++ b/cmd/waku/rlngenerate/command_rln.go @@ -0,0 +1,141 @@ +//go:build gowaku_rln +// +build gowaku_rln + +package rlngenerate + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/ethclient" + cli "github.com/urfave/cli/v2" + "github.com/waku-org/go-waku/logging" + "github.com/waku-org/go-waku/waku/v2/protocol/rln/contracts" + "github.com/waku-org/go-waku/waku/v2/protocol/rln/group_manager/dynamic" + "github.com/waku-org/go-waku/waku/v2/protocol/rln/keystore" + "github.com/waku-org/go-waku/waku/v2/utils" + "github.com/waku-org/go-zerokit-rln/rln" + "go.uber.org/zap" +) + +var options Options +var logger = utils.Logger().Named("rln-credentials") + +// Command generates a key file used to generate the node's peerID, encrypted with an optional password +var Command = cli.Command{ + Name: "generate-rln-credentials", + Usage: "Generate credentials for usage with RLN", + Action: func(cCtx *cli.Context) error { + err := verifyFlags() + if err != nil { + logger.Error("validating option flags", zap.Error(err)) + return cli.Exit(err, 1) + } + + err = execute(context.Background()) + if err != nil { + logger.Error("registering RLN credentials", zap.Error(err)) + return cli.Exit(err, 1) + } + + return nil + }, + Flags: flags, +} + +func verifyFlags() error { + if options.CredentialsPath == "" { + logger.Warn("keystore: no credentials path set, using default path", zap.String("path", keystore.RLN_CREDENTIALS_FILENAME)) + options.CredentialsPath = keystore.RLN_CREDENTIALS_FILENAME + } + + if options.CredentialsPassword == "" { + logger.Warn("keystore: no credentials password set, using default password", zap.String("password", keystore.RLN_CREDENTIALS_PASSWORD)) + options.CredentialsPassword = keystore.RLN_CREDENTIALS_PASSWORD + } + + if options.ETHPrivateKey == nil { + return errors.New("a private key must be specified") + } + + return nil +} + +func execute(ctx context.Context) error { + ethClient, err := ethclient.Dial(options.ETHClientAddress) + if err != nil { + return err + } + + rlnInstance, err := rln.NewRLN() + if err != nil { + return err + } + + chainID, err := ethClient.ChainID(ctx) + if err != nil { + return err + } + + rlnContract, err := contracts.NewRLN(options.MembershipContractAddress, ethClient) + if err != nil { + return err + } + + // prepare rln membership key pair + logger.Info("generating rln credential") + identityCredential, err := rlnInstance.MembershipKeyGen() + if err != nil { + return err + } + + // register the rln-relay peer to the membership contract + membershipIndex, err := register(ctx, ethClient, rlnContract, identityCredential.IDCommitment, chainID) + if err != nil { + return err + } + + // TODO: clean private key from memory + + err = persistCredentials(identityCredential, membershipIndex, chainID) + if err != nil { + return err + } + + if logger.Level() == zap.DebugLevel { + logger.Info("registered credentials into the membership contract", + logging.HexString("IDCommitment", identityCredential.IDCommitment[:]), + logging.HexString("IDNullifier", identityCredential.IDNullifier[:]), + logging.HexString("IDSecretHash", identityCredential.IDSecretHash[:]), + logging.HexString("IDTrapDoor", identityCredential.IDTrapdoor[:]), + zap.Uint("index", membershipIndex), + ) + } else { + logger.Info("registered credentials into the membership contract", logging.HexString("idCommitment", identityCredential.IDCommitment[:]), zap.Uint("index", membershipIndex)) + } + + ethClient.Close() + + return nil +} + +func persistCredentials(identityCredential *rln.IdentityCredential, membershipIndex rln.MembershipIndex, chainID *big.Int) error { + membershipGroup := keystore.MembershipGroup{ + TreeIndex: membershipIndex, + MembershipContract: keystore.MembershipContract{ + ChainId: fmt.Sprintf("0x%X", chainID.Int64()), + Address: options.MembershipContractAddress.String(), + }, + } + + keystoreIndex, membershipGroupIndex, err := keystore.AddMembershipCredentials(options.CredentialsPath, identityCredential, membershipGroup, options.CredentialsPassword, dynamic.RLNAppInfo, keystore.DefaultSeparator) + if err != nil { + return fmt.Errorf("failed to persist credentials: %w", err) + } + + logger.Info("persisted credentials succesfully", zap.Int("keystoreIndex", keystoreIndex), zap.Int("membershipGroupIndex", membershipGroupIndex)) + + return nil +} diff --git a/cmd/waku/rlngenerate/flags.go b/cmd/waku/rlngenerate/flags.go new file mode 100644 index 00000000..460e9a72 --- /dev/null +++ b/cmd/waku/rlngenerate/flags.go @@ -0,0 +1,75 @@ +//go:build gowaku_rln +// +build gowaku_rln + +package rlngenerate + +import ( + cli "github.com/urfave/cli/v2" + wcli "github.com/waku-org/go-waku/waku/cliutils" + "github.com/waku-org/go-waku/waku/v2/protocol/rln/keystore" +) + +var flags = []cli.Flag{ + &cli.PathFlag{ + Name: "cred-path", + Usage: "RLN relay membership credentials file", + Value: keystore.RLN_CREDENTIALS_FILENAME, + Destination: &options.CredentialsPath, + }, + &cli.StringFlag{ + Name: "cred-password", + Value: keystore.RLN_CREDENTIALS_PASSWORD, + Usage: "Password for encrypting RLN credentials", + Destination: &options.CredentialsPassword, + }, + &cli.GenericFlag{ + Name: "eth-account-private-key", + Usage: "Ethereum account private key used for registering in member contract", + Value: &wcli.PrivateKeyValue{ + Value: &options.ETHPrivateKey, + }, + }, + &cli.StringFlag{ + Name: "eth-client-address", + Usage: "Ethereum testnet client address", + Value: "ws://localhost:8545", + Destination: &options.ETHClientAddress, + }, + &cli.GenericFlag{ + Name: "eth-contract-address", + Usage: "Address of membership contract", + Value: &wcli.AddressValue{ + Value: &options.MembershipContractAddress, + }, + }, + &cli.StringFlag{ + Name: "eth-nonce", + Value: "", + Usage: "Set an specific ETH transaction nonce. Leave empty to calculate the nonce automatically", + Destination: &options.ETHNonce, + }, + &cli.Uint64Flag{ + Name: "eth-gas-limit", + Value: 0, + Usage: "Gas limit to set for the transaction execution (0 = estimate)", + Destination: &options.ETHGasLimit, + }, + &cli.StringFlag{ + Name: "eth-gas-price", + Value: "", + Usage: "Gas price in wei to use for the transaction execution (empty = gas price oracle)", + Destination: &options.ETHGasPrice, + }, + &cli.StringFlag{ + Name: "eth-gas-fee-cap", + Value: "", + Usage: "Gas fee cap in wei to use for the 1559 transaction execution (empty = gas price oracle)", + Destination: &options.ETHGasFeeCap, + }, + &cli.StringFlag{ + Name: "eth-gas-tip-cap", + Value: "", + Usage: "Gas priority fee cap in wei to use for the 1559 transaction execution (empty = gas price oracle)", + Destination: &options.ETHGasTipCap, + }, +} diff --git a/cmd/waku/rlngenerate/options.go b/cmd/waku/rlngenerate/options.go new file mode 100644 index 00000000..ce79d926 --- /dev/null +++ b/cmd/waku/rlngenerate/options.go @@ -0,0 +1,21 @@ +package rlngenerate + +import ( + "crypto/ecdsa" + + "github.com/ethereum/go-ethereum/common" +) + +// Options are settings used to create RLN credentials. +type Options struct { + CredentialsPath string + CredentialsPassword string + ETHPrivateKey *ecdsa.PrivateKey + ETHClientAddress string + MembershipContractAddress common.Address + ETHGasLimit uint64 + ETHNonce string + ETHGasPrice string + ETHGasFeeCap string + ETHGasTipCap string +} diff --git a/cmd/waku/rlngenerate/web3.go b/cmd/waku/rlngenerate/web3.go new file mode 100644 index 00000000..6099e0a2 --- /dev/null +++ b/cmd/waku/rlngenerate/web3.go @@ -0,0 +1,125 @@ +//go:build gowaku_rln +// +build gowaku_rln + +package rlngenerate + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/log" + "github.com/waku-org/go-waku/logging" + "github.com/waku-org/go-waku/waku/v2/protocol/rln/contracts" + "github.com/waku-org/go-zerokit-rln/rln" + "go.uber.org/zap" +) + +func getMembershipFee(ctx context.Context, rlnContract *contracts.RLN) (*big.Int, error) { + return rlnContract.MEMBERSHIPDEPOSIT(&bind.CallOpts{Context: ctx}) +} + +func register(ctx context.Context, ethClient *ethclient.Client, rlnContract *contracts.RLN, idComm rln.IDCommitment, chainID *big.Int) (rln.MembershipIndex, error) { + // check if the contract exists by calling a static function + membershipFee, err := getMembershipFee(ctx, rlnContract) + if err != nil { + return 0, err + } + + auth, err := bind.NewKeyedTransactorWithChainID(options.ETHPrivateKey, chainID) + if err != nil { + return 0, err + } + auth.Value = membershipFee + auth.Context = ctx + auth.GasLimit = options.ETHGasLimit + + var ok bool + + if options.ETHNonce != "" { + nonce := &big.Int{} + auth.Nonce, ok = nonce.SetString(options.ETHNonce, 10) + if !ok { + return 0, errors.New("invalid nonce value") + } + } + + if options.ETHGasFeeCap != "" { + gasFeeCap := &big.Int{} + auth.GasFeeCap, ok = gasFeeCap.SetString(options.ETHGasFeeCap, 10) + if !ok { + return 0, errors.New("invalid gas fee cap value") + } + } + + if options.ETHGasTipCap != "" { + gasTipCap := &big.Int{} + auth.GasTipCap, ok = gasTipCap.SetString(options.ETHGasTipCap, 10) + if !ok { + return 0, errors.New("invalid gas tip cap value") + } + } + + if options.ETHGasPrice != "" { + gasPrice := &big.Int{} + auth.GasPrice, ok = gasPrice.SetString(options.ETHGasPrice, 10) + if !ok { + return 0, errors.New("invalid gas price value") + } + } + + log.Debug("registering an id commitment", zap.Binary("idComm", idComm[:])) + + // registers the idComm into the membership contract whose address is in rlnPeer.membershipContractAddress + tx, err := rlnContract.Register(auth, rln.Bytes32ToBigInt(idComm)) + if err != nil { + return 0, fmt.Errorf("transaction error: %w", err) + } + + url := "" + switch chainID.Int64() { + case 1: + url = "https://etherscan.io" + case 5: + url = "https://goerli.etherscan.io" + case 11155111: + url = "https://sepolia.etherscan.io" + } + + if url != "" { + logger.Info(fmt.Sprintf("transaction broadcasted, find details of your registration transaction in %s/tx/%s", url, tx.Hash())) + } else { + logger.Info("transaction broadcasted.", zap.String("transactionHash", tx.Hash().String())) + } + + logger.Warn("waiting for transaction to be mined...") + + txReceipt, err := bind.WaitMined(ctx, ethClient, tx) + if err != nil { + return 0, fmt.Errorf("transaction error: %w", err) + } + + if txReceipt.Status != types.ReceiptStatusSuccessful { + return 0, errors.New("transaction reverted") + } + + // the receipt topic holds the hash of signature of the raised events + evt, err := rlnContract.ParseMemberRegistered(*txReceipt.Logs[0]) + if err != nil { + return 0, err + } + + var eventIDComm rln.IDCommitment = rln.BigIntToBytes32(evt.Pubkey) + + log.Debug("information extracted from tx log", zap.Uint64("blockNumber", evt.Raw.BlockNumber), logging.HexString("idCommitment", eventIDComm[:]), zap.Uint64("index", evt.Index.Uint64())) + + if eventIDComm != idComm { + return 0, errors.New("invalid id commitment key") + } + + return rln.MembershipIndex(uint(evt.Index.Int64())), nil +} diff --git a/waku/v2/protocol/rln/group_manager/dynamic/dynamic.go b/waku/v2/protocol/rln/group_manager/dynamic/dynamic.go index 5b1da3e4..ad0bfae6 100644 --- a/waku/v2/protocol/rln/group_manager/dynamic/dynamic.go +++ b/waku/v2/protocol/rln/group_manager/dynamic/dynamic.go @@ -215,7 +215,7 @@ func (gm *DynamicGroupManager) Start(ctx context.Context, rlnInstance *rln.RLN, if len(credentials) != 0 { if int(gm.keystoreIndex) <= len(credentials)-1 { credential := credentials[gm.keystoreIndex] - gm.identityCredential = &credential.IdentityCredential + gm.identityCredential = credential.IdentityCredential if int(gm.membershipGroupIndex) <= len(credential.MembershipGroups)-1 { gm.membershipIndex = &credential.MembershipGroups[gm.membershipGroupIndex].TreeIndex } else { @@ -275,18 +275,15 @@ func (gm *DynamicGroupManager) persistCredentials() error { return errors.New("no credentials to persist") } - keystoreCred := keystore.MembershipCredentials{ - IdentityCredential: *gm.identityCredential, - MembershipGroups: []keystore.MembershipGroup{{ - TreeIndex: *gm.membershipIndex, - MembershipContract: keystore.MembershipContract{ - ChainId: fmt.Sprintf("0x%X", gm.chainId), - Address: gm.membershipContractAddress.String(), - }, - }}, + membershipGroup := keystore.MembershipGroup{ + TreeIndex: *gm.membershipIndex, + MembershipContract: keystore.MembershipContract{ + ChainId: fmt.Sprintf("0x%X", gm.chainId), + Address: gm.membershipContractAddress.String(), + }, } - err := keystore.AddMembershipCredentials(gm.keystorePath, []keystore.MembershipCredentials{keystoreCred}, gm.keystorePassword, RLNAppInfo, keystore.DefaultSeparator) + _, _, err := keystore.AddMembershipCredentials(gm.keystorePath, gm.identityCredential, membershipGroup, gm.keystorePassword, RLNAppInfo, keystore.DefaultSeparator) if err != nil { return fmt.Errorf("failed to persist credentials: %w", err) } diff --git a/waku/v2/protocol/rln/keystore/keystore.go b/waku/v2/protocol/rln/keystore/keystore.go index 62ce3151..f108aae2 100644 --- a/waku/v2/protocol/rln/keystore/keystore.go +++ b/waku/v2/protocol/rln/keystore/keystore.go @@ -7,6 +7,7 @@ import ( "fmt" "io/ioutil" "os" + "sort" "github.com/ethereum/go-ethereum/accounts/keystore" "github.com/waku-org/go-zerokit-rln/rln" @@ -27,8 +28,8 @@ type MembershipGroup struct { } type MembershipCredentials struct { - IdentityCredential rln.IdentityCredential `json:"identityCredential"` - MembershipGroups []MembershipGroup `json:"membershipGroups"` + IdentityCredential *rln.IdentityCredential `json:"identityCredential"` + MembershipGroups []MembershipGroup `json:"membershipGroups"` } type AppInfo struct { @@ -51,7 +52,7 @@ type AppKeystoreCredential struct { const DefaultSeparator = "\n" func (m MembershipCredentials) Equals(other MembershipCredentials) bool { - if !rln.IdentityCredentialEquals(m.IdentityCredential, other.IdentityCredential) { + if !rln.IdentityCredentialEquals(*m.IdentityCredential, *other.IdentityCredential) { return false } @@ -218,80 +219,104 @@ func GetMembershipCredentials(logger *zap.Logger, credentialsPath string, passwo return result, nil } -// Adds a sequence of membership credential to the keystore matching the application, appIdentifier and version filters. -func AddMembershipCredentials(path string, credentials []MembershipCredentials, password string, appInfo AppInfo, separator string) error { +// Adds a membership credential to the keystore matching the application, appIdentifier and version filters. +func AddMembershipCredentials(path string, newIdentityCredential *rln.IdentityCredential, newMembershipGroup MembershipGroup, password string, appInfo AppInfo, separator string) (keystoreIndex int, membershipGroupIndex int, err error) { k, err := LoadAppKeystore(path, appInfo, DefaultSeparator) if err != nil { - return err + return 0, 0, err } - var credentialsToAdd []MembershipCredentials - for _, newCredential := range credentials { - // A flag to tell us if the keystore contains a credential associated to the input identity credential, i.e. membershipCredential - found := -1 - for i, existingCredentials := range k.Credentials { - credentialsBytes, err := keystore.DecryptDataV3(existingCredentials.Crypto, password) - if err != nil { - continue - } - - var credentials MembershipCredentials - err = json.Unmarshal(credentialsBytes, &credentials) - if err != nil { - continue - } - - if rln.IdentityCredentialEquals(credentials.IdentityCredential, newCredential.IdentityCredential) { - // idCredential is present in keystore. We add the input credential membership group to the one contained in the decrypted keystore credential (we deduplicate groups using sets) - allMemberships := append(credentials.MembershipGroups, newCredential.MembershipGroups...) - - // we define the updated credential with the updated membership sets - updatedCredential := MembershipCredentials{ - IdentityCredential: newCredential.IdentityCredential, - MembershipGroups: allMemberships, - } - - // we re-encrypt creating a new keyfile - b, err := json.Marshal(updatedCredential) - if err != nil { - return err - } - - encryptedCredentials, err := keystore.EncryptDataV3(b, []byte(password), keystore.StandardScryptN, keystore.StandardScryptP) - if err != nil { - return err - } - - // we update the original credential field in keystoreCredentials - k.Credentials[i] = AppKeystoreCredential{Crypto: encryptedCredentials} - - found = i - - // We stop decrypting other credentials in the keystore - break - } - } - - if found == -1 { - credentialsToAdd = append(credentialsToAdd, newCredential) - } - } - - for _, c := range credentialsToAdd { - b, err := json.Marshal(c) + // A flag to tell us if the keystore contains a credential associated to the input identity credential, i.e. membershipCredential + found := false + for i, existingCredentials := range k.Credentials { + credentialsBytes, err := keystore.DecryptDataV3(existingCredentials.Crypto, password) if err != nil { - return err + continue + } + + var credentials MembershipCredentials + err = json.Unmarshal(credentialsBytes, &credentials) + if err != nil { + continue + } + + if rln.IdentityCredentialEquals(*credentials.IdentityCredential, *newIdentityCredential) { + // idCredential is present in keystore. We add the input credential membership group to the one contained in the decrypted keystore credential (we deduplicate groups using sets) + allMembershipsMap := make(map[MembershipGroup]struct{}) + for _, m := range credentials.MembershipGroups { + allMembershipsMap[m] = struct{}{} + } + allMembershipsMap[newMembershipGroup] = struct{}{} + + // We sort membership groups, otherwise we will not have deterministic results in tests + var allMemberships []MembershipGroup + for k := range allMembershipsMap { + allMemberships = append(allMemberships, k) + } + sort.Slice(allMemberships, func(i, j int) bool { + return allMemberships[i].MembershipContract.Address < allMemberships[j].MembershipContract.Address + }) + + // we define the updated credential with the updated membership sets + updatedCredential := MembershipCredentials{ + IdentityCredential: newIdentityCredential, + MembershipGroups: allMemberships, + } + + // we re-encrypt creating a new keyfile + b, err := json.Marshal(updatedCredential) + if err != nil { + return 0, 0, err + } + + encryptedCredentials, err := keystore.EncryptDataV3(b, []byte(password), keystore.StandardScryptN, keystore.StandardScryptP) + if err != nil { + return 0, 0, err + } + + // we update the original credential field in keystoreCredentials + k.Credentials[i] = AppKeystoreCredential{Crypto: encryptedCredentials} + + found = true + + // We setup the return values + membershipGroupIndex = len(allMemberships) + keystoreIndex = i + for mIdx, mg := range updatedCredential.MembershipGroups { + if mg.MembershipContract.Equals(newMembershipGroup.MembershipContract) { + membershipGroupIndex = mIdx + break + } + } + + // We stop decrypting other credentials in the keystore + break + } + } + + if !found { // Not found + newCredential := MembershipCredentials{ + IdentityCredential: newIdentityCredential, + MembershipGroups: []MembershipGroup{newMembershipGroup}, + } + + b, err := json.Marshal(newCredential) + if err != nil { + return 0, 0, err } encryptedCredentials, err := keystore.EncryptDataV3(b, []byte(password), keystore.StandardScryptN, keystore.StandardScryptP) if err != nil { - return err + return 0, 0, err } k.Credentials = append(k.Credentials, AppKeystoreCredential{Crypto: encryptedCredentials}) + + keystoreIndex = len(k.Credentials) - 1 + membershipGroupIndex = len(newCredential.MembershipGroups) - 1 } - return save(k, path, separator) + return keystoreIndex, membershipGroupIndex, save(k, path, separator) } // Safely saves a Keystore's JsonNode to disk.