Coverage for ledger/pin.py: 87%
97 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-07-10 13:43 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-07-10 13:43 +0000
1# The MIT License (MIT)
2#
3# Copyright (c) 2021 RSK Labs Ltd
4#
5# Permission is hereby granted, free of charge, to any person obtaining a copy of
6# this software and associated documentation files (the "Software"), to deal in
7# the Software without restriction, including without limitation the rights to
8# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9# of the Software, and to permit persons to whom the Software is furnished to do
10# so, subject to the following conditions:
11#
12# The above copyright notice and this permission notice shall be included in all
13# copies or substantial portions of the Software.
14#
15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21# SOFTWARE.
23import os
24import random
25import string
26import logging
29class PinError(RuntimeError):
30 pass
33class BasePin:
34 PIN_LENGTH = 8
35 POSSIBLE_CHARS = string.ascii_letters + string.digits
36 ALPHA_CHARS = string.ascii_letters
38 @classmethod
39 def generate_pin(cls):
40 random.seed()
41 pin = None
42 while pin is None or not cls.is_valid(pin.encode()):
43 pin = ""
44 for i in range(cls.PIN_LENGTH):
45 pin += random.choice(cls.POSSIBLE_CHARS)
46 return pin.encode()
48 @classmethod
49 def is_valid(cls, pin, any_pin=False):
50 if type(pin) != bytes:
51 return False
53 if not all(map(lambda c: chr(c) in cls.POSSIBLE_CHARS, pin)):
54 return False
56 if any_pin:
57 return True
59 if len(pin) != cls.PIN_LENGTH:
60 return False
62 return any(map(lambda c: chr(c) in cls.ALPHA_CHARS, pin))
65# Handles PIN initialization and loading
66class FileBasedPin(BasePin):
67 @classmethod
68 def new(cls, path):
69 # Generate a new random pin and save to the given path. Return the
70 # generated pin
71 with open(path, 'wb') as file:
72 pin = cls.generate_pin()
73 file.write(pin)
74 return pin
76 def __init__(self, path, default_pin, force_change=False):
77 self.logger = logging.getLogger("pin")
78 self._path = path
80 # Check pin file existence
81 try:
82 self.logger.info("Checking whether pin file '%s' exists", path)
83 pin_file_exists = os.path.isfile(path)
84 except Exception as e:
85 self._error("Error checking file existence: %s" % format(e))
87 # Load pin
88 if pin_file_exists:
89 try:
90 self.logger.info("Loading PIN from '%s'", path)
91 with open(path, 'rb') as file:
92 self._pin = file.read().strip()
93 except Exception as e:
94 self._error("Error loading pin: %s" % format(e))
95 else:
96 self.logger.info("Using default PIN")
97 self._pin = default_pin
99 # Make sure pin is valid
100 if not self.is_valid(self._pin):
101 self._error("Invalid pin: %s" % self._pin)
103 # Pin needs to be changed if no pin file found or a pin change is forced
104 self._needs_change = force_change or not pin_file_exists
105 self._changing = False
107 # Returns the current pin
108 def get_pin(self):
109 return self._pin
111 def get_new_pin(self):
112 if not self._changing:
113 return None
115 return self._new_pin
117 # Indicates whether the current pin needs changing
118 def needs_change(self):
119 return self._needs_change
121 # Starts a pin change and generates a new pin
122 def start_change(self):
123 self.logger.info("Starting PIN change")
125 if self._changing or not self._needs_change:
126 return
128 self._changing = True
129 self._new_pin = self.generate_pin()
131 self.logger.info("New PIN generated")
133 # Finalizes a pin change by commiting the changes to disk
134 def commit_change(self):
135 self.logger.info("Commiting PIN change to disk")
136 if not self._changing:
137 return
139 try:
140 with open(self._path, "wb") as file:
141 file.write(self._new_pin)
142 except Exception as e:
143 self._error("Error commiting: %s" % format(e))
145 self._pin = self._new_pin
146 self._new_pin = None
147 self._changing = False
148 self._needs_change = False
150 self.logger.info("PIN change committed to disk")
152 # Aborts a pin change
153 def abort_change(self):
154 self.logger.info("Aborting PIN change")
156 if not self._changing:
157 return
159 self._new_pin = None
160 self._changing = False
162 self.logger.info("PIN change aborted")
164 def _error(self, msg):
165 self.logger.error(msg)
166 raise PinError(msg)