diff --git a/share/rpcauth/rpcauth.py b/share/rpcauth/rpcauth.py index cc7bba1f8b..8eed9f32c2 100755 --- a/share/rpcauth/rpcauth.py +++ b/share/rpcauth/rpcauth.py @@ -24,6 +24,7 @@ def main(): parser = ArgumentParser(description='Create login credentials for a JSON-RPC user') parser.add_argument('username', help='the username for authentication') parser.add_argument('password', help='leave empty to generate a random password or specify "-" to prompt for password', nargs='?') + parser.add_argument('--output', dest='output', help='file to store credentials, to be used with -rpcauthfile') args = parser.parse_args() if not args.password: @@ -35,9 +36,14 @@ def main(): salt = generate_salt(16) password_hmac = password_to_hmac(salt, args.password) - print('String to be appended to bitcoin.conf:') - print(f'rpcauth={args.username}:{salt}${password_hmac}') - print(f'Your password:\n{args.password}') + if args.output: + file = open(args.output, "a", encoding="utf8") + file.write(f"{args.username}:{salt}${password_hmac}\n") + print(f'Your password:\n{args.password}') + else: + print('String to be appended to bitcoin.conf:') + print(f'rpcauth={args.username}:{salt}${password_hmac}') + print(f'Your password:\n{args.password}') if __name__ == '__main__': main() diff --git a/src/httprpc.cpp b/src/httprpc.cpp index c72dbf10bc..3446600b33 100644 --- a/src/httprpc.cpp +++ b/src/httprpc.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -252,9 +253,10 @@ static bool InitRPCAuthentication() LogPrintf("Config options rpcuser and rpcpassword will soon be deprecated. Locally-run instances may remove rpcuser to use cookie-based auth, or may be replaced with rpcauth. Please see share/rpcauth for rpcauth auth generation.\n"); strRPCUserColonPass = gArgs.GetArg("-rpcuser", "") + ":" + gArgs.GetArg("-rpcpassword", ""); } - if (gArgs.GetArg("-rpcauth", "") != "") { + if (!(gArgs.IsArgNegated("-rpcauth") || (gArgs.GetArgs("-rpcauth").empty() && gArgs.GetArgs("-rpcauthfile").empty()))) { LogPrintf("Using rpcauth authentication.\n"); for (const std::string& rpcauth : gArgs.GetArgs("-rpcauth")) { + if (rpcauth.empty()) continue; std::vector fields{SplitString(rpcauth, ':')}; const std::vector salt_hmac{SplitString(fields.back(), '$')}; if (fields.size() == 2 && salt_hmac.size() == 2) { @@ -266,6 +268,21 @@ static bool InitRPCAuthentication() return false; } } + for (const std::string& path : gArgs.GetArgs("-rpcauthfile")) { + std::ifstream file; + file.open(path); + if (!file.is_open()) continue; + std::string rpcauth; + while (std::getline(file, rpcauth)) { + std::vector fields{SplitString(rpcauth, ':')}; + const std::vector salt_hmac{SplitString(fields.back(), '$')}; + if (fields.size() == 2 && salt_hmac.size() == 2) { + fields.pop_back(); + fields.insert(fields.end(), salt_hmac.begin(), salt_hmac.end()); + g_rpcauth.push_back(fields); + } + } + } } g_rpc_whitelist_default = gArgs.GetBoolArg("-rpcwhitelistdefault", gArgs.IsArgSet("-rpcwhitelist")); diff --git a/src/init.cpp b/src/init.cpp index 988daefeec..6e411be513 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -650,6 +650,7 @@ void SetupServerArgs(ArgsManager& argsman) argsman.AddArg("-rest", strprintf("Accept public REST requests (default: %u)", DEFAULT_REST_ENABLE), ArgsManager::ALLOW_ANY, OptionsCategory::RPC); argsman.AddArg("-rpcallowip=", "Allow JSON-RPC connections from specified source. Valid values for are a single IP (e.g. 1.2.3.4), a network/netmask (e.g. 1.2.3.4/255.255.255.0), a network/CIDR (e.g. 1.2.3.4/24), all ipv4 (0.0.0.0/0), or all ipv6 (::/0). This option can be specified multiple times", ArgsManager::ALLOW_ANY, OptionsCategory::RPC); argsman.AddArg("-rpcauth=", "Username and HMAC-SHA-256 hashed password for JSON-RPC connections. The field comes in the format: :$. A canonical python script is included in share/rpcauth. The client then connects normally using the rpcuser=/rpcpassword= pair of arguments. This option can be specified multiple times", ArgsManager::ALLOW_ANY | ArgsManager::SENSITIVE, OptionsCategory::RPC); + argsman.AddArg("-rpcauthfile=", "A file with a single lines with same format as rpcauth. This option can be specified multiple times", ArgsManager::ALLOW_ANY, OptionsCategory::RPC); argsman.AddArg("-rpcbind=[:port]", "Bind to given address to listen for JSON-RPC connections. Do not expose the RPC server to untrusted networks such as the public internet! This option is ignored unless -rpcallowip is also passed. Port is optional and overrides -rpcport. Use [host]:port notation for IPv6. This option can be specified multiple times (default: 127.0.0.1 and ::1 i.e., localhost)", ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::RPC); argsman.AddArg("-rpcdoccheck", strprintf("Throw a non-fatal error at runtime if the documentation for an RPC is incorrect (default: %u)", DEFAULT_RPC_DOC_CHECK), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::RPC); argsman.AddArg("-rpccookiefile=", "Location of the auth cookie. Relative paths will be prefixed by a net-specific datadir location. (default: data dir)", ArgsManager::ALLOW_ANY, OptionsCategory::RPC); diff --git a/test/functional/rpc_users.py b/test/functional/rpc_users.py index 66cdd7cf9a..f0d7b1a0d3 100755 --- a/test/functional/rpc_users.py +++ b/test/functional/rpc_users.py @@ -11,6 +11,7 @@ from test_framework.util import ( ) import http.client +from pathlib import Path import urllib.parse import subprocess from random import SystemRandom @@ -37,6 +38,8 @@ class HTTPBasicsTest(BitcoinTestFramework): self.supports_cli = False def conf_setup(self): + self.authinfo = [] + #Append rpcauth to bitcoin.conf before initialization self.rtpassword = "cA773lm788buwYe4g4WT+05pKyNruVKjQ25x3n0DQcM=" rpcauth = "rpcauth=rt:93648e835a54c573682c2eb19f882535$7681e9c5b74bdd85e78166031d2058e1069b3ed7ed967c93fc63abba06f31144" @@ -61,10 +64,42 @@ class HTTPBasicsTest(BitcoinTestFramework): rpcauth3 = lines[1] self.password = lines[3] + # Generate rpcauthfile with one entry + username = 'rpcauth_single_' + ''.join(SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(10)) + p = subprocess.Popen([sys.executable, gen_rpcauth, "--output", Path(self.options.tmpdir) / 'rpcauth_single', username], stdout=subprocess.PIPE, universal_newlines=True) + lines = p.stdout.read().splitlines() + self.authinfo.append( (username, lines[1]) ) + + # Generate rpcauthfile with two entries + username = 'rpcauth_multi1_' + ''.join(SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(10)) + p = subprocess.Popen([sys.executable, gen_rpcauth, "--output", Path(self.options.tmpdir) / 'rpcauth_multi', username], stdout=subprocess.PIPE, universal_newlines=True) + lines = p.stdout.read().splitlines() + self.authinfo.append( (username, lines[1]) ) + # Blank lines in between should get ignored + with open(Path(self.options.tmpdir) / 'rpcauth_multi', "a", encoding='utf8') as f: + f.write("\n\n") + username = 'rpcauth_multi2_' + ''.join(SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(10)) + p = subprocess.Popen([sys.executable, gen_rpcauth, "--output", Path(self.options.tmpdir) / 'rpcauth_multi', username], stdout=subprocess.PIPE, universal_newlines=True) + lines = p.stdout.read().splitlines() + self.authinfo.append( (username, lines[1]) ) + + # Hand-generated rpcauthfile with one entry and no newline + username = 'rpcauth_nonewline_' + ''.join(SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(10)) + p = subprocess.Popen([sys.executable, gen_rpcauth, username], stdout=subprocess.PIPE, universal_newlines=True) + lines = p.stdout.read().splitlines() + assert "\n" not in lines[1] + assert lines[1][:8] == 'rpcauth=' + with open(Path(self.options.tmpdir) / 'rpcauth_nonewline', "a", encoding='utf8') as f: + f.write(lines[1][8:]) + self.authinfo.append( (username, lines[3]) ) + with open(self.nodes[0].datadir_path / "bitcoin.conf", "a", encoding="utf8") as f: f.write(rpcauth + "\n") f.write(rpcauth2 + "\n") f.write(rpcauth3 + "\n") + f.write("rpcauthfile=rpcauth_single\n") + f.write("rpcauthfile=rpcauth_multi\n") + f.write("rpcauthfile=rpcauth_nonewline\n") with open(self.nodes[1].datadir_path / "bitcoin.conf", "a", encoding="utf8") as f: f.write("rpcuser={}\n".format(self.rpcuser)) f.write("rpcpassword={}\n".format(self.rpcpassword)) @@ -93,6 +128,8 @@ class HTTPBasicsTest(BitcoinTestFramework): self.test_auth(self.nodes[0], 'rt', self.rtpassword) self.test_auth(self.nodes[0], 'rt2', self.rt2password) self.test_auth(self.nodes[0], self.user, self.password) + for info in self.authinfo: + self.test_auth(self.nodes[0], *info) self.log.info('Check correctness of the rpcuser/rpcpassword config options') url = urllib.parse.urlparse(self.nodes[1].url) @@ -101,9 +138,45 @@ class HTTPBasicsTest(BitcoinTestFramework): init_error = 'Error: Unable to start HTTP server. See debug log for details.' + self.log.info('Check blank -rpcauth is ignored') + rpcauth_abc = '-rpcauth=abc:$2e32c2f20c67e29c328dd64a4214180f18da9e667d67c458070fd856f1e9e5e7' + rpcauth_def = '-rpcauth=def:$fd7adb152c05ef80dccf50a1fa4c05d5a3ec6da95575fc312ae7c5d091836351' + self.restart_node(0, extra_args=['-rpcauth']) + self.restart_node(0, extra_args=['-rpcauth=', rpcauth_abc]) + self.restart_node(0, extra_args=[rpcauth_def, '-rpcauth=']) + # ...without disrupting usage of other -rpcauth tokens + assert_equal(200, call_with_auth(self.nodes[0], 'def', 'abc').status) + assert_equal(200, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status) + for info in self.authinfo: + assert_equal(200, call_with_auth(self.nodes[0], *info).status) + + self.log.info('Check -norpcauth disables all previous -rpcauth* params') + self.restart_node(0, extra_args=[rpcauth_def, '-norpcauth']) + assert_equal(401, call_with_auth(self.nodes[0], 'def', 'abc').status) + assert_equal(401, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status) + for info in self.authinfo: + assert_equal(401, call_with_auth(self.nodes[0], *info).status) + + self.log.info('Check -norpcauth can be reversed with -rpcauth') + self.restart_node(0, extra_args=[rpcauth_def, '-norpcauth', '-rpcauth']) + # FIXME: assert_equal(200, call_with_auth(self.nodes[0], 'def', 'abc').status) + assert_equal(200, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status) + for info in self.authinfo: + assert_equal(200, call_with_auth(self.nodes[0], *info).status) + + self.log.info('Check -norpcauth followed by a specific -rpcauth=* restores config file -rpcauth=* values too') + self.restart_node(0, extra_args=[rpcauth_def, '-norpcauth', rpcauth_abc]) + assert_equal(401, call_with_auth(self.nodes[0], 'def', 'abc').status) + assert_equal(200, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status) + for info in self.authinfo: + assert_equal(200, call_with_auth(self.nodes[0], *info).status) + self.restart_node(0, extra_args=[rpcauth_def, '-norpcauth', '-rpcauth=']) + assert_equal(401, call_with_auth(self.nodes[0], 'def', 'abc').status) + assert_equal(200, call_with_auth(self.nodes[0], 'rt', self.rtpassword).status) + for info in self.authinfo: + assert_equal(200, call_with_auth(self.nodes[0], *info).status) + self.log.info('Check -rpcauth are validated') - # Empty -rpcauth= are ignored - self.restart_node(0, extra_args=['-rpcauth=']) self.stop_node(0) self.nodes[0].assert_start_raises_init_error(expected_msg=init_error, extra_args=['-rpcauth=foo']) self.nodes[0].assert_start_raises_init_error(expected_msg=init_error, extra_args=['-rpcauth=foo:bar'])