## Laboratorul 03 - PRGs

Prezentarea PowerPoint pentru acest laborator poate fi găsită aici.

Puteți lucra acest laborator folosind și platforma Google Colab, accesând acest link.

Click pentru a vedea utils.py

Click pentru a vedea utils.py

utils.py
import base64
from typing import Generator

def _pad(data: str, size: int) -> str:
reminder = len(data) % size
if reminder != 0:
data = "0" * (size - reminder) + data
return data

def _chunks(data: str, chunk_size: int) -> Generator[str, None, None]:
for i in range(0, len(data), chunk_size):
yield data[i : i + chunk_size]

def _hex(data: int) -> str:
return format(data, "02x")

# Conversion functions

def hex_2_bin(data: str) -> str:
"""Converts a hexadecimal string to a binary representation.

Args:
data (str): The hexadecimal string to be converted. It should have an
even number of characters and only contain valid hexadecimal digits
(0-9, A-F, a-f).

Returns:
str: The binary representation of the hexadecimal string, where each
pair of hexadecimal digits is encoded as an 8-bit binary number.

Examples:
>>> hex_2_bin("01abcd")
'000000011010101111001101'
>>> hex_2_bin("0a")
'00001010'
"""
return "".join(f"{int(x, 16):08b}" for x in _chunks(data, 2))

def bin_2_hex(data: str) -> str:
"""Converts a binary string to a hexadecimal representation.

Args:
data (str): The binary string to be converted. It should have a multiple
of 8 characters and only contain valid binary digits (0 or 1).

Returns:
str: The hexadecimal representation of the binary string, where each
group of 8 binary digits is encoded as a pair of hexadecimal digits.

Examples:
>>> bin_2_hex("000000011010101111001101")
'01abcd'
>>> bin_2_hex("00001010")
'0a'
"""
return "".join(f"{int(b, 2):02x}" for b in _chunks(data, 8))

def str_2_bin(data: str) -> str:
"""Converts a string to a binary representation.

Args:
data (str): The string to be converted.

Returns:
str: The binary representation of the string, where each character is
encoded as an 8-bit binary number.

Examples:
>>> str_2_bin("Hello")
'0100100001100101011011000110110001101111'
>>> str_2_bin("IC")
'0100100101000011'
"""
return "".join(f"{ord(c):08b}" for c in data)

def bin_2_str(data: str) -> str:
"""Converts a binary string to a string.

Args:
data (str): The binary string to be converted. It should have a multiple
of 8 characters and only contain valid binary digits (0 or 1).

Returns:
str: The string representation of the binary string, where each group
of 8 binary digits is decoded as a character.

Examples:
>>> bin_2_str("0100100001100101011011000110110001101111")
'Hello'
>>> bin_2_str("0100100101000011")
'IC'
"""
return "".join(chr(int(b, 2)) for b in _chunks(data, 8))

def str_2_hex(data: str) -> str:
"""Converts a string to a hexadecimal representation.

Args:
data (str): The string to be converted.

Returns:
str: The hexadecimal representation of the string, where each character
is encoded as a pair of hexadecimal digits.

Examples:
>>> str_2_hex("Hello")
'48656c6c6f'
>>> str_2_hex("IC")
'4943'
"""
return "".join(f"{ord(c):02x}" for c in data)

def hex_2_str(data: str) -> str:
"""Converts a hexadecimal string to a string.

Args:
data (str): The hexadecimal string to be converted. It should have an
even number of characters and only contain valid hexadecimal digits
(0-9, A-F, a-f).

Returns:
str: The string representation of the hexadecimal string, where each
pair of hexadecimal digits is decoded as a character.

Examples:
>>> hex_2_str("48656c6c6f")
'Hello'
>>> hex_2_str("4943")
'IC'
"""
return "".join(chr(int(x, 16)) for x in _chunks(data, 2))

# XOR functions

