Coverage for silkaj/auth.py: 43%

102 statements  

« 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/>. 

15 

16import re 

17from pathlib import Path 

18from typing import Optional 

19 

20import rich_click as click 

21from duniterpy.key.scrypt_params import ScryptParams 

22from duniterpy.key.signing_key import SigningKey, SigningKeyException 

23 

24from silkaj import tools 

25from silkaj.account_storage import AccountStorage 

26from silkaj.constants import PUBKEY_PATTERN 

27from silkaj.public_key import gen_pubkey_checksum 

28 

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})" 

32 

33 

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) 

45 

46 

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) 

61 

62 

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) 

117 

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) 

122 

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") 

130 

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 ) 

146 

147 

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") 

157 

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) 

161 

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 

170 

171 

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) 

178 

179 

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 ) 

192 

193 if nrp: 

194 a, b, c = nrp.split(",") 

195 

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 

205 

206 try: 

207 return SigningKey.from_credentials(salt, password, scrypt_params) 

208 except SigningKeyException as error: 

209 tools.click_fail(error) 

210 

211 

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) 

225 

226 

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)