signing_key.py 12.2 KB
Newer Older
inso's avatar
inso committed
1 2 3 4 5
"""
duniter public and private keys

@author: inso
"""
6 7
from re import compile, MULTILINE, search
from typing import Optional, Union, TypeVar, Type
inso's avatar
inso committed
8 9

import libnacl.sign
10
import pyaes
11
from libnacl.utils import load_key
inso's avatar
inso committed
12
from pylibscrypt import scrypt
13

inso's avatar
inso committed
14
from .base58 import Base58Encoder
15
from ..helpers import ensure_bytes, xor_bytes
inso's avatar
inso committed
16 17 18 19

SEED_LENGTH = 32  # Length of the key
crypto_sign_BYTES = 64

Vincent Texier's avatar
Vincent Texier committed
20

inso's avatar
inso committed
21
class ScryptParams:
22
    def __init__(self, n: int, r: int, p: int) -> None:
Vincent Texier's avatar
Vincent Texier committed
23 24 25
        """
        Init a ScryptParams instance with crypto parameters

26 27 28
        :param n: scrypt param N
        :param r: scrypt param r
        :param p: scrypt param p
Vincent Texier's avatar
Vincent Texier committed
29
        """
30
        self.N = n
inso's avatar
inso committed
31 32
        self.r = r
        self.p = p
inso's avatar
inso committed
33

Vincent Texier's avatar
Vincent Texier committed
34

35 36 37 38
# required to type hint cls in classmethod
SigningKeyType = TypeVar('SigningKeyType', bound='SigningKey')


inso's avatar
inso committed
39
class SigningKey(libnacl.sign.Signer):
40 41 42 43 44 45

    def __init__(self, seed: bytes) -> None:
        """
        Init pubkey property

        :param str seed: Hexadecimal seed string
Vincent Texier's avatar
Vincent Texier committed
46
        """
47 48 49 50 51 52 53 54
        super().__init__(seed)
        self.pubkey = Base58Encoder.encode(self.vk)

    @classmethod
    def from_credentials(cls: Type[SigningKeyType], salt: Union[str, bytes], password: Union[str, bytes],
                         scrypt_params: Optional[ScryptParams] = None) -> SigningKeyType:
        """
        Create a SigningKey object from credentials
Vincent Texier's avatar
Vincent Texier committed
55

56 57 58
        :param salt: Secret salt passphrase credential
        :param password: Secret password credential
        :param scrypt_params: ScryptParams instance
Vincent Texier's avatar
Vincent Texier committed
59 60 61 62
        """
        if scrypt_params is None:
            scrypt_params = ScryptParams(4096, 16, 1)

inso's avatar
inso committed
63 64
        salt = ensure_bytes(salt)
        password = ensure_bytes(password)
inso's avatar
inso committed
65
        seed = scrypt(password, salt,
inso's avatar
inso committed
66 67
                      scrypt_params.N, scrypt_params.r, scrypt_params.p,
                      SEED_LENGTH)
inso's avatar
inso committed
68

69
        return cls(seed)
70

71 72 73 74 75 76 77 78
    def save_private_key(self, path: str) -> None:
        """
        Save authentication file

        :param path: Authentication file path
        """
        self.save(path)

79
    @staticmethod
80 81 82 83 84 85 86 87 88 89 90
    def from_private_key(path: str) -> SigningKeyType:
        """
        Read authentication file
        Add public key attribute

        :param path: Authentication file path
        """
        key = load_key(path)
        key.pubkey = Base58Encoder.encode(key.vk)
        return key

91 92 93 94 95 96 97 98 99 100 101
    def decrypt_seal(self, message: bytes) -> str:
        """
        Decrypt message with a curve25519 version of the ed25519 key pair

        :param message: Encrypted message

        :return:
        """
        curve25519_public_key = libnacl.crypto_sign_ed25519_pk_to_curve25519(self.vk)
        curve25519_secret_key = libnacl.crypto_sign_ed25519_sk_to_curve25519(self.sk)
        return libnacl.crypto_box_seal_open(message, curve25519_public_key, curve25519_secret_key).decode('utf-8')
