Coverage for silkaj/auth.py: 43%
102 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-22 12:04 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-22 12:04 +0000
1# Copyright 2016-2025 Maël Azimi <m.a@moul.re>
2#
3# Silkaj is free software: you can redistribute it and/or modify
4# it under the terms of the GNU Affero General Public License as published by
5# the Free Software Foundation, either version 3 of the License, or
6# (at your option) any later version.
7#
8# Silkaj is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11# GNU Affero General Public License for more details.
12#
13# You should have received a copy of the GNU Affero General Public License
14# along with Silkaj. If not, see <https://www.gnu.org/licenses/>.
16import re
17from pathlib import Path
18from typing import Optional
20import rich_click as click
21from duniterpy.key.scrypt_params import ScryptParams
22from duniterpy.key.signing_key import SigningKey, SigningKeyException
24from silkaj import tools
25from silkaj.account_storage import AccountStorage
26from silkaj.constants import PUBKEY_PATTERN
27from silkaj.public_key import gen_pubkey_checksum
29SEED_HEX_PATTERN = "^[0-9a-fA-F]{64}$"
30PUBSEC_PUBKEY_PATTERN = f"pub: ({PUBKEY_PATTERN})"
31PUBSEC_SIGNKEY_PATTERN = "sec: ([1-9A-HJ-NP-Za-km-z]{87,90})"
34@click.pass_context
35def auth_method(ctx: click.Context) -> SigningKey:
36 """Account storage authentication"""
37 password = ctx.obj["PASSWORD"]
38 authfile = AccountStorage().authentication_file_path()
39 wif_content = authfile.read_text()
40 regex = re.compile("Type: ([a-zA-Z]+)", re.MULTILINE)
41 match = re.search(regex, wif_content)
42 if match and match.groups()[0] == "EWIF" and not password:
43 password = click.prompt("Encrypted WIF, enter your password", hide_input=True)
44 return auth_by_wif_file(authfile, password)
47def auth_options(
48 auth_file: Path,
49 auth_seed: bool,
50 auth_wif: bool,
51 nrp: Optional[str] = None,
52) -> SigningKey:
53 """Authentication from CLI options"""
54 if auth_file:
55 return auth_by_auth_file(auth_file)
56 if auth_seed:
57 return auth_by_seed()
58 if auth_wif:
59 return auth_by_wif()
60 return auth_by_scrypt(nrp)
63@click.command("authentication", help="Generate and store authentication file")
64@click.option(
65 "--auth-scrypt",
66 "--scrypt",
67 is_flag=True,
68 help="Scrypt authentication. Default method",
69 cls=tools.MutuallyExclusiveOption,
70 mutually_exclusive=["auth_file", "auth_seed", "auth_wif"],
71)
72@click.option("--nrp", help='Scrypt parameters: defaults N,r,p: "4096,16,1"')
73@click.option(
74 "--auth-file",
75 "-af",
76 type=click.Path(exists=True, dir_okay=False, path_type=Path),
77 help="Seed hexadecimal authentication from file path",
78 cls=tools.MutuallyExclusiveOption,
79 mutually_exclusive=["auth_scrypt", "auth_seed", "auth_wif"],
80)
81@click.option(
82 "--auth-seed",
83 "--seed",
84 is_flag=True,
85 help="Seed hexadecimal authentication",
86 cls=tools.MutuallyExclusiveOption,
87 mutually_exclusive=["auth_scrypt", "auth_file", "auth_wif"],
88)
89@click.option(
90 "--auth-wif",
91 "--wif",
92 is_flag=True,
93 help="WIF and EWIF authentication methods",
94 cls=tools.MutuallyExclusiveOption,
95 mutually_exclusive=["auth_scrypt", "auth_file", "auth_seed"],
96)
97@click.option(
98 "--password",
99 "-p",
100 help="EWIF encryption password for the destination file. \
101If no password argument is passed, WIF format will be used. \
102If you use this option prefix the command \
103with a space so the password does not get saved in your shell history. \
104Password input will be suggested via a prompt.",
105)
106@click.pass_context
107def generate_auth_file(
108 ctx: click.Context,
109 auth_scrypt: bool,
110 nrp: Optional[str],
111 auth_file: Path,
112 auth_seed: bool,
113 auth_wif: bool,
114 password: Optional[str],
115) -> None:
116 auth_file_path = AccountStorage().authentication_file_path(check_exist=False)
118 if not password and click.confirm(
119 "Would you like to encrypt the generated authentication file?",
120 ):
121 password = click.prompt("Enter encryption password", hide_input=True)
123 if password:
124 password_confirmation = click.prompt(
125 "Enter encryption password confirmation",
126 hide_input=True,
127 )
128 if password != password_confirmation:
129 tools.click_fail("Entered passwords differ")
131 key = auth_options(auth_file, auth_seed, auth_wif, nrp)
132 pubkey_cksum = gen_pubkey_checksum(key.pubkey)
133 if auth_file_path.is_file():
134 message = (
135 f"Would you like to erase {auth_file_path} with an authentication file corresponding \
136to following pubkey `{pubkey_cksum}`?"
137 )
138 click.confirm(message, abort=True)
139 if password:
140 key.save_ewif_file(auth_file_path, password)
141 else:
142 key.save_wif_file(auth_file_path)
143 print(
144 f"Authentication file '{auth_file_path}' generated and stored for public key: {pubkey_cksum}",
145 )
148@click.pass_context
149def auth_by_auth_file(ctx: click.Context, authfile: Path) -> SigningKey:
150 """
151 Uses an authentication file to generate the key
152 Authfile can either be:
153 * A seed in hexadecimal encoding
154 * PubSec format with public and private key in base58 encoding
155 """
156 filetxt = authfile.read_text(encoding="utf-8")
158 # two regural expressions for the PubSec format
159 regex_pubkey = re.compile(PUBSEC_PUBKEY_PATTERN, re.MULTILINE)
160 regex_signkey = re.compile(PUBSEC_SIGNKEY_PATTERN, re.MULTILINE)
162 # Seed hexadecimal format
163 if re.search(re.compile(SEED_HEX_PATTERN), filetxt):
164 return SigningKey.from_seedhex_file(authfile)
165 # PubSec format
166 if re.search(regex_pubkey, filetxt) and re.search(regex_signkey, filetxt):
167 return SigningKey.from_pubsec_file(authfile)
168 tools.click_fail("The format of the file is invalid")
169 return None
172def auth_by_seed() -> SigningKey:
173 seedhex = click.prompt("Please enter your seed on hex format", hide_input=True)
174 try:
175 return SigningKey.from_seedhex(seedhex)
176 except SigningKeyException as error:
177 tools.click_fail(error)
180@click.pass_context
181def auth_by_scrypt(ctx: click.Context, nrp: Optional[str]) -> SigningKey:
182 salt = click.prompt(
183 "Please enter your Scrypt Salt (Secret identifier)",
184 hide_input=True,
185 default="",
186 )
187 password = click.prompt(
188 "Please enter your Scrypt password (masked)",
189 hide_input=True,
190 default="",
191 )
193 if nrp:
194 a, b, c = nrp.split(",")
196 if a.isnumeric() and b.isnumeric() and c.isnumeric():
197 n, r, p = int(a), int(b), int(c)
198 if n <= 0 or n > 65536 or r <= 0 or r > 512 or p <= 0 or p > 32:
199 tools.click_fail("The values of Scrypt parameters are not good")
200 scrypt_params = ScryptParams(n, r, p)
201 else:
202 tools.click_fail("one of n, r or p is not a number")
203 else:
204 scrypt_params = None
206 try:
207 return SigningKey.from_credentials(salt, password, scrypt_params)
208 except SigningKeyException as error:
209 tools.click_fail(error)
212def auth_by_wif() -> SigningKey:
213 wif_hex = click.prompt(
214 "Enter your WIF or Encrypted WIF address (masked)",
215 hide_input=True,
216 )
217 password = click.prompt(
218 "(Leave empty in case WIF format) Enter the Encrypted WIF password (masked)",
219 hide_input=True,
220 )
221 try:
222 return SigningKey.from_wif_or_ewif_hex(wif_hex, password)
223 except SigningKeyException as error:
224 tools.click_fail(error)
227def auth_by_wif_file(wif_file: Path, password: Optional[str] = None) -> SigningKey:
228 try:
229 return SigningKey.from_wif_or_ewif_file(wif_file, password)
230 except SigningKeyException as error:
231 tools.click_fail(error)