{
"cells": [
{
"cell_type": "markdown",
"id": "a7782a35",
"metadata": {},
"source": [
"\n",
"# Key Derivation Function (KDF)\n",
"\n",
"## HKDF\n",
"HKDF (HMAC Key Derivation Function) is used to generate an encryption key based on a password. We can use a range of hashing methods to dervie the encryption key. In this case we will use a range of hashing methods, and derive a key of a given size.\n",
"\n",
"
"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "4eb825c2",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Key: 00\n",
" Hex: 3030\n",
"Salt: \n",
" Hex: \n",
"\n",
"HKDF SHA256: c9f9ff58d94c9d8901f5ed32e36f30af yfn/WNlMnYkB9e0y428wrw==\n",
"\n"
]
}
],
"source": [
"# 06_01.py\n",
"# https://asecuritysite.com/hazmat/hashnew3\n",
"from cryptography.hazmat.primitives import hashes\n",
"from cryptography.hazmat.primitives.kdf.hkdf import HKDF\n",
"from cryptography.hazmat.backends import default_backend\n",
"\n",
"\n",
"import binascii\n",
"import sys\n",
"\n",
"st = \"00\"\n",
"hex=False\n",
"showhex=\"No\"\n",
"k=\"00\"\n",
"length=16\n",
"slt=\"\"\n",
"\n",
"def show_hash(name,type,data,length,salt):\n",
"\n",
" hkdf = HKDF(algorithm=type, length=length,salt=salt, info=b\"\",backend=default_backend())\n",
" mykey=hkdf.derive(data)\n",
" hex=binascii.b2a_hex(mykey).decode()\n",
" b64=binascii.b2a_base64(mykey).decode()\n",
" print (f\"HKDF {name}: {hex} {b64}\")\n",
" \n",
"\n",
"\n",
"\n",
"try:\n",
"\tif (hex==True): data = binascii.a2b_hex(st)\n",
"\telse: data=st.encode()\n",
"\tif (hex==True): salt = binascii.a2b_hex(slt)\n",
"\telse: salt=slt.encode()\n",
"\n",
"\n",
"\tprint (\"Key: \",st)\n",
"\tprint (\" Hex: \",binascii.b2a_hex(data).decode())\n",
"\n",
"\tprint (\"Salt: \",slt)\n",
"\tprint (\" Hex: \",binascii.b2a_hex(salt).decode())\n",
"\n",
"\tprint()\n",
"\n",
"\n",
"\tshow_hash(\"SHA256\",hashes.SHA256(),data,length,salt)\n",
"\n",
"\n",
"\n",
"except Exception as e:\n",
" print(e)"
]
},
{
"cell_type": "markdown",
"id": "e39a85a4",
"metadata": {},
"source": [
"> Verify the operaton of the program.\n",
"\n",
"> Change the SHA-256 method to MD5. Verify the operation.\n",
"\n",
"> Change the SHA-256 method to SHA-1. Verify the operation.\n",
"\n",
"> Change the SHA-256 method to Blake2p, and use 64 bytes of hash. Verify the operation."
]
},
{
"cell_type": "markdown",
"id": "fa6da6d0",
"metadata": {},
"source": [
"## PBKDF2\n",
"PBKDF2 (Password-Based Key Derivation Function 2) is defined in RFC 2898 and generates a salted hash. Often this is used to create an encryption key from a defined password, and where it is not possible to reverse the password from the hashed value. It is used in TrueCrypt to generate the key required to read the header information of the encrypted drive, and which stores the encryption keys. Also, it is used in WPA-2 in order to create a hashed version of the password. With this, WPA-2 uses 4,096 interations. We can also specify the length of the generated hashed. \n",
"\n",
"\n",
"
"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "23a3df3f",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Message:\tHello\n",
"Salt:\t\te9ed6ffc41f8350c15ac0b67611ae723\n",
"Salt:\t\t6e1v/EH4NQwVrAtnYRrnIw==\n",
"Rounds:\t\t1000\n",
"Hash:\t\tsha1\n",
"\n",
"Key:\t\tdf9f33fc4e8d7ec3655d2369539caa0228e7946bc629076d3381e3616dac1f73\n",
"Key:\t\t358z/E6NfsNlXSNpU5yqAijnlGvGKQdtM4HjYW2sH3M=\n",
"KDF Verified\n"
]
}
],
"source": [
"\n",
"# 06_02.py\n",
"# https://asecuritysite.com/pbkdf2/hazkdf\n",
"import os\n",
"import sys\n",
"import base64\n",
"from cryptography.hazmat.primitives import hashes\n",
"from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC\n",
"from cryptography.hazmat.backends import default_backend\n",
"\n",
"round=1000\n",
"hashtype=0\n",
"length=32\n",
"message=\"Hello\"\n",
"\n",
"\n",
"salt = os.urandom(16)\n",
"h=None\n",
"if (hashtype==0): h=hashes.SHA1()\n",
"elif (hashtype==1): h=hashes.SHA512_224()\n",
"elif (hashtype==2): h=hashes.SHA512_256()\n",
"elif (hashtype==3): h=hashes.SHA224()\n",
"elif (hashtype==4): h=hashes.SHA256()\n",
"elif (hashtype==5): h=hashes.SHA384()\n",
"elif (hashtype==6): h=hashes.SHA512()\n",
"elif (hashtype==7): h=hashes.SHA3_224()\n",
"elif (hashtype==8): h=hashes.SHA3_256()\n",
"elif (hashtype==9): h=hashes.SHA3_384()\n",
"elif (hashtype==10): h=hashes.SHA3_512()\n",
"elif (hashtype==11): h=hashes.MD5()\n",
"elif (hashtype==12): h=hashes.SM3()\n",
"elif (hashtype==13): h=hashes.BLAKE2b(64)\n",
"elif (hashtype==14): h=hashes.BLAKE2s(32)\n",
"\n",
"\n",
"kdf = PBKDF2HMAC(algorithm=h,length=length,salt=salt, iterations=round,backend=default_backend())\n",
"\n",
"key = kdf.derive(message.encode())\n",
"\n",
"\n",
"# verify\n",
"\n",
"kdf = PBKDF2HMAC(algorithm=h, length=length,salt=salt, iterations=round,backend=default_backend())\n",
"\n",
"rtn=kdf.verify(message.encode(), key)\n",
"\n",
"print(f\"Message:\\t{message}\")\n",
"print(f\"Salt:\\t\\t{salt.hex()}\")\n",
"print(f\"Salt:\\t\\t{base64.b64encode(salt).decode()}\")\n",
"print(f\"Rounds:\\t\\t{round}\")\n",
"print(f\"Hash:\\t\\t{kdf._algorithm.name}\")\n",
"print(f\"\\nKey:\\t\\t{key.hex()}\")\n",
"print(f\"Key:\\t\\t{base64.b64encode(key).decode()}\")\n",
"if (rtn==None): print(\"KDF Verified\")"
]
},
{
"cell_type": "markdown",
"id": "1b11596f",
"metadata": {},
"source": [
"We can also use a pure PBKDF2 method with:"
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "906b48e7",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Using PBKDF2\n",
"Password: qwerty, Salt: \n",
"\n",
"Hash: b'b1444b43fe945ff29561e772401db5a0'\n"
]
}
],
"source": [
"# 06_03.py\n",
"import binascii\n",
"from Crypto.Protocol.KDF import PBKDF2, HKDF\n",
"from Crypto.Hash import SHA256\n",
"from Crypto.Random import get_random_bytes\n",
"import sys\n",
"\n",
"password=\"qwerty\"\n",
"salt = get_random_bytes(16)\n",
"s=\"\"\n",
"type=1\n",
"bytes=16\n",
"\n",
"\n",
"salt=binascii.unhexlify(s)\n",
"\n",
"\n",
"KEK = PBKDF2(password, salt, bytes, count=1000, hmac_hash_module=SHA256)\n",
"print (\"Using PBKDF2\")\n",
"\n",
"\n",
"print (f\"Password: {password}, Salt: {s}\")\n",
"print (\"\\nHash: \",binascii.hexlify(KEK))"
]
},