102

103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
    @classmethod
    def from_pubsec_file(cls: Type[SigningKeyType], path: str) -> SigningKeyType:
        """
        Return SigningKey instance from Duniter WIF file

        :param path: Path to WIF file
        """
        with open(path, 'r') as fh:
            pubsec_content = fh.read()

        # line patterns
        regex_pubkey = compile("pub: ([1-9A-HJ-NP-Za-km-z]+)", MULTILINE)
        regex_signkey = compile("sec: ([1-9A-HJ-NP-Za-km-z]+)", MULTILINE)

        # check public key field
        match = search(regex_pubkey, pubsec_content)
        if not match:
            raise Exception('Error: Bad format PubSec v1 file, missing public key')

        # check signkey field
        match = search(regex_signkey, pubsec_content)
        if not match:
            raise Exception('Error: Bad format PubSec v1 file, missing sec key')

        # capture signkey
        signkey_hex = match.groups()[0]

        # extract seed from signkey
        seed = bytes(Base58Encoder.decode(signkey_hex)[0:32])

        return cls(seed)

    def save_pubsec_file(self, path: str) -> None:
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151
        """
        Save a Duniter PubSec file (PubSec) v1

        :param path: Path to file
        """
        # version
        version = 1

        # base58 encode keys
        base58_signing_key = Base58Encoder.encode(self.sk)
        base58_public_key = self.pubkey

        # save file
        with open(path, 'w') as fh:
            fh.write(
                """Type: PubSec
152 153 154
Version: {version}
pub: {pubkey}
sec: {signkey}""".format(version=version, pubkey=base58_public_key, signkey=base58_signing_key)
155
            )
156

Vincent Texier's avatar
Vincent Texier committed
157
    @staticmethod
Vincent Texier's avatar
Vincent Texier committed
158
    def from_wif_or_ewif_file(path: str, password: Optional[str] = None) -> SigningKeyType:
Moul's avatar
Moul committed
159 160 161 162
        """
        Return SigningKey instance from Duniter WIF or EWIF file

        :param path: Path to WIF of EWIF file
Vincent Texier's avatar
Vincent Texier committed
163
        :param password: Password needed for EWIF file
Moul's avatar
Moul committed
164 165 166 167 168 169 170 171 172 173 174 175 176 177
        """
        with open(path, 'r') as fh:
            wif_content = fh.read()

        # check data field
        regex = compile('Data: ([1-9A-HJ-NP-Za-km-z]+)', MULTILINE)
        match = search(regex, wif_content)
        if not match:
            raise Exception('Error: Bad format WIF or EWIF v1 file')

        # capture hexa wif key
        wif_hex = match.groups()[0]
        return SigningKey.from_wif_or_ewif_hex(wif_hex, password)

Vincent Texier's avatar
Vincent Texier committed
178
    @staticmethod
Vincent Texier's avatar
Vincent Texier committed
179
    def from_wif_or_ewif_hex(wif_hex: str, password: Optional[str] = None) -> SigningKeyType:
Moul's avatar
Moul committed
180 181 182 183 184 185 186 187 188 189 190 191
        """
        Return SigningKey instance from Duniter WIF or EWIF in hexadecimal format

        :param wif_hex: WIF or EWIF string in hexadecimal format
        :param password: Password of EWIF encrypted seed
        """
        wif_bytes = Base58Encoder.decode(wif_hex)

        fi = wif_bytes[0:1]

        if fi == b"\x01":
            return SigningKey.from_wif_hex(wif_hex)
Vincent Texier's avatar
Vincent Texier committed
192
        elif fi == b"\x02" and password is not None:
Moul's avatar
Moul committed
193
            return SigningKey.from_ewif_hex(wif_hex, password)
Moul's avatar
Moul committed
194 195 196
        else:
            raise Exception("Error: Bad format: not WIF nor EWIF")

Vincent Texier's avatar
Vincent Texier committed
197
    @staticmethod