def strxor(operand_1: str, operand_2: str) -> str:
"""Performs a bitwise exclusive OR (XOR) operation on two strings.

Args:
operand_1 (str): The first string to be XORed.
operand_2 (str): The second string to be XORed.

Returns:
str: The result of the XOR operation on the two strings, where each
character is encoded as an 8-bit binary number. The result has
the same length as the shorter input string.

Examples:
>>> strxor("Hello", "IC")
'\\x01&'
>>> strxor("secret", "key")
'\\x18\\x00\\x1a'
"""
return "".join(chr(ord(x) ^ ord(y)) for (x, y) in zip(operand_1, operand_2))

def bitxor(operand_1: str, operand_2: str) -> str:
"""Performs a bitwise exclusive OR (XOR) operation on two bit-strings.

Args:
operand_1 (str): The first bit-string to be XORed. It should only
contain valid binary digits (0 or 1).
operand_2 (str): The second bit-string to be XORed. It should only
contain valid binary digits (0 or 1).

Returns:
str: The result of the XOR operation on the two bit-strings, where each
bit is encoded as a character. The result has the same length as
the shorter input bit-string.

Examples:
>>> bitxor("01001000", "01000010")
'00001010'
>>> bitxor("10101010", "00110011")
'10011001'
"""
return "".join(str(int(x) ^ int(y)) for (x, y) in zip(operand_1, operand_2))

def hexxor(operand_1: str, operand_2: str) -> str:
"""Performs a bitwise exclusive OR (XOR) operation on two hexadecimal
strings.

Args:
operand_1 (str): The first hexadecimal string to be XORed. It should
have an even number of characters and only contain valid hexadecimal
digits (0-9, A-F, a-f).
operand_2 (str): The second hexadecimal string to be XORed. It should
have an even number of characters and only contain valid
digits (0-9, A-F, a-f).

Returns:
str: The result of the XOR operation on the two hexadecimal strings,
where each pair of hexadecimal digits is encoded as a pair of
hexadecimal digits. The result has the same length as the shorter

Examples:
>>> hexxor("48656c6c6f", "42696e67")
'0a0c020b'
>>> hexxor("736563726574", "6b6579")
'18001a'
"""
return "".join(
_hex(int(x, 16) ^ int(y, 16))
for (x, y) in zip(_chunks(operand_1, 2), _chunks(operand_2, 2))
)

# Python3 'bytes' functions

def bytes_to_string(bytes_data: bytearray | bytes) -> str:
"""Converts a byte array or a byte string to a string.

Args:
bytes_data (bytearray | bytes): The byte array or the byte string to be
converted. It should be encoded in Latin-1 format.

Returns:
str: The string representation of the byte array or the byte string,
decoded using Latin-1 encoding.

Examples:
>>> bytes_to_string(b'Hello')
'Hello'
>>> bytes_to_string(bytearray(b'IC'))
'IC'
"""
return bytes_data.decode(encoding="raw_unicode_escape")

def string_to_bytes(string_data: str) -> bytes:
"""Converts a string to a byte string.

Args:
string_data (str): The string to be converted.

Returns:
bytes: The byte string representation of the string, encoded using
Latin-1 encoding.

Examples:
>>> string_to_bytes('Hello')
b'Hello'
>>> string_to_bytes('IC')
b'IC'
"""
return string_data.encode(encoding="raw_unicode_escape")

# Base64 functions

def b64encode(data: str) -> str:
"""Encodes a string to base64.

Parameters:
data (str): The string to be encoded.

Returns:
str: The base64 encoded string, using Latin-1 encoding.

Examples:
>>> b64encode("Hello")
'SGVsbG8='
>>> b64encode("IC")
'SUM='
"""
return bytes_to_string(base64.b64encode(string_to_bytes(data)))

def b64decode(data: str) -> str:
"""Decodes a base64 encoded string.

Args:
data (str): The base64 encoded string to be decoded. It should only
contain valid base64 characters (A-Z, a-z, 0-9, +, /, =).

Returns:
str: The decoded string, using Latin-1 encoding.

Examples:
>>> b64decode("SGVsbG8=")
'Hello'
>>> b64decode("SUM=")
'IC'
"""
return bytes_to_string(base64.b64decode(string_to_bytes(data)))

### Exercițiul 1 (3p)

În acest exercițiu vom încerca să spargem un LCG (Linear Congruential Generator, care se poate folosi pentru a genera numere “slab” aleatoare. Am folosit un astfel de generator pentru a obține o secvență de octeți cu care a fost criptat un mesaj (plaintext). Textul cifrat (ciphertext) în sistemul hexazecimal este următorul:

a432109f58ff6a0f2e6cb280526708baece6680acc1f5fcdb9523129434ae9f6ae9edc2f224b73a8

După cum știți, LCG folosește următoarea formula pentru a genera octeți:

s_next = (a * s_prev + b) mod p

unde s_next și s_prev sunt octeți (0-255) generați, p = 257, iar a și b au valori între 0 și 256.

Mai știți și că primele 16 litere din plaintext sunt “Let all creation”. Criptarea a fost obținută realizând XOR între plaintext și secvența de octeți consecutivi generați de LCG.

Spargeți LCG astfel încât să puteți prezice șirul de numere generate “aleatoriu”. Care este mesajul inițial?

Puteți folosi următorul schelet de cod:

'ex1_weak_rng.py'
from utils import *

class WeakRNG:
"""Simple class for weak RNG"""

def __init__(self) -> None:
self.rstate = 0
self.maxn = 255
self.a = 0  # Set this to correct value
self.b = 0  # Set this to correct value
self.p = 257

def init_state(self, rstate: int) -> None:
"""Initialise rstate"""
self.rstate = rstate  # Set this to some value
self.update_state()

def update_state(self) -> None:
"""Update state"""
self.rstate = (self.a * self.rstate + self.b) % self.p

def get_prg_byte(self) -> int:
"""Return a new PRG byte and update PRG state"""
b = self.rstate & 0xFF
self.update_state()
return b

def main() -> None:
# Initialise weak rng
wr = WeakRNG()
wr.init_state(0)

# Print ciphertext
CH = "a432109f58ff6a0f2e6cb280526708baece6680acc1f5fcdb9523129434ae9f6ae9edc2f224b73a8"
print("Full ciphertext in hexa:", CH)

# Print known plaintext
pknown = "Let all creation"
nb = len(pknown)
print("Known plaintext:", pknown)
pkh = str_2_hex(pknown)
print("Plaintext in hexa:", pkh)

# Obtain first nb bytes of RNG
gh = hexxor(pkh, CH[0 : nb * 2])
print(gh)
gbytes = []
for i in range(nb):
gbytes.append(ord(hex_2_str(gh[2 * i : 2 * i + 2])))
print("Bytes of RNG: ")
print(gbytes)

# Break the LCG here:
# TODO 1: Find a and b, and set them in the RNG

# TODO 2: Predict/generate rest of RNG bytes

# TODO 3: Decrypt plaintext

# TODO 4: Print the full plaintext
p = ""
print("Full plaintext is:", p)

if __name__ == "__main__":
main()

### Exercițiul 2 (2p)

Avantaje. Scopul acestui exercițiu este de a clarifica conceptul de avantaje prezentat la curs. Vom considera două experimente $\mathsf{EXP}(0)$ și $\mathsf{EXP}(1)$, astfel:

• În $\mathsf{EXP}(0)$ verificatorul dă cu banul (probabilitate de $1/2$ pentru CAP și $1/2$ pentru PAJURĂ) și transmite rezultatul adversarului $\mathsf{A}$.
• În $\mathsf{EXP}(1)$ verificatorul întotdeauna va transmite PAJURĂ către adversar.

Considerăm $r = 0$ pentru CAP și $r = 1$ pentru PAJURĂ. Jocul va arăta astfel:

Scopul adversarului este să diferențieze între cele două experimente: la finalul fiecarui experiment, adversarul returnează un bit $\mathsf{b'}$, $0$ sau $1$, în speranța de a ghici experimentul în care se află (adică, $\mathsf{EXP}(b) = \mathsf{EXP}(b')$). Fie $W_{b}$ evenimentul când în experimentul $\mathsf{EXP}(b)$ adversarul returnează $b' = 1$. Adversarul vrea să își maximizeze avantajul de a distinge experimentele. Mai exact, el vrea să maximizeze valoarea $\mathsf{Adv} = \left| \mathsf{Pr}\left[W_{0}\right] − \mathsf{Pr}\left[W_{1}\right] \right| \in \left[0, 1\right]$.

Avantajul $\mathsf{Adv}$ redă capacitatea adversarului de a diferenția experimentele. Dacă avantajul este $0$, atunci advesarul se comportă identic în ambele cazuri și nu poate să le distingă. Dacă avantajul este $1$, atunci adversarul știe concret care este valoarea lui $b$. Dacă avantajul este neglijabil pentru orice adversar eficient atunci putem spune că nu se poate distinge între experimente.

a. Calculați avantajul pentru fiecare dintre următorii adversari:

• A1: Returnează mereu $b'=1$.
• A2: Ignoră valoarea primită de la verificator și returnează aleatoriu $0$ sau $1$, cu aceeași probabilitate.
• A3: Returnează $1$ dacă a primit CAP ($r=0$) de la verificator, altfel returnează $0$.
• A4: Returnează $0$ dacă a primit CAP de la verificator, altfel returnează $1$.
• A5: Dacă a primit CAP, returnează $1$. Dacă a primit PAJURĂ, returnează aleatoriu $0$ sau $1$ cu aceeași probabilitate.
• A6: Dacă a primit CAP, returnează aleatoriu $0$ sau $1$, cu aceeași probabilitate. Dacă a primit PAJURĂ, returnează $0$.

b. Care este avantajul maxim pe care îl poate obține un adversar? De ce?

Încercați să obtineți mai întai o formulă generală ce nu depinde de adversar.

### Exercițiul 3 (2p)

O aplicație a avantajului în schemele criptografice este de a arăta că un $PRG$ este sigur sau nu. Amintiți-vă de la curs că $G : K \rightarrow \{0, 1\}^n$ este un $PRG$ sigur dacă pentru orice test statistic $\mathsf{A}$ eficient (care rulează în timp polinomial), avantajul $\mathsf{Adv_{PRG}[A, G]}$ este neglijabil. Un test statistic este de fapt un algoritm care încearcă să determine dacă intrarea pare aleatoare sau nu. Acest algoritm poate fi definit astfel:

\begin{equation*} A(x) = \begin{cases} 1 ,& \text{dacă x e aleatoriu}\\ 0 ,& \text{altfel} \end{cases} \end{equation*}

Putem asocia pentru fiecare test statistic $A$ câte un adversar care încearcă să spargă $\mathsf{PRG}$-ul (adică poate distinge între rezultatul $\mathsf{PRG}$-ului și un generator de numere complet aleatoare). În acest caz, vom defini experimentele după cum urmează:

• În $\mathsf{EXP}(0)$ verificatorul trimite către adversarul $\mathsf{A}$ valoarea $G(k)$ generată de $\mathsf{PRG}$;
• În $\mathsf{EXP}(1)$ verificatorul trimite către adversarul $\mathsf{A}$ o valoare $r$ generată de un generator de numere complet aleatoare.

Ca mai devreme, $W_{b}$ este evenimentul în care adversarul spune că numărul este aleatoriu și a avut loc experimentul $b$. Dacă adversarul poate să distingă între $\mathsf{PRG}$ și un generator de numere complet aleatoare, atunci $\mathsf{PRG}$-ul nu e sigur. Așadar, avantajul devine:

$$\mathsf{Adv_{PRG}[A, G]} = \left|\underset{k \leftarrow K}{Pr}[A(G(k)) = 1] - \underset{r \leftarrow \{0, 1\}^n}{Pr}[A(r) = 1] \right|$$

Deși problema determinării dacă există $\mathsf{PRG}$-uri care să poată fi demonstrate ca fiind sigure este echivalentă cu a rezolva bine-cunoscuta problemă $\mathsf{P}$ vs $\mathsf{NP}$, în practică există $\mathsf{PRG}$-uri considerate sigure prin folosirea unor euristici.

În acest exercițiu, se dă un $\mathsf{PRG}$ $G : \{0, 1\}^s \rightarrow \{0, 1\}^n$ sigur. Cu ajutorul acestuia, spuneți dacă următoarele $\mathsf{PRG}$-uri sunt sigure.

• $G_{1}(k) = G(k) \oplus 1^n$
• $G_{2}(k) = G(k) \| 0$
• $G_{3}(k) = G(0)$
• $G_{4}(k=(k_1, k_2)) = G(k_1) \| G(k_2)$
• $G_{5}(k)=G(k) \| G(k)$

Pentru două șiruri de biți $y$ și $z$ folosim $y \| z$ pentru a indica concatenarea între $y$ și $z$.

Pentru unele dintre ele nu este nevoie să calculați avantajul.

### Exercițiul 4 (3p)

Vom folosi experimentul definit mai devreme pentru a construi un generator pseudoaleatoriu ($\mathsf{PRG}$):

1. Se alege lungimea $n$ a șirului ce va fi generat
2. Se obține un șir de biți $R$ de lungime $n$ (de exemplu, puteți folosi chiar și LCG-ul din Exercițiul 1)
3. Pentru fiecare bit $r$ din șirul $R$ generat anterior, $\mathsf{PRG}$-ul va întoarce un bit $b$ astfel:
• dacă bitul $r$ este $0$, atunci întoarce un bit aleatoriu $b \in \{0, 1\}$
• dacă bitul $r$ este $1$, atunci întoarce $1$

a. Implementați frequency (monobit) test așa cum este descris de către NIST (vezi secțiunea 2.1). Verificați dacă secvența generată de $\mathsf{PRG}$-ul de mai sus este aleatoare sau nu (să presupunem pentru $n=100$).

b. Executați testul pe un șir aleatoriu de biți (de exemplu, șirul R folosit în $\mathsf{PRG}$) și comparați rezultatele.

Dacă rezultatele celor două experimente diferă în mod regulat, acest test vă oferă un atacator care sparge $\mathsf{PRG}$-ul descris mai sus.

Pentru a genera un șir aleatoriu de biți, puteți folosi o funcție de forma:

import random

def get_random_string(n: int) -> str:
"""Generate random bit string"""
return bin(random.getrandbits(n)).lstrip("0b").zfill(n)

În Python puteți folosi funcții precum sqrt, fabs sau erfc din modulul math.

import math

### Exercițiul 5 - Bonus (2p)

În acest exercițiu, veți încerca să verificați dacă într-adevar Salsa20 stream cipher este mai rapid decât RC4. Descărcați această arhivă. Rulați programul folosind același fișier de intrare atât pentru Salsa20, cât și pentru RC4. Comparați timpul de execuție în ambele cazuri.

Pentru sistemele UNIX (și WSL), folosiți scriptul prepare.sh pentru a complila sursele și a genera fișierele necesare. Dacă nu folosiți WSL pe Windows, scriptul prepare.ps1 generează fișierele necesare, dar nu și compilează sursele.

Folosiți opțiunea ”-h” pentru a afișa lista tuturor argumentelor folosite de program.

Uitați-vă în fișierul salsa20.h. Unde se execută rundele de criptare?

Încercați să implementați aceeași funcționalitate folosind OpenSSL. OpenSSL suportă RC4, dar nu și Salsa20. Folosiți ChaCha20 in loc de Salsa20, aceasta fiind o variantă îmbunătățită a algoritmului.