
{
"cell_type": "markdown",
"id": "19cdb854",
"metadata": {},
"source": [
"## scrypt\n",
"scrypt is a password-based key derivation function which produces a hash with a salt and iterations. The iteration count slows down the cracking and the salt makes pre-computation difficult. The main parameters are: passphrase (P); salt (S); Blocksize (r) and CPU/Memory cost parameter (N - a power of 2). \n",
"\n",
"
"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a9f5ff8f",
"metadata": {},
"outputs": [],
"source": [
"# 06_04.py\n",
"import os\n",
"import sys\n",
"import base64\n",
"from cryptography.hazmat.primitives import hashes\n",
"from cryptography.hazmat.primitives.kdf.scrypt import Scrypt\n",
"\n",
"\n",
"length=32\n",
"message=\"Hello\"\n",
"N=14\n",
"r=8\n",
"p=1\n",
"\n",
"if (len(sys.argv)>1):\n",
"\tmessage=str(sys.argv[1])\n",
"if (len(sys.argv)>2):\n",
"\tN=int(sys.argv[2])\n",
"if (len(sys.argv)>3):\n",
"\tr=int(sys.argv[3])\n",
"if (len(sys.argv)>4):\n",
"\tlength=int(sys.argv[4])\n",
"if (len(sys.argv)>5):\n",
"\tp=int(sys.argv[5])\n",
"\n",
"\n",
"salt = os.urandom(16)\n",
"\n",
"\n",
"kdf = Scrypt(salt=salt,length=length,n=2**N,r=r,p=p)\n",
"\n",
"key = kdf.derive(message.encode())\n",
"\n",
"\n",
"# verify\n",
"\n",
"kdf = Scrypt(salt=salt,length=length,n=2**N,r=r,p=p)\n",
"\n",
"rtn=kdf.verify(message.encode(), key)\n",
"\n",
"print(\"== scrypt === \")\n",
"print(f\"Message:\\t{message}\")\n",
"print(f\"Salt:\\t\\t{salt.hex()}\")\n",
"print(f\"Salt:\\t\\t{base64.b64encode(salt).decode()}\")\n",
"print(f\"scrypt param:\\tn=2**{N}, r={r}, p={p}\")\n",
"print(f\"\\nKey:\\t\\t{key.hex()}\")\n",
"print(f\"Key:\\t\\t{base64.b64encode(key).decode()}\")\n",
"if (rtn==None): print(\"KDF Verified\")"
]
},
{
"cell_type": "markdown",
"id": "ed20a77c",
"metadata": {},
"source": [
"> From the Cryptography libary, which other KDFs are available?"
]
},
{
"cell_type": "markdown",
"id": "31a8309e",
"metadata": {},
"source": [
"\n",
"# bcrypt\n",
"MD5 and SHA-1 produce a hash signature, but this can be attacked by rainbow tables. Bcrypt is a more powerful hash generator for passwords and uses salt to create a non-recurrent hash. It was designed by Niels Provos and David Mazières, and is based on the Blowfish cipher. It is used as the default password hashing method for BSD and other systems. \n",
"\n",
"Overall it uses a 128-bit salt value, which requires 22 Radix-64 characters. It can use a number of iterations, which will slow down any brute-force cracking of the hashed value.\n",
"\n",
"For example, “Hello” with a salt value of “$2a$06$NkYh0RCM8pNWPaYvRLgN9.” gives:\n",
"\n",
"$2a$06$NkYh0RCM8pNWPaYvRLgN9.LbJw4gcnWCOQYIom0P08UEZRQQjbfpy\n",
"\n",
"As illustrated below, the first part is \"$2a$\" (or \"$2b$\"), and then followed by the number of iterations used (in this case is it 6 iterations (where each additional iternation doubles the hash time). The 128-bit (22 character) salt values comes after this, and then finally there is a 184-bit hash code (which is 31 characters).\n",
"\n",
"
\n",
"\n",
"The slowness of Bcrypt is highlighted with a recent AWS EC2 server benchmark using hashcat [here]:\n",
"\n",
"Hash type: MD5 Speed/sec: 380.02M words\n",
"Hash type: SHA1 Speed/sec: 218.86M words\n",
"Hash type: SHA256 Speed/sec: 110.37M words\n",
"Hash type: bcrypt, Blowfish(OpenBSD) Speed/sec: 25.86k words\n",
"Hash type: NTLM. Speed/sec: 370.22M words\n",
"\n",
"You can see that Blowfish is almost four times slower than MD5 (380,000,000 words/sec down to only 25,860 words/sec). With John The Ripper:\n",
"\n",
"md5crypt [MD5 32/64 X2] 318237 c/s real, 8881 c/s virtual\n",
"bcrypt (\"$2a$05\", 32 iterations) 25488 c/s real, 708 c/s virtual\n",
"LM [DES 128/128 SSE2-16] 88090K c/s real, 2462K c/s virtual\n"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "1badea25",
"metadata": {},
"outputs": [
{
"ename": "ModuleNotFoundError",
"evalue": "No module named 'bcrypt'",
"output_type": "error",
"traceback": [
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
"\u001b[0;31mModuleNotFoundError\u001b[0m Traceback (most recent call last)",
"\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 2\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mbinascii\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 3\u001b[0m \u001b[0;32mfrom\u001b[0m \u001b[0mCrypto\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mProtocol\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mKDF\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mPBKDF2\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mscrypt\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0mHKDF\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 4\u001b[0;31m \u001b[0;32mimport\u001b[0m \u001b[0mbcrypt\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 5\u001b[0m \u001b[0;32mfrom\u001b[0m \u001b[0mCrypto\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mHash\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mSHA256\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 6\u001b[0m \u001b[0;32mfrom\u001b[0m \u001b[0mCrypto\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mRandom\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mget_random_bytes\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
"\u001b[0;31mModuleNotFoundError\u001b[0m: No module named 'bcrypt'"
]
}
],
"source": [
"# 06_05.py\n",
"# https://asecuritysite.com/bcrypt/kdf\n",
"import binascii\n",
"from Crypto.Protocol.KDF import PBKDF2, scrypt,HKDF\n",
"import bcrypt\n",
"from Crypto.Hash import SHA256\n",
"from Crypto.Random import get_random_bytes\n",
"import sys\n",
"\n",
"password=\"qwerty\"\n",
"salt = get_random_bytes(16)\n",
"s=\"\"\n",
"type=1\n",
"bytes=16\n",
"\n",
"if (len(sys.argv)>1):\n",
"\tpassword=str(sys.argv[1])\n",
"if (len(sys.argv)>2):\n",
"\ts=str(sys.argv[2])\n",
"if (len(sys.argv)>3):\n",
"\ttype=int(sys.argv[3])\n",
"if (len(sys.argv)>4):\n",
"\tbytes=int(sys.argv[4])\n",
"\n",
"\n",
"salt=binascii.unhexlify(s)\n",
"\n",
"\n",
"KEK = bcrypt.kdf(password=password.encode(),salt=b'salt',desired_key_bytes=bytes,rounds=100)\n",
"print (\"Using bcrypt\")\n",
"\n",
"\n",
"\n",
"print (f\"Password: {password}, Salt: {s}\")\n",
"print (\"\\nHash: \",binascii.hexlify(KEK))"
]
},
{
"cell_type": "markdown",
"id": "5d4cdaac",
"metadata": {},
"source": [
"> Change the program so that the number of rounds is 500, 1000, 5000 and 10000. What happens to the creation of the hash?"
]
},
{
"cell_type": "markdown",
"id": "fe1a15b0",
"metadata": {},
"source": [
"## Argon 2.0\n",
"This case we will use PyNaCl (Networking and Cryptography) library, and which is a Python binding to libsodium. We will hash a password using SHA-256 and SHA-512, and also create a KDF (Key Derivation Function) using scrypt and Argon2. With Argon2, we have a memory robust key derivation function from a password and a salt value. Argon2 was designed Alex Biryukov, Daniel Dinu, and Dmitry Khovratovich is a key derivation function (KDF), where were we can create hashed values of passwords, or create encryption keys based on a password. It was a winner of the Password Hashing Competition in July 2015, and is robust against GPU and side channel attacks [paper]. We will also use the salt value as a key for the key-hash method of SIPHash24. "
]
},
{
"cell_type": "markdown",
"id": "2aa950e4",
"metadata": {},
"source": [
"With many fast hashing methods, such as MD5, we can now get billions or even trillions or hashes per second, where even 9 or 10 character passwords can be cracked for a reasonable financial cost. This includes salting of the password, as the salt is contained with the hashed password, and can be easily cracked with brute force (or dictionaries). The alternative is to use a hashing method which has a cost in memory, and for CPU processing. We also want to create a method which makes it difficult to apply parallel threads (and thus run it on GPUs). So a step forward is Argon2 which was designed Alex Biryukov, Daniel Dinu, and Dmitry Khovratovich as a key derivation function. It was a winner of the Password Hashing Competition in July 2015. It is resistant to GPU attacks, and also has a memory cost. The costs include: execution time (CPU cost); memory required (memory cost); and degree of parallelism.\n",
"\n",
"The parameters include:\n",
"\n",
"* Password (P): Defines the password bytes to be hashed\n",
"* Salt (S): Defines the bytes to be used for salting.\n",
"* Parallelism (p): Defines the number of thread that are required for the parallelism.\n",
"* TagLength (T): Define the number of bytes to return for the hash.\n",
"* MemorySizeKB (m): Amount of memory (in KB) to use.\n",
"\n",
"$argon2id$v=19$m=8,t=3,p=1$yh3bPPtc2tSnm8xpK1RZbw$CBE1uk3HK23zkotoY9280NWemhMj6FnljoDMSZ8PZ58\n",
"\n",
"Note: You will need to install the libary with \"pip install PyNaCl\".\n",
"\n",
"
"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "634aa8a1",
"metadata": {},
"outputs": [
{
"ename": "ModuleNotFoundError",
"evalue": "No module named 'nacl'",
"output_type": "error",
"traceback": [
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
"\u001b[0;31mModuleNotFoundError\u001b[0m Traceback (most recent call last)",
"\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# https://asecuritysite.com/nacl/nacl02\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0;32mimport\u001b[0m \u001b[0mnacl\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mhash\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 3\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mnacl\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mhashlib\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mnacl\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mpwhash\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 5\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mnacl\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mencoding\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
"\u001b[0;31mModuleNotFoundError\u001b[0m: No module named 'nacl'"
]
}
],
"source": [
"# https://asecuritysite.com/argon2/nacl02\n",
"# 06_06.py\n",
"import nacl.hash\n",
"import nacl.hashlib\n",
"import nacl.pwhash\n",
"import nacl.encoding\n",
"import binascii\n",
"import sys\n",
"\n",
"from os import urandom\n",
"\n",
"password='hello'\n",
"salt=''\n",
"\n",
"\n",
"salt=salt.zfill(16)\n",
"\n",
"print(\"Password: \",password)\n",
"print(\"Salt: \",salt)\n",
"\n",
"salt=salt.encode()\n",
"password=password.encode()\n",
"\n",
"mlevel=nacl.pwhash.MEMLIMIT_INTERACTIVE\n",
"olevel=nacl.pwhash.OPSLIMIT_INTERACTIVE\n",
"\n",
"print (\"\\nSHA256: \",nacl.hash.sha256(password))\n",
"print (\"SHA512: \",nacl.hash.sha512(password))\n",
"print (\"\\nBlake2b: \",nacl.hashlib.blake2b(password,salt=salt).hexdigest())\n",
"print (\"Scrypt: \",binascii.hexlify(nacl.hashlib.scrypt(password,salt, n=2, r=8, p=1, maxmem=2**25, dklen=64)))\n",
"\n",
"\n",
"print(\"\\nArgon2id\")\n",
"print(binascii.hexlify(nacl.pwhash.argon2id.kdf(size=32,password=password,salt=salt,opslimit=3,memlimit=8192)))\n",
"print(nacl.pwhash.argon2id.str(password=password,opslimit=3,memlimit=8192))\n",
"\n",
"print(\"\\nArgon2i\")\n",
"\n",
"print(binascii.hexlify(nacl.pwhash.argon2i.kdf(size=32,password=password,salt=salt,opslimit=3,memlimit=8192)))\n",
"print(nacl.pwhash.argon2i.str(password=password,opslimit=3,memlimit=8192))\n",
"\n",
"salt=salt.zfill(32)\n",
"print(\"\\nScrypt\")\n",
"print(binascii.hexlify(nacl.pwhash.scrypt.kdf(size=32,password=password,salt=salt,opslimit=olevel,memlimit=mlevel)))\n",
"print(nacl.pwhash.scrypt.str(password=password,opslimit=3,memlimit=mlevel))"
]
},
{
"cell_type": "markdown",
"id": "358fadf4",
"metadata": {},
"source": [
"> What is the value of the salt we have used?\n",
"> The program for different values of opslimits, and observe the result."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.3"
}
},
"nbformat": 4,
"nbformat_minor": 5
}