Moul's avatar
Moul committed
198
    def from_wif_file(path: str) -> SigningKeyType:
199
        """
200
        Return SigningKey instance from Duniter WIF file
201 202 203 204 205 206

        :param path: Path to WIF file
        """
        with open(path, 'r') as fh:
            wif_content = fh.read()

207
        # check data field
208 209 210 211 212
        regex = compile('Data: ([1-9A-HJ-NP-Za-km-z]+)', MULTILINE)
        match = search(regex, wif_content)
        if not match:
            raise Exception('Error: Bad format WIF v1 file')

213
        # capture hexa wif key
214
        wif_hex = match.groups()[0]
Moul's avatar
Moul committed
215 216 217 218 219 220 221 222 223
        return SigningKey.from_wif_hex(wif_hex)

    @classmethod
    def from_wif_hex(cls: Type[SigningKeyType], wif_hex: str) -> SigningKeyType:
        """
        Return SigningKey instance from Duniter WIF in hexadecimal format

        :param wif_hex: WIF string in hexadecimal format
        """
224 225 226 227
        wif_bytes = Base58Encoder.decode(wif_hex)
        if len(wif_bytes) != 35:
            raise Exception("Error: the size of WIF is invalid")

228
        # extract data
229 230 231 232 233
        checksum_from_wif = wif_bytes[-2:]
        fi = wif_bytes[0:1]
        seed = wif_bytes[1:-2]
        seed_fi = wif_bytes[0:-2]

234
        # check WIF format flag
235
        if fi != b"\x01":
236
            raise Exception("Error: bad format version, not WIF")
237 238 239 240 241 242 243 244

        # checksum control
        checksum = libnacl.crypto_hash_sha256(libnacl.crypto_hash_sha256(seed_fi))[0:2]
        if checksum_from_wif != checksum:
            raise Exception("Error: bad checksum of the WIF")

        return cls(seed)

245
    def save_wif_file(self, path: str) -> None:
246
        """
247
        Save a Wallet Import Format file (WIF) v1
248 249 250

        :param path: Path to file
        """
251
        # version
252 253
        version = 1

254 255
        # add format to seed (1=WIF,2=EWIF)
        seed_fi = b"\x01" + self.seed
256 257 258 259 260 261

        # calculate checksum
        sha256_v1 = libnacl.crypto_hash_sha256(seed_fi)
        sha256_v2 = libnacl.crypto_hash_sha256(sha256_v1)
        checksum = sha256_v2[0:2]

262
        # base58 encode key and checksum
263 264 265 266 267 268
        wif_key = Base58Encoder.encode(seed_fi + checksum)

        with open(path, 'w') as fh:
            fh.write(
                """Type: WIF
Version: {version}
269 270 271
Data: {data}""".format(version=version, data=wif_key)
            )

Vincent Texier's avatar
Vincent Texier committed
272
    @staticmethod
Moul's avatar
Moul committed
273
    def from_ewif_file(path: str, password: str) -> SigningKeyType:
274 275 276
        """
        Return SigningKey instance from Duniter EWIF file

Moul's avatar
Moul committed
277
        :param path: Path to EWIF file
278 279 280 281 282
        :param password: Password of the encrypted seed
        """
        with open(path, 'r') as fh:
            wif_content = fh.read()

283
        # check data field
284 285 286 287 288
        regex = compile('Data: ([1-9A-HJ-NP-Za-km-z]+)', MULTILINE)
        match = search(regex, wif_content)
        if not match:
            raise Exception('Error: Bad format EWIF v1 file')

289
        # capture ewif key
290
        ewif_hex = match.groups()[0]
Moul's avatar
Moul committed
291 292 293 294 295 296 297 298 299 300
        return SigningKey.from_ewif_hex(ewif_hex, password)

    @classmethod
    def from_ewif_hex(cls: Type[SigningKeyType], ewif_hex: str, password: str) -> SigningKeyType:
        """
        Return SigningKey instance from Duniter EWIF in hexadecimal format

        :param ewif_hex: EWIF string in hexadecimal format
        :param password: Password of the encrypted seed
        """
