diff --git a/README.md b/README.md index dbee37f1cfe642e594030592570b14e5987cabf1..74aedf6d8c470e55f18bc1beef92ae31dcdbe99c 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,11 @@ silkaj -ep <hostname>:<port>/<path> <sub-command> ### Authentication -- Authentication methods: Scrypt, file, and (E)WIF +- Authentication methods: Scrypt, Seedhex, PubSec, and (E)WIF ### Others +- Account storage - Display Ğ1 monetary license - Public key checksum diff --git a/docs/usage/account_storage.md b/docs/usage/account_storage.md new file mode 100644 index 0000000000000000000000000000000000000000..817db0803c0c188c561d6bbf8151e4794e8c2eac --- /dev/null +++ b/docs/usage/account_storage.md @@ -0,0 +1,72 @@ +# Account storage + +Silkaj features the account storage to store and read security-wise important documents from a location on your local computer. +It is used to store and read authentication and revocation files with Silkaj. +It is recommended to use the storage instead of storing and reading these files anywhere on your system. + +They are stored into `$XDG_DATA_HOME/silkaj`, aka `$HOME/.local/share/silkaj/$currency/$account_name` as: + +- `revocation.txt` +- `authentication_file_ed25519.dewif` for v1 WIF and EWIF formats based on the approved [RFC n°13](https://git.duniter.org/documents/rfcs/-/blob/master/rfc/0013_Duniter_Encrypted_Wallet_Import_Format.md?ref_type=heads). +- `authentication_file_sr25519.json` for v2 encrypted json format + +The account name is a local name given to a wallet. +It does not necessarily need to be the same identity nickname/alias stored into the blockchain. +No verification what so ever is performed to check any correspondence between the local name stored into Silkaj account storage and the one stored on the blockchain or the indexer. + +## Per currency storage + +The storage is organized per currencies. +Depending on the specified endpoint with the endpoint options (`--endpoint`, `--gtest`) the currency will be determined. Based on the latter, it will stored into `g1` or `g1-test` directory. + +## Authentication + +### Import + +Import your authentication file into the storage. +In case you want to use an other authentication method than the default Scrypt method, use one of the authentication options which can be find in `silkaj authentication --help` usage. + +Next command will store the authentication file in `$HOME/.local/share/silkaj/g1/test/authentication_file_ed25519.dewif` + +``` +silkaj --account test authentication <authentication option> +``` + +Note: `g1` and `test` folders comes respectively from the default Ğ1 endpoint and `test` from the account passed. + +### Reading + +Commands using authentication such as `money transfer`, `wot certify`, `wot membership`, and `money balance` will read the authentication file from the account storage. +With the general `--account` option, it will use the authentication file created in previous step. + +``` +silkaj --account test money transfer +``` + +## Revocation + +### Input/Output + +The general `--account` option (placed between `silkaj` and the sub-command) is used to read the authentication file and to write the revocation file in the same directory. + +``` +silkaj --account test authentication +``` + +### Creation + +``` +silkaj --gtest --account john wot revocation create +``` + +Will be stored into `$HOME/.local/share/silkaj/g1-test/john/revocation.txt` + +### Reading + +The revocation document can be read with `wot revocation publish` and `verify` commands as follow: + +``` +silkaj --gtest --account john wot revocation verify +``` + +Here we are reading the revocation file generated in previous step. diff --git a/docs/usage/index.md b/docs/usage/index.md index 2b4b5717bf1665087ecd6be30a0239bf883db0c1..afcab28e8c3225323f8e6e73ee245f31d45b9465 100644 --- a/docs/usage/index.md +++ b/docs/usage/index.md @@ -1,4 +1,5 @@ # Usage +- [Account storage](account_storage.md) - [Multi-recipients transfers and automation](multi-recipients_transfers_and_automation.md) - [DeathReaper](deathreaper.md) diff --git a/mkdocs.yml b/mkdocs.yml index b3061b97f1a42299bf95e944785ef4858a218a85..2a548970136259ae0e638206fbab3d6fe1fc9f6f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ nav: - Installation: install.md - Usage: - usage/index.md + - usage/account_storage.md - usage/multi-recipients_transfers_and_automation.md - DeathReaper: usage/deathreaper.md - Blog: diff --git a/silkaj/account_storage.py b/silkaj/account_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..f955af7e6af8880249f6adbff4a8a6bda4bd0be3 --- /dev/null +++ b/silkaj/account_storage.py @@ -0,0 +1,54 @@ +# Copyright 2016-2025 Maël Azimi <m.a@moul.re> +# +# Silkaj is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Silkaj is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Silkaj. If not, see <https://www.gnu.org/licenses/>. + +from pathlib import Path + +from silkaj import tools +from silkaj.blockchain import tools as bc_tools + + +class AccountStorage: + xdg_data_home = ".local/share" + program_name = "silkaj" + revocation_file_name = "revocation.txt" + authentication_v1_file_name = "authentication_file_ed25519.dewif" + authentication_v2_file_name = "authentication_file_sr25519.json" + + def __init__(self) -> None: + self.account_name = tools.has_account_defined() + + self.path = Path.home().joinpath( + self.xdg_data_home, + self.program_name, + bc_tools.get_currency(), + self.account_name, + ) + self.path.mkdir(parents=True, exist_ok=True) + + def authentication_file_path(self, check_exist: bool = True) -> Path: + auth_file_path = self.path.joinpath(self.authentication_v1_file_name) + if check_exist and not auth_file_path.is_file(): + tools.click_fail( + f"{auth_file_path} not found for account name: {self.account_name}", + ) + return auth_file_path + + def revocation_path(self, check_exist: bool = True) -> Path: + revocation_path = self.path.joinpath(self.revocation_file_name) + if check_exist and not revocation_path.is_file(): + tools.click_fail( + f"{revocation_path} not found for account name: {self.account_name}", + ) + return revocation_path diff --git a/silkaj/auth.py b/silkaj/auth.py index e394bb0686511131f91fefeb4133e66ac829c60b..8e84672e17a94bf4bdb5f99ab81db7af1cef1e50 100644 --- a/silkaj/auth.py +++ b/silkaj/auth.py @@ -16,12 +16,15 @@ import re import sys from pathlib import Path +from typing import Optional import rich_click as click from duniterpy.key.scrypt_params import ScryptParams from duniterpy.key.signing_key import SigningKey, SigningKeyException -from silkaj.constants import FAILURE_EXIT_STATUS, PUBKEY_PATTERN +from silkaj import tools +from silkaj.account_storage import AccountStorage +from silkaj.constants import PUBKEY_PATTERN from silkaj.public_key import gen_pubkey_checksum SEED_HEX_PATTERN = "^[0-9a-fA-F]{64}$" @@ -31,54 +34,126 @@ PUBSEC_SIGNKEY_PATTERN = "sec: ([1-9A-HJ-NP-Za-km-z]{87,90})" @click.pass_context def auth_method(ctx: click.Context) -> SigningKey: - if ctx.obj.get("AUTH_SEED"): + """Account storage authentication""" + password = ctx.obj["PASSWORD"] + authfile = AccountStorage().authentication_file_path() + wif_content = authfile.read_text() + regex = re.compile("Type: ([a-zA-Z]+)", re.MULTILINE) + match = re.search(regex, wif_content) + if match and match.groups()[0] == "EWIF" and not password: + password = click.prompt("Encrypted WIF, enter your password", hide_input=True) + return auth_by_wif_file(authfile, password) + + +def auth_options( + auth_file: Path, + auth_seed: bool, + auth_wif: bool, + nrp: Optional[str] = None, +) -> SigningKey: + """Authentication from CLI options""" + if auth_file: + return auth_by_auth_file(auth_file) + if auth_seed: return auth_by_seed() - if ctx.obj.get("AUTH_FILE_PATH"): - return auth_by_auth_file() - if ctx.obj.get("AUTH_WIF"): + if auth_wif: return auth_by_wif() - return auth_by_scrypt() + return auth_by_scrypt(nrp) +@click.command("authentication", help="Generate and store authentication file") +@click.option( + "--auth-scrypt", + "--scrypt", + is_flag=True, + help="Scrypt authentication: default method", + cls=tools.MutuallyExclusiveOption, + mutually_exclusive=["auth_file", "auth_seed", "auth_wif"], +) +@click.option("--nrp", help='Scrypt parameters: defaults N,r,p: "4096,16,1"') +@click.option( + "--auth-file", + "-af", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + help="Authentication file path", + cls=tools.MutuallyExclusiveOption, + mutually_exclusive=["auth_scrypt", "auth_seed", "auth_wif"], +) +@click.option( + "--auth-seed", + "--seed", + is_flag=True, + help="Seed hexadecimal authentication", + cls=tools.MutuallyExclusiveOption, + mutually_exclusive=["auth_scrypt", "auth_file", "auth_wif"], +) +@click.option( + "--auth-wif", + "--wif", + is_flag=True, + help="WIF and EWIF authentication methods", + cls=tools.MutuallyExclusiveOption, + mutually_exclusive=["auth_scrypt", "auth_file", "auth_seed"], +) +@click.option( + "--password", + "-p", + help="EWIF encryption password for the destination file. \ +If no password argument is passed, WIF format will be used. \ +If you use this option prefix the command \ +with a space so the password does not get saved in your shell history. \ +Password input will be suggested via a prompt.", +) @click.pass_context -def has_auth_method(ctx: click.Context) -> bool: - return ( - ("AUTH_SCRYPT" in ctx.obj and ctx.obj["AUTH_SCRYPT"]) - or ("AUTH_FILE_PATH" in ctx.obj and ctx.obj["AUTH_FILE_PATH"]) - or ("AUTH_SEED" in ctx.obj and ctx.obj["AUTH_SEED"]) - or ("AUTH_WIF" in ctx.obj and ctx.obj["AUTH_WIF"]) - ) - +def generate_auth_file( + ctx: click.Context, + auth_scrypt: bool, + nrp: Optional[str], + auth_file: Path, + auth_seed: bool, + auth_wif: bool, + password: Optional[str], +) -> None: + auth_file_path = AccountStorage().authentication_file_path(check_exist=False) + + if not password and click.confirm( + "Would you like to encrypt the generated authentication file?", + ): + password = click.prompt("Enter encryption password", hide_input=True) + + if password: + password_confirmation = click.prompt( + "Enter encryption password confirmation", + hide_input=True, + ) + if password != password_confirmation: + tools.click_fail("Entered passwords differ") -@click.command("authentication", help="Generate authentication file") -@click.argument( - "auth_file", - type=click.Path(dir_okay=False, writable=True, path_type=Path), -) -def generate_auth_file(auth_file: Path) -> None: - key = auth_method() + key = auth_options(auth_file, auth_seed, auth_wif, nrp) pubkey_cksum = gen_pubkey_checksum(key.pubkey) - if auth_file.is_file(): + if auth_file_path.is_file(): message = ( - f"Would you like to erase {auth_file} with an authentication file corresponding \n\ + f"Would you like to erase {auth_file_path} with an authentication file corresponding \ to following pubkey `{pubkey_cksum}`?" ) click.confirm(message, abort=True) - key.save_seedhex_file(auth_file) + if password: + key.save_ewif_file(auth_file_path, password) + else: + key.save_wif_file(auth_file_path) print( - f"Authentication file '{auth_file}' generated and stored for public key: {pubkey_cksum}", + f"Authentication file '{auth_file_path}' generated and stored for public key: {pubkey_cksum}", ) @click.pass_context -def auth_by_auth_file(ctx: click.Context) -> SigningKey: +def auth_by_auth_file(ctx: click.Context, authfile: Path) -> SigningKey: """ Uses an authentication file to generate the key Authfile can either be: * A seed in hexadecimal encoding * PubSec format with public and private key in base58 encoding """ - authfile = ctx.obj["AUTH_FILE_PATH"] filetxt = authfile.read_text(encoding="utf-8") # two regural expressions for the PubSec format @@ -98,14 +173,12 @@ def auth_by_seed() -> SigningKey: seedhex = click.prompt("Please enter your seed on hex format", hide_input=True) try: return SigningKey.from_seedhex(seedhex) - # To be fixed upstream in DuniterPy except SigningKeyException as error: - print(error) - sys.exit(FAILURE_EXIT_STATUS) + tools.click_fail(error) @click.pass_context -def auth_by_scrypt(ctx: click.Context) -> SigningKey: +def auth_by_scrypt(ctx: click.Context, nrp: Optional[str]) -> SigningKey: salt = click.prompt( "Please enter your Scrypt Salt (Secret identifier)", hide_input=True, @@ -117,11 +190,11 @@ def auth_by_scrypt(ctx: click.Context) -> SigningKey: default="", ) - if ctx.obj["AUTH_SCRYPT_PARAMS"]: - n, r, p = ctx.obj["AUTH_SCRYPT_PARAMS"].split(",") + if nrp: + a, b, c = nrp.split(",") - if n.isnumeric() and r.isnumeric() and p.isnumeric(): - n, r, p = int(n), int(r), int(p) + if a.isnumeric() and b.isnumeric() and c.isnumeric(): + n, r, p = int(a), int(b), int(c) if n <= 0 or n > 65536 or r <= 0 or r > 512 or p <= 0 or p > 32: sys.exit("Error: the values of Scrypt parameters are not good") scrypt_params = ScryptParams(n, r, p) @@ -132,9 +205,8 @@ def auth_by_scrypt(ctx: click.Context) -> SigningKey: try: return SigningKey.from_credentials(salt, password, scrypt_params) - except ValueError as error: - print(error) - sys.exit(FAILURE_EXIT_STATUS) + except SigningKeyException as error: + tools.click_fail(error) def auth_by_wif() -> SigningKey: @@ -148,7 +220,12 @@ def auth_by_wif() -> SigningKey: ) try: return SigningKey.from_wif_or_ewif_hex(wif_hex, password) - # To be fixed upstream in DuniterPy except SigningKeyException as error: - print(error) - sys.exit(FAILURE_EXIT_STATUS) + tools.click_fail(error) + + +def auth_by_wif_file(wif_file: Path, password: Optional[str] = None) -> SigningKey: + try: + return SigningKey.from_wif_or_ewif_file(wif_file, password) + except SigningKeyException as error: + tools.click_fail(error) diff --git a/silkaj/checksum.py b/silkaj/checksum.py index 8214d6c4af11f8f4a7c071ae4c0e52ce8e6094f4..bc9e3f8d911a4e402924103f74ed9fbf7fc538af 100644 --- a/silkaj/checksum.py +++ b/silkaj/checksum.py @@ -18,7 +18,7 @@ import sys import rich_click as click -from silkaj.auth import auth_method, has_auth_method +from silkaj import auth, tools from silkaj.public_key import ( PUBKEY_CHECKSUM_PATTERN, PUBKEY_DELIMITED_PATTERN, @@ -36,8 +36,8 @@ MESSAGE = "You should specify a pubkey or an authentication method" ) @click.argument("pubkey_checksum", nargs=-1) def checksum_command(pubkey_checksum: str) -> None: - if has_auth_method(): - key = auth_method() + if tools.has_account_defined(exit_error=False): + key = auth.auth_method() click.echo(gen_pubkey_checksum(key.pubkey)) elif not pubkey_checksum: sys.exit(MESSAGE) diff --git a/silkaj/cli.py b/silkaj/cli.py index b3e2fc627a657995bda9c49e1ae551e55205c817..93217de90a41e412e2fc8bfeeafe97a1c8e346ee 100644 --- a/silkaj/cli.py +++ b/silkaj/cli.py @@ -15,7 +15,6 @@ import contextlib import sys -from pathlib import Path import rich_click as click from duniterpy.api.endpoint import endpoint as du_endpoint @@ -54,14 +53,8 @@ click.rich_click.OPTION_GROUPS = { "options": ["--endpoint", "--gtest"], }, { - "name": "Authentication", - "options": [ - "--auth-scrypt", - "--nrp", - "--auth-file", - "--auth-seed", - "--auth-wif", - ], + "name": "Account and authentication specification", + "options": ["--account", "--password"], }, ], } @@ -91,29 +84,18 @@ Note that --endpoint has precedence over --gtest.", mutually_exclusive=["endpoint"], ) @click.option( - "--auth-scrypt", - "--scrypt", - is_flag=True, - help="Scrypt authentication: default method", -) -@click.option("--nrp", help='Scrypt parameters: defaults N,r,p: "4096,16,1"') -@click.option( - "--auth-file", - "-af", - type=click.Path(exists=True, dir_okay=False, path_type=Path), - help="Authentication file path", + "account_name", + "--account", + "-a", + help="Account name used in storage $HOME/.local/share/silkaj/$currency/$account_name \ +for authentication and revocation", ) @click.option( - "--auth-seed", - "--seed", - is_flag=True, - help="Seed hexadecimal authentication", -) -@click.option( - "--auth-wif", - "--wif", - is_flag=True, - help="WIF and EWIF authentication methods", + "--password", + "-p", + help="EWIF authentication password. If you use this option, prefix the command \ +with a space so the password is not saved in your shell history. \ +In case of an encrypted file, password input will be prompted.", ) @click.option( "--display", @@ -133,11 +115,8 @@ def cli( ctx: click.Context, endpoint: str, gtest: bool, - auth_scrypt: bool, - nrp: str, - auth_file: Path, - auth_seed: bool, - auth_wif: bool, + account_name: str, + password: str, display: bool, dry_run: bool, ) -> None: @@ -148,11 +127,8 @@ def cli( ctx.ensure_object(dict) ctx.obj["ENDPOINT"] = endpoint ctx.obj["GTEST"] = gtest - ctx.obj["AUTH_SCRYPT"] = auth_scrypt - ctx.obj["AUTH_SCRYPT_PARAMS"] = nrp - ctx.obj["AUTH_FILE_PATH"] = auth_file - ctx.obj["AUTH_SEED"] = auth_seed - ctx.obj["AUTH_WIF"] = auth_wif + ctx.obj["ACCOUNT_NAME"] = account_name + ctx.obj["PASSWORD"] = password ctx.obj["DISPLAY_DOCUMENT"] = display ctx.obj["DRY_RUN"] = dry_run ctx.help_option_names = ["-h", "--help"] diff --git a/silkaj/money/balance.py b/silkaj/money/balance.py index 25f1d5e0fcfd84e21f8a416e88a377f07e7ceb50..e1159906b81016172aab94b1cf7b360155acf02e 100644 --- a/silkaj/money/balance.py +++ b/silkaj/money/balance.py @@ -18,12 +18,11 @@ from typing import Optional import rich_click as click -from silkaj import tui -from silkaj.auth import auth_method, has_auth_method +from silkaj import tools, tui +from silkaj.auth import auth_method from silkaj.blockchain.tools import get_head_block from silkaj.money import tools as m_tools from silkaj.public_key import gen_pubkey_checksum, is_pubkey_and_check -from silkaj.tools import get_currency_symbol from silkaj.wot import tools as wt @@ -31,7 +30,7 @@ from silkaj.wot import tools as wt @click.argument("pubkeys", nargs=-1) @click.pass_context def balance_cmd(ctx: click.Context, pubkeys: str) -> None: - if not has_auth_method(): + if not tools.has_account_defined(exit_error=False): # check input pubkeys if not pubkeys: sys.exit("You should specify one or many pubkeys") @@ -74,7 +73,7 @@ def show_amount_from_pubkey(label: str, inputs_balance: list[int]) -> None: """ totalAmountInput = inputs_balance[0] balance = inputs_balance[1] - currency_symbol = get_currency_symbol() + currency_symbol = tools.get_currency_symbol() ud_value = m_tools.get_ud_value() average = get_average() member = None diff --git a/silkaj/tools.py b/silkaj/tools.py index fc6e11f6296b332f296a480f53364550ce9cd06a..c3c17cdbb19cf3b82f36bb26a266bca5184921e9 100644 --- a/silkaj/tools.py +++ b/silkaj/tools.py @@ -15,7 +15,7 @@ import functools import sys -from typing import Any +from typing import Any, Union import rich_click as click @@ -31,11 +31,28 @@ def get_currency_symbol() -> str: return GTEST_SYMBOL +@click.pass_context +def has_account_defined( + ctx: click.Context, + exit_error: bool = True, +) -> Union[bool, str]: + if not (account_name := ctx.obj["ACCOUNT_NAME"]): + if exit_error: + click_fail("--account general option should be specified") + return False + return account_name + + def message_exit(message: str) -> None: print(message) sys.exit(FAILURE_EXIT_STATUS) +@click.pass_context +def click_fail(context: click.Context, message: str) -> None: + context.fail(message) + + class MutuallyExclusiveOption(click.Option): def __init__(self, *args: Any, **kwargs: Any) -> None: self.mutually_exclusive = set(kwargs.pop("mutually_exclusive", [])) diff --git a/silkaj/wot/revocation.py b/silkaj/wot/revocation.py index a90a5c2177045861fc04bc39126da63fa5af3f90..c3e5ecb9d15d5f26e1f7b5527678f0594f27382a 100644 --- a/silkaj/wot/revocation.py +++ b/silkaj/wot/revocation.py @@ -26,7 +26,8 @@ from duniterpy.documents.revocation import Revocation from duniterpy.key.verifying_key import VerifyingKey from silkaj import auth, network, tui -from silkaj.blockchain.tools import get_currency +from silkaj.account_storage import AccountStorage +from silkaj.blockchain import tools as bc_tools from silkaj.constants import FAILURE_EXIT_STATUS, SUCCESS_EXIT_STATUS from silkaj.public_key import gen_pubkey_checksum from silkaj.wot import idty_tools @@ -34,13 +35,8 @@ from silkaj.wot import tools as w_tools @click.command("create", help="Create and save a revocation document") -@click.argument( - "file", - type=click.Path(dir_okay=False, writable=True, path_type=Path), -) -@click.pass_context -def create(ctx: click.Context, file: Path) -> None: - currency = get_currency() +def create() -> None: + currency = bc_tools.get_currency() key = auth.auth_method() gen_pubkey_checksum(key.pubkey) @@ -51,9 +47,11 @@ def create(ctx: click.Context, file: Path) -> None: idty_table = idty_tools.display_identity(rev_doc.identity) click.echo(idty_table.draw()) + revocation_file_path = AccountStorage().revocation_path(check_exist=False) + confirm_message = "Do you want to save the revocation document for this identity?" if click.confirm(confirm_message): - save_doc(file, rev_doc.signed_raw(), key.pubkey) + save_doc(revocation_file_path, rev_doc.signed_raw(), key.pubkey) else: click.echo("Ok, goodbye!") @@ -64,7 +62,7 @@ def create(ctx: click.Context, file: Path) -> None: ) @click.pass_context def revoke_now(ctx: click.Context) -> None: - currency = get_currency() + currency = bc_tools.get_currency() warn_before_dry_run_or_display(ctx) @@ -89,16 +87,11 @@ def revoke_now(ctx: click.Context) -> None: @click.command( "verify", - help="Verifies that a revocation document is correctly formatted and matches an \ -existing identity", + help="Verifies that a revocation document is correctly formatted and matches an existing identity", ) -@click.argument( - "file", - type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path), -) -@click.pass_context -def verify(ctx: click.Context, file: Path) -> None: - rev_doc = verify_document(file) +def verify() -> None: + revocation_file_path = AccountStorage().revocation_path() + rev_doc = verify_document(revocation_file_path) idty_table = idty_tools.display_identity(rev_doc.identity) click.echo(idty_table.draw()) click.echo("Revocation document is valid.") @@ -108,15 +101,12 @@ def verify(ctx: click.Context, file: Path) -> None: "publish", help="Publish revocation document. Identity will be revoked immediately", ) -@click.argument( - "file", - type=click.Path(exists=True, dir_okay=False, readable=True, path_type=Path), -) @click.pass_context -def publish(ctx: click.Context, file: Path) -> None: +def publish(ctx: click.Context) -> None: + revocation_file_path = AccountStorage().revocation_path() warn_before_dry_run_or_display(ctx) - rev_doc = verify_document(file) + rev_doc = verify_document(revocation_file_path) if ctx.obj["DRY_RUN"]: click.echo(rev_doc.signed_raw()) return @@ -173,7 +163,7 @@ def save_doc(rev_path: Path, content: str, pubkey: str) -> None: if rev_path.is_file(): if click.confirm( f"Would you like to erase existing file `{rev_path}` with the \ -gene rated revocation document corresponding to {pubkey_cksum} public key?", +generated revocation document corresponding to {pubkey_cksum} public key?", ): rev_path.unlink() else: diff --git a/tests/patched/auth.py b/tests/patched/auth.py index 4582708da5858b11be1e339bcc523d25bc13642b..45a7b8d2f11ad0a73d7b814d7207a8747095d050 100644 --- a/tests/patched/auth.py +++ b/tests/patched/auth.py @@ -33,9 +33,9 @@ def patched_auth_by_wif(): return "call_auth_by_wif" -def patched_auth_by_auth_file(): +def patched_auth_by_auth_file(authfile): return "call_auth_by_auth_file" -def patched_auth_by_scrypt(): +def patched_auth_by_scrypt(nrp): return "call_auth_by_scrypt" diff --git a/tests/patched/blockchain_tools.py b/tests/patched/blockchain_tools.py index ed7b4c03d0f092ef69d35070a5da33cb2b5665f3..39fc9984a84bbfdf608570066770f15529e6083a 100644 --- a/tests/patched/blockchain_tools.py +++ b/tests/patched/blockchain_tools.py @@ -59,3 +59,11 @@ def patched_get_head_block(): def patched_get_head_block_gtest(): return mocked_block_gtest + + +def patched_get_currency(): + return currency + + +def patched_get_currency_gtest(): + return "g1-test" diff --git a/tests/unit/test_account_storage.py b/tests/unit/test_account_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..57b7251aef8064376280f069b899572c04fd48e1 --- /dev/null +++ b/tests/unit/test_account_storage.py @@ -0,0 +1,49 @@ +# Copyright 2016-2025 Maël Azimi <m.a@moul.re> +# +# Silkaj is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Silkaj is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Silkaj. If not, see <https://www.gnu.org/licenses/>. + +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from silkaj.account_storage import AccountStorage +from silkaj.blockchain import tools +from tests import helpers + + +@pytest.mark.parametrize( + ("account_name", "currency"), + [ + ("test", "g1"), + ("toto", "g1-test"), + ], +) +def test_account_storage_account(account_name, currency, monkeypatch): + def patched_get_currency(): + return currency + + helpers.define_click_context(account_name=account_name) + patched_pathlib_mkdir = Mock() + monkeypatch.setattr(Path, "mkdir", patched_pathlib_mkdir) + monkeypatch.setattr(tools, "get_currency", patched_get_currency) + + account_storage = AccountStorage() + assert account_storage.path == Path.home().joinpath( + account_storage.xdg_data_home, + account_storage.program_name, + currency, + account_name, + ) + patched_pathlib_mkdir.assert_called_once() diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 32c5ef7fa85baf69828d64202887b3c7ddf86576..a98acfd560309f1632c88d6a075aeb57b7699a13 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -13,31 +13,73 @@ # You should have received a copy of the GNU Affero General Public License # along with Silkaj. If not, see <https://www.gnu.org/licenses/>. +from pathlib import Path +from unittest.mock import Mock + import pytest +from click.testing import CliRunner -from silkaj import auth -from tests import helpers +from silkaj import auth, cli +from silkaj.blockchain import tools from tests.patched.auth import ( patched_auth_by_auth_file, patched_auth_by_scrypt, patched_auth_by_seed, patched_auth_by_wif, ) +from tests.patched.blockchain_tools import patched_get_currency @pytest.mark.parametrize( - ("context", "auth_method_called"), + ("kwargs", "auth_method_called"), [ - ({"auth_seed": True}, "call_auth_by_seed"), - ({"auth_file_path": True}, "call_auth_by_auth_file"), - ({"auth_wif": True}, "call_auth_by_wif"), - ({}, "call_auth_by_scrypt"), + ((True, None, None), "call_auth_by_auth_file"), + ((None, True, None), "call_auth_by_seed"), + ((None, None, True), "call_auth_by_wif"), + ((None, None, None), "call_auth_by_scrypt"), ], ) -def test_auth_method(context, auth_method_called, monkeypatch): +def test_auth_options(kwargs, auth_method_called, monkeypatch): monkeypatch.setattr(auth, "auth_by_seed", patched_auth_by_seed) monkeypatch.setattr(auth, "auth_by_wif", patched_auth_by_wif) monkeypatch.setattr(auth, "auth_by_auth_file", patched_auth_by_auth_file) monkeypatch.setattr(auth, "auth_by_scrypt", patched_auth_by_scrypt) - helpers.define_click_context(**context) - assert auth_method_called == auth.auth_method() + assert auth_method_called == auth.auth_options(*kwargs) + + +def build_authentication_cmd(account): + command = ["--account", account] if account else [] + command.append("authentication") + return command + + +@pytest.mark.parametrize( + ("account"), + [ + (None), + ("test"), + ], +) +def test_authentication_cmd_cli(account, monkeypatch): + patched_auth_options = Mock() + monkeypatch.setattr(auth, "auth_options", patched_auth_options) + monkeypatch.setattr(Path, "mkdir", Mock) + monkeypatch.setattr(tools, "get_currency", patched_get_currency) + + command = build_authentication_cmd(account) + result = CliRunner().invoke(cli.cli, args=command) + if not account: + assert "--account general option should be specified" in result.output + patched_auth_options.assert_not_called() + else: + patched_auth_options.assert_called_once() + + +def test_authentication_wif(monkeypatch): + patched_auth_by_wif = Mock() + monkeypatch.setattr(auth, "auth_by_wif", patched_auth_by_wif) + + command = build_authentication_cmd("test") + command.append("--auth-wif") + CliRunner().invoke(cli.cli, args=command) + patched_auth_by_wif.assert_called_once() diff --git a/tests/unit/test_checksum.py b/tests/unit/test_checksum.py index eab8a72cb486155572378008fbeb1b7ead6fe8ec..4223a49a3e07db79a4201b99bbb52c84df3782a7 100644 --- a/tests/unit/test_checksum.py +++ b/tests/unit/test_checksum.py @@ -13,21 +13,23 @@ # You should have received a copy of the GNU Affero General Public License # along with Silkaj. If not, see <https://www.gnu.org/licenses/>. -from pathlib import Path import pytest from click.testing import CliRunner +from silkaj import auth from silkaj.checksum import MESSAGE from silkaj.cli import cli +from tests.patched.auth import patched_auth_method -pubkey = "3rp7ahDGeXqffBQTnENiXEFXYS7BRjYmS33NbgfCuDc8" -checksum = "DFQ" +pubkey = "DCovzCEnQm9GUWe6mr8u42JR1JAuoj3HbQUGdCkfTzSr" +checksum = "FwY" pubkey_checksum = f"{pubkey}:{checksum}" -pubkey_seedhex_authfile = ( - "3bc6f2484e441e40562155235cdbd8ce04c25e7df35bf5f87c067bf239db8511" -) -AUTH_FILE = "auth_file.txt" +account = "test" + + +def patched_auth_method_test(): + return patched_auth_method(account) @pytest.mark.parametrize( @@ -38,12 +40,11 @@ AUTH_FILE = "auth_file.txt" (["checksum", pubkey], pubkey_checksum), (["checksum", "uid"], "Error: Wrong public key format"), (["checksum"], MESSAGE), - (["--auth-file", AUTH_FILE, "checksum"], pubkey_checksum), - (["--auth-file", AUTH_FILE, "checksum", "pubkey"], pubkey_checksum), + (["--account", account, "checksum"], pubkey_checksum), + (["--account", account, "checksum", "pubkey"], pubkey_checksum), ], ) -def test_checksum_command(command, excepted_output): - with CliRunner().isolated_filesystem(): - Path(AUTH_FILE).write_text(pubkey_seedhex_authfile, encoding="utf-8") - result = CliRunner().invoke(cli, args=command) - assert result.output == f"{excepted_output}\n" +def test_checksum_command(command, excepted_output, monkeypatch): + monkeypatch.setattr(auth, "auth_method", patched_auth_method_test) + result = CliRunner().invoke(cli, args=command) + assert result.output == f"{excepted_output}\n" diff --git a/tests/unit/wot/test_revocation.py b/tests/unit/wot/test_revocation.py index 7fd6eccdbe1584b40e90654443df08287c41910b..c6deb82939502acc3768b8439a23385b444a623c 100644 --- a/tests/unit/wot/test_revocation.py +++ b/tests/unit/wot/test_revocation.py @@ -24,6 +24,7 @@ from duniterpy.api import bma from duniterpy.documents.revocation import Revocation from silkaj import auth +from silkaj.account_storage import AccountStorage from silkaj.blockchain import tools as bc_tools from silkaj.cli import cli from silkaj.constants import FAILURE_EXIT_STATUS, SUCCESS_EXIT_STATUS @@ -32,7 +33,10 @@ from silkaj.public_key import gen_pubkey_checksum from silkaj.wot import idty_tools, revocation from silkaj.wot import tools as w_tools from tests.patched.auth import patched_auth_method -from tests.patched.blockchain_tools import patched_get_head_block_gtest +from tests.patched.blockchain_tools import ( + patched_get_currency_gtest, + patched_get_head_block_gtest, +) from tests.patched.idty_tools import idty1, idty2, idty_block, lookup_one, lookup_two REVOCATION_PATH = Path("revocation.txt") @@ -138,28 +142,31 @@ def patched_send_bma_revoke_error(wot_useless, rev_doc_useless): ) +def patched_account_storage_revocation_path(self): + return REVOCATION_PATH + + # tests # test cli dry-run @pytest.mark.parametrize( - ("subcommand", "expected_warn"), + ("subcommand"), [ - ( - "publish", - True, - ), - ( - "revoke", - True, - ), + ("publish"), + ("revoke"), ], ) -def test_revocation_cli_dry_run(subcommand, expected_warn, monkeypatch): +def test_revocation_cli_dry_run(subcommand, monkeypatch): """ Tests dry-run option behavior when associated with other options """ monkeypatch.setattr(auth, "auth_method", patched_auth_method_Claude) + monkeypatch.setattr( + AccountStorage, + "revocation_path", + patched_account_storage_revocation_path, + ) monkeypatch.setattr(bc_tools, "get_head_block", patched_get_head_block_gtest) monkeypatch.setattr(w_tools, "choose_identity", patched_choose_identity) monkeypatch.setattr(bma.blockchain, "block", patch_get_id_block) @@ -168,26 +175,19 @@ def test_revocation_cli_dry_run(subcommand, expected_warn, monkeypatch): "check_many_identities", patch_check_many_identities, ) - - print("subcommand: ", subcommand) # debug + monkeypatch.setattr(Path, "mkdir", Mock()) warning = "WARNING: the document will only be displayed and will not be sent." - - command = ["--dry-run", "-gt", "wot", "revocation", subcommand] - if subcommand == "publish": - command.append(str(REVOCATION_PATH)) + command = ("--dry-run", "-gt", "--account", "test", "wot", "revocation", subcommand) print("command: ", " ".join(command)) # debug + runner = CliRunner() with runner.isolated_filesystem(): REVOCATION_PATH.write_text(REV_DOC.signed_raw(), encoding="utf-8") result = runner.invoke(cli, args=command) assert idty1.pubkey in result.output assert "Version: 10" in result.output - - if expected_warn: - assert warning in result.output - else: - assert warning not in result.output + assert warning in result.output CREATE_STRINGS = [ @@ -198,36 +198,23 @@ CREATE_STRINGS = [ # test cli create @pytest.mark.parametrize( - ("file", "user_input", "expected"), + ("user_input", "expected"), [ ( - None, "yes\n", CREATE_STRINGS, ), ( None, - None, - CREATE_STRINGS, - ), - ( - Path("test_doc"), - "yes\n", CREATE_STRINGS, ), ( - Path("test_doc"), - None, - CREATE_STRINGS, - ), - ( - None, "no\n", [*CREATE_STRINGS, "Ok, goodbye!"], ), ], ) -def test_revocation_cli_create(file, user_input, expected, monkeypatch): +def test_revocation_cli_create(user_input, expected, monkeypatch): monkeypatch.setattr(auth, "auth_method", patched_auth_method_Claude) monkeypatch.setattr(bc_tools, "get_head_block", patched_get_head_block_gtest) monkeypatch.setattr(w_tools, "choose_identity", patched_choose_identity) @@ -238,15 +225,12 @@ def test_revocation_cli_create(file, user_input, expected, monkeypatch): "save_doc", patched_save_doc, ) + monkeypatch.setattr(Path, "mkdir", Mock()) - command = ["wot", "revocation", "create"] - if not file: - file = REVOCATION_PATH - command.extend([str(file)]) - + command = ("--account", "test", "wot", "revocation", "create") result = CliRunner().invoke(cli, args=command, input=user_input) if user_input == "yes\n": - patched_save_doc.assert_called_with(file, REV_DOC.signed_raw(), idty1.pubkey) + patched_save_doc.assert_called_once() elif user_input == "no\n": patched_save_doc.assert_not_called() for expect in expected: @@ -255,12 +239,11 @@ def test_revocation_cli_create(file, user_input, expected, monkeypatch): # test cli verify @pytest.mark.parametrize( - ("doc", "lookup", "file", "expected", "not_expected"), + ("doc", "lookup", "expected", "not_expected"), [ ( REV_DOC, lookup_one, - "", [ "| Public key |", "Revocation document is valid.\n", @@ -270,7 +253,6 @@ def test_revocation_cli_create(file, user_input, expected, monkeypatch): ( REV_DOC, lookup_two, - "", [ "One matching identity!\nSimilar identities:", "uid", @@ -282,7 +264,6 @@ def test_revocation_cli_create(file, user_input, expected, monkeypatch): ( REV_DOC_FALSE, lookup_one, - "", ["Error: the signature of the revocation document is invalid."], [ "| Public key |", @@ -292,7 +273,6 @@ def test_revocation_cli_create(file, user_input, expected, monkeypatch): ( REV_2, lookup_two, - Path("test_doc"), [ "Revocation document does not match any valid identity.\nSimilar identities:", "uid", @@ -309,7 +289,6 @@ def test_revocation_cli_create(file, user_input, expected, monkeypatch): def test_revocation_cli_verify( doc, lookup, - file, expected, not_expected, monkeypatch, @@ -320,34 +299,34 @@ def test_revocation_cli_verify( monkeypatch.setattr(bma.wot, "lookup", patched_lookup) monkeypatch.setattr(bma.blockchain, "block", patch_get_id_block) monkeypatch.setattr(bc_tools, "get_head_block", patched_get_head_block_gtest) + monkeypatch.setattr( + AccountStorage, + "revocation_path", + patched_account_storage_revocation_path, + ) + monkeypatch.setattr(Path, "mkdir", Mock()) - # prepare command - command = ["wot", "revocation", "verify"] - if not file: - file = REVOCATION_PATH - command.extend([str(file)]) - - # verify file + command = ("--account", "test", "wot", "revocation", "verify") runner = CliRunner() with runner.isolated_filesystem(): - Path(file).write_text(doc.signed_raw(), encoding="utf-8") - result = runner.invoke(cli, args=command) - for expect in expected: - assert expect in result.output - for not_expect in not_expected: - assert not_expect not in result.output + REVOCATION_PATH.write_text(doc.signed_raw(), encoding="utf-8") + result = CliRunner().invoke(cli, args=command) + + for expect in expected: + assert expect in result.output + for not_expect in not_expected: + assert not_expect not in result.output # test cli publish @pytest.mark.parametrize( - ("display", "dry_run", "doc", "lookup", "file", "user_input", "expected"), + ("display", "dry_run", "doc", "lookup", "user_input", "expected"), [ ( False, False, REV_DOC, lookup_one, - "", "yes\n", [ "| Public key |", @@ -359,7 +338,6 @@ def test_revocation_cli_verify( False, REV_DOC, lookup_two, - "", "yes\n", [ "One matching identity!\nSimilar identities:", @@ -373,7 +351,6 @@ def test_revocation_cli_verify( False, REV_DOC, lookup_one, - "", "yes\n", [ "| Public key |", @@ -386,7 +363,6 @@ def test_revocation_cli_verify( False, REV_DOC, lookup_two, - "", "yes\n", [ "One matching identity!\nSimilar identities:", @@ -400,7 +376,6 @@ def test_revocation_cli_verify( True, REV_DOC, lookup_one, - "", None, [ "WARNING: the document will only be displayed and will not be sent.", @@ -412,7 +387,6 @@ def test_revocation_cli_verify( True, REV_DOC, lookup_two, - "", None, [ "One matching identity!\nSimilar identities:", @@ -425,32 +399,17 @@ def test_revocation_cli_verify( False, REV_DOC, lookup_one, - "", "no\n", [ "| Public key |", "Do you confirm sending this revocation document immediately?", ], ), - ( - True, - False, - REV_DOC, - lookup_one, - "", - "no\n", - [ - "| Public key |", - "Do you confirm sending this revocation document immediately?", - "Version: 10", - ], - ), ( False, False, REV_DOC_FALSE, lookup_one, - "", None, ["Error: the signature of the revocation document is invalid."], ), @@ -459,7 +418,6 @@ def test_revocation_cli_verify( False, REV_DOC_FALSE, lookup_one, - "", None, ["Error: the signature of the revocation document is invalid."], ), @@ -468,31 +426,17 @@ def test_revocation_cli_verify( True, REV_DOC_FALSE, lookup_one, - "", None, [ "WARNING: the document will only be displayed and will not be sent.", "Error: the signature of the revocation document is invalid.", ], ), - ( - False, - False, - REV_DOC, - lookup_one, - Path("test_doc"), - "yes\n", - [ - "| Public key |", - "Do you confirm sending this revocation document immediately?", - ], - ), ( True, False, REV_DOC, lookup_one, - Path("test_doc"), "yes\n", [ "| Public key |", @@ -504,37 +448,17 @@ def test_revocation_cli_verify( True, REV_DOC, lookup_one, - Path("test_doc"), "yes\n", [ "WARNING: the document will only be displayed and will not be sent.", "Version: 10", ], ), - ( - False, - False, - REV_DOC_FALSE, - lookup_one, - Path("test_doc"), - None, - ["Error: the signature of the revocation document is invalid."], - ), - ( - True, - False, - REV_DOC_FALSE, - lookup_one, - Path("test_doc"), - None, - ["Error: the signature of the revocation document is invalid."], - ), ( False, True, REV_DOC_FALSE, lookup_one, - Path("test_doc"), None, [ "Error: the signature of the revocation document is invalid.", @@ -547,7 +471,6 @@ def test_revocation_cli_verify( REV_2, lookup_two, "", - "", [ "Revocation document does not match any valid identity.\nSimilar identities:", ], @@ -558,7 +481,6 @@ def test_revocation_cli_verify( REV_DOC, False, "", - "", ["Revocation document does not match any valid identity."], ), ], @@ -568,7 +490,6 @@ def test_revocation_cli_publish( dry_run, doc, lookup, - file, user_input, expected, monkeypatch, @@ -587,21 +508,24 @@ def test_revocation_cli_publish( monkeypatch.setattr(bma.wot, "lookup", patched_lookup) monkeypatch.setattr(bc_tools, "get_head_block", patched_get_head_block_gtest) monkeypatch.setattr(bma.blockchain, "block", patch_get_id_block) + monkeypatch.setattr( + AccountStorage, + "revocation_path", + patched_account_storage_revocation_path, + ) patched_send_bma_revoke = Mock() monkeypatch.setattr(bma.wot, "revoke", patched_send_bma_revoke) + monkeypatch.setattr(Path, "mkdir", Mock()) # prepare command command = display_dry_options(display, dry_run) - command.extend(["wot", "revocation", "publish"]) - if not file: - file = REVOCATION_PATH - command.extend([str(file)]) + command.extend(["--account", "test", "wot", "revocation", "publish"]) # test publication runner = CliRunner() with runner.isolated_filesystem(): - Path(file).write_text(doc.signed_raw(), encoding="utf-8") + REVOCATION_PATH.write_text(doc.signed_raw(), encoding="utf-8") result = runner.invoke(cli, args=command, input=user_input) if user_input == "yes\n" and not dry_run: patched_send_bma_revoke.assert_called_once_with( @@ -616,11 +540,10 @@ def test_revocation_cli_publish( # test cli publish send errors @pytest.mark.parametrize( - ("display", "file", "user_input", "expected"), + ("display", "user_input", "expected"), [ ( False, - "", "yes\n", [ "| Public key |", @@ -630,28 +553,6 @@ def test_revocation_cli_publish( ), ( True, - "", - "yes\n", - [ - "| Public key |", - "Do you confirm sending this revocation document immediately?", - "Version: 10", - "Error while publishing revocation", - ], - ), - ( - False, - Path("test_doc"), - "yes\n", - [ - "| Public key |", - "Do you confirm sending this revocation document immediately?", - "Error while publishing revocation", - ], - ), - ( - True, - Path("test_doc"), "yes\n", [ "| Public key |", @@ -664,7 +565,6 @@ def test_revocation_cli_publish( ) def test_revocation_cli_publish_send_errors( display, - file, user_input, expected, monkeypatch, @@ -672,22 +572,25 @@ def test_revocation_cli_publish_send_errors( def patched_lookup(node, id_pubkey): return lookup_one + monkeypatch.setattr( + AccountStorage, + "revocation_path", + patched_account_storage_revocation_path, + ) monkeypatch.setattr(bma.wot, "lookup", patched_lookup) monkeypatch.setattr(bc_tools, "get_head_block", patched_get_head_block_gtest) monkeypatch.setattr(bma.blockchain, "block", patch_get_id_block) monkeypatch.setattr(bma.wot, "revoke", patched_send_bma_revoke_error) + monkeypatch.setattr(Path, "mkdir", Mock()) # prepare command command = display_dry_options(display, False) - command.extend(["wot", "revocation", "publish"]) - if not file: - file = REVOCATION_PATH - command.extend([str(file)]) + command.extend(["--account", "test", "wot", "revocation", "publish"]) # test publication runner = CliRunner() with runner.isolated_filesystem(): - Path(file).write_text(REV_DOC.signed_raw(), encoding="utf-8") + REVOCATION_PATH.write_text(REV_DOC.signed_raw(), encoding="utf-8") result = runner.invoke(cli, args=command, input=user_input) for expect in expected: assert expect in result.output @@ -762,7 +665,7 @@ def test_revocation_cli_revoke( monkeypatch, ): monkeypatch.setattr(auth, "auth_method", patched_auth_method_Claude) - monkeypatch.setattr(bc_tools, "get_head_block", patched_get_head_block_gtest) + monkeypatch.setattr(bc_tools, "get_currency", patched_get_currency_gtest) monkeypatch.setattr(w_tools, "choose_identity", patched_choose_identity) monkeypatch.setattr(bma.blockchain, "block", patch_get_id_block) patched_send_bma_revoke = Mock()