301 302 303 304
        ewif_bytes = Base58Encoder.decode(ewif_hex)
        if len(ewif_bytes) != 39:
            raise Exception("Error: the size of EWIF is invalid")

305
        # extract data
306 307 308 309 310 311 312
        fi = ewif_bytes[0:1]
        checksum_from_ewif = ewif_bytes[-2:]
        ewif_no_checksum = ewif_bytes[0:-2]
        salt = ewif_bytes[1:5]
        encryptedhalf1 = ewif_bytes[5:21]
        encryptedhalf2 = ewif_bytes[21:37]

313
        # check format flag
314
        if fi != b"\x02":
315
            raise Exception("Error: bad format version, not EWIF")
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354

        # checksum control
        checksum = libnacl.crypto_hash_sha256(libnacl.crypto_hash_sha256(ewif_no_checksum))[0:2]
        if checksum_from_ewif != checksum:
            raise Exception("Error: bad checksum of the EWIF")

        # SCRYPT
        password_bytes = password.encode("utf-8")
        scrypt_seed = scrypt(password_bytes, salt, 16384, 8, 8, 64)
        derivedhalf1 = scrypt_seed[0:32]
        derivedhalf2 = scrypt_seed[32:64]

        # AES
        aes = pyaes.AESModeOfOperationECB(derivedhalf2)
        decryptedhalf1 = aes.decrypt(encryptedhalf1)
        decryptedhalf2 = aes.decrypt(encryptedhalf2)

        # XOR
        seed1 = xor_bytes(decryptedhalf1, derivedhalf1[0:16])
        seed2 = xor_bytes(decryptedhalf2, derivedhalf1[16:32])
        seed = bytes(seed1 + seed2)

        # Password Control
        signer = SigningKey(seed)
        salt_from_seed = libnacl.crypto_hash_sha256(
            libnacl.crypto_hash_sha256(
                Base58Encoder.decode(signer.pubkey)))[0:4]
        if salt_from_seed != salt:
            raise Exception("Error: bad Password of EWIF address")

        return cls(seed)

    def save_ewif_file(self, path: str, password: str) -> None:
        """
        Save an Encrypted Wallet Import Format file (WIF v2)

        :param path: Path to file
        :param password:
        """
355
        # version
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394
        version = 1

        # add version to seed
        salt = libnacl.crypto_hash_sha256(
            libnacl.crypto_hash_sha256(
                Base58Encoder.decode(self.pubkey)))[0:4]

        # SCRYPT
        password_bytes = password.encode("utf-8")
        scrypt_seed = scrypt(password_bytes, salt, 16384, 8, 8, 64)
        derivedhalf1 = scrypt_seed[0:32]
        derivedhalf2 = scrypt_seed[32:64]

        # XOR
        seed1_xor_derivedhalf1_1 = bytes(xor_bytes(self.seed[0:16], derivedhalf1[0:16]))
        seed2_xor_derivedhalf1_2 = bytes(xor_bytes(self.seed[16:32], derivedhalf1[16:32]))

        # AES
        aes = pyaes.AESModeOfOperationECB(derivedhalf2)
        encryptedhalf1 = aes.encrypt(seed1_xor_derivedhalf1_1)
        encryptedhalf2 = aes.encrypt(seed2_xor_derivedhalf1_2)

        # add format to final seed (1=WIF,2=EWIF)
        seed_bytes = b'\x02' + salt + encryptedhalf1 + encryptedhalf2

        # calculate checksum
        sha256_v1 = libnacl.crypto_hash_sha256(seed_bytes)
        sha256_v2 = libnacl.crypto_hash_sha256(sha256_v1)
        checksum = sha256_v2[0:2]

        # B58 encode final key string
        ewif_key = Base58Encoder.encode(seed_bytes + checksum)

        # save file
        with open(path, 'w') as fh:
            fh.write(
                """Type: EWIF
Version: {version}
Data: {data}""".format(version=version, data=ewif_key)
395
            )