Laboratorul 01 - Introducere

Puteți lucra acest laborator folosind și platforma Google Colab, accesând acest link, cu excepția exercițiului bonus. Un scurt tutorial pentru utilizarea platformei poate fi găsit aici.

Python3 Crash Course

Tutorialul poate fi găsit aici. La laboratoare vom folosi Python 3.10 sau mai nou.

Codificare vs Criptare

Codificare (engl. Encoding)

  • Folosită pentru a transforma datele dintr-un format în altul
  • În general, este folosită pentru a transfera date între sisteme diferite
  • Informația NU este păstrată secretă!!!
  • Pentru a decodifica datele, trebuie să știm doar algoritmul utilizat

Criptare (engl. Encryption)

  • Transformă datele astfel încât să păstrăm informația secretă
  • Algoritmul utilizat este în general public, iar cheia este secretă
  • Cuvinte cheie: text în clar (engl. plaintext), text cifrat (engl. ciphertext)
  • Tipuri: Criptare cu Cheie Privata (engl. Private-Key Encryption) vs Criptare cu Cheie Publică (engl. Public-Key Encryption)

În timpul laboratoarelor, va trebui să convertim datele dintr-un format în altul. Cele mai utilizate formate pentru păstrarea datelor sunt:

  • ASCII (text)
  • Binar (01010101)
  • Hexazecimal [0-9a-fA-F]
  • Base64 [a-zA-Z0-9] împreună cu '+' și '/'. În general, base64 se termină cu '=' sau '==' reprezentând padding. Este foarte folosit în web deoarece HTTP este un protocol de transfer text.

Mai jos găsiți câteva funcții utile pentru conversii și operații de XOR pentru date de diferite formate:

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]:
    data = _pad(data, chunk_size)
    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
            input hexadecimal string.
 
    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)))
Bytes în Python

Să considerăm exemplele de mai jos:

text1 = "Ana are mere"
text2 = b"Ana are mere"
print(type(text1))  # <class 'str'>
print(type(text2))  # <class 'bytes'>

Ambele variabile stochează aceeași informație. Diferența constă în modul cum sunt păstrate datele intern, cele două texte fiind codificate în 2 obiecte de tipuri diferite (string și bytes). În timpul laboratoarelor vom lucra de foarte multe ori cu tipul string, dar unele biblioteci externe pot necesita transformarea datelor din formatul string în formatul bytes.

Exercițiul #1 - Encoding is nice (2p)

Decodificați următoarele stringuri:

C1 = "010101100110000101101100011010000110000101101100011011000110000100100001"
C2 = "526f636b2c2050617065722c2053636973736f727321"
C3 = "WW91IGRvbid0IG5lZWQgYSBrZXkgdG8gZW5jb2RlIGRhdGEu"

Exercițiul #2 - But XOR-ing is cool (2p)

Găsiți mesajele în clar pentru următoarele ciphertexturi, știind că cifrul este operația XOR (ciphertext = plaintext XOR key), iar cheia este “abcdefghijkl”.

C1 = "000100010001000000001100000000110001011100000111000010100000100100011101000001010001100100000101"
C2 = "02030F07100A061C060B1909"

Hail, Caesar!

Unul dintre cele mai cunoscute și mai simple scheme de criptare este Cifrul lui Cezar. Ideea de bază este de a transforma fiecare literă din plaintext prin deplasarea la stânga a poziției literei curente cu trei poziții. Cu alte cuvinte, A devine D, B devine E, C devine F, și așa mai departe. Operația de criptare a unei litere $m$ este definită prin relația $Enc(m) = (m + 3)\ mod\ 26 $. Analog, pentru a decripta un text, trebuie să facem deplasarea la dreapta cu 3 poziții. Deci, operația de decriptare pentru fiecare literă $c$ dintr-un ciphertext este dată de relația $Dec(c) = (c - 3)\ mod\ 26$.

Criptarea unei litere

Să începem cu un exemplu simplu:

ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
 
 
def caesar_enc(letter: str) -> str:
    if not "A" <= letter <= "Z":
        raise ValueError("Invalid letter")
    return ALPHABET[(ord(letter) - ord("A") + 3) % len(ALPHABET)]

Creați un fișier nou caesar.py care să conțină codul de mai sus. Deschideți interpretorul de python în directorul în care ați salvat fișierul:

shell$ ls
caesar.py
shell$ python
>>>

Importați simbolurile din fișierul caesar.py:

>>> from caesar import *

Testați următoarele comenzi în consolă:

>>> print(ALPHABET)
>>> len(ALPHABET)
>>> ALPHABET[0]
>>> ord("A")
>>> ord("D") - ord("A")
>>> 26 % 26
>>> 28 % 26
>>> -1 % 26

Testați funcția de criptare pe câteva exemple:

>>> caesar_enc("D")
>>> caesar_enc("Z")
>>> caesar_enc("B")

Exercițiul #3 - Decriptarea unei litere (2p)

Adăugați o funcție 'caesar_dec' în fișierul 'caesar.py' care decriptează o singură literă criptată folosind cifrul lui Cezar.

Implementarea funcției de decriptare este similară cu cea a funcției de criptare. Diferă doar relația matematică.

Criptarea unui string

Vom extinde funcția definită anterior pentru a permite primirea unui string ca parametru.

def caesar_enc_string(plaintext: str) -> str:
    ciphertext = ""
    for letter in plaintext:
        ciphertext += caesar_enc(letter)
    return ciphertext

Testați codul de mai sus într-un interpretor de Python:

shell$ python -i caesar.py # run the script in interactive mode
>>> test = "HELLO"
>>> test += "WORLD"
>>> caesar_enc_string(test)

O altă modalitate de a rula diverse lucruri în Python, care poate fi foarte folositoare în general, este de a folosi o funcție main() și să avem un script precum cel de mai jos:

test_caesar.py
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
 
 
def caesar_enc(letter: str) -> str:
    if not 'A' <= letter <= 'Z':
        raise ValueError('Invalid letter')
    return ALPHABET[(ord(letter) - ord('A') + 3) % len(ALPHABET)]
 
 
def caesar_enc_string(plaintext: str) -> str:
    ciphertext = ""
    for letter in plaintext:
        ciphertext += caesar_enc(letter)
    return ciphertext
 
 
def main() -> None:
    m = "BINEATIVENIT"
    c = caesar_enc_string(m)
    print(c)
 
 
if __name__ == "__main__":
    main()

Apoi, puteți rula programul dintr-un terminal folosind:

python test_caesar.py

Exercițiul #4 - Decriptarea unui string (2p)

Scrieți funcția de decriptare numită 'caesar_dec_string'.

Shift ciphers

Așa cum am văzut mai sus, Cifrul lui Cezar folosește o cheie fixată $k=3$. Putem însă generaliza pe mai multe valori ale cheii (adică, în cazul alfabetului englez, putem avea valori de la 0 la 25). Această modalitate de criptare este numită Shift Cipher. Pentru a cripta un mesaj, pentru fiecare literă aplicăm $Enc(m, k) = (m + k)\ mod\ 26$, iar pentru decriptare, folosim $Dec(c, k) = (c - k)\ mod\ 26$.

Python ne permite să pasăm valori implicite ca parametri. Putem folosi valori implicite pentru a extinde funcția noastră 'caesar_enc' astfel încât să permită primirea cheii ca un parametru adițional, fără a afecta compatibilitatea cu codul anterior.

def caesar_enc(letter: str, k: int = 3) -> str:
    if not "A" <= letter <= "Z":
        raise ValueError("Invalid letter")
    return ALPHABET[(ord(letter) - ord("A") + k) % len(ALPHABET)]
 
 
def caesar_enc_string(plaintext: str, k: int = 3) -> str:
    ciphertext = ""
    for letter in plaintext:
        ciphertext += caesar_enc(letter, k)
    return ciphertext

Pentru a testa noile funcții, încercați comenzile de mai jos:

shell$ python -i caesar.py
>>> caesar_enc_string("HELLO")       # use the default value for k
>>> caesar_enc_string("HELLO", 0)    # pass the key as a positional argument
>>> caesar_enc_string("HELLO", k=1)  # pass the key as a keyword (named) argument

Exercițiul #5 - Shift Ciphers (2p)

Folosind valori implicite, extindeți funcția `caesar_dec_string` pentru a decripta mesaje criptate cu shift cipher, astfel încât să suporte chei arbitrare.

Bonus - Many-Time Pad (2p)

OTP (One Time Pad) este o tehnică de criptare care lucrează pe streamuri de date (adica, este un stream cipher). In OTP, mesajul și cheia trebuie să aibă aceeași dimensiune în număr de biți, iar algoritmul de criptare este operația XOR: $OTP(k, m) = k \oplus m$.

Avantajul folosirii OTP este dată de proprietatea de perfect secrecy: atâta timp cât nu cunoaștem cheia de criptare, un ciphertext poate corespunde oricărui mesaj posibil, cu aceeași probabilitate. Cu alte cuvinte, dacă avem acces la un ciphertext criptat cu OTP, nu putem ști care este mesajul din spate, fără a-l decripta. Această proprietate nu mai este adevărată în cazul în care folosim cheia de mai multe ori (de unde și numele de one-time pad).

Ideea de atac din spatele many-time pad (MTP) se bazează pe faptul că dacă $C1 = K \oplus M1$, iar $C2 = K \oplus M2$, atunci $C1 \oplus C2 = K \oplus M1 \oplus K \oplus M2 = M1 \oplus M2$. Cum vă poate ajuta această observație în a sparge MTP?

Simbolul $\oplus$ reprezintă operația XOR.

Decriptați ultimul ciphertext știind că toate mesajele au fost criptate cu aceeași cheie folosind OTP.

ciphers.ciphertexts
8841a58f876901c9e195d1e320e0c30a017bec11b0643d30533adcb0475e85a820d64e1a0869963453b490933b7005839f7d8a9571c8a890d75773bc2acc11d5cb3259f0610e95ad6ae1ec8445fc836b661b9c0554494c430210989e4a42ff7b4c19338945a68653c89d783e8460935c93896a3d73d9bc84a8e381951443ab8ada62c5d662d43c0da848c3602d
8e14e681d0651cd5fb99d1a87cee972b4436fe19b22c3d1e7a75c2a6155ac4fa06d74e07042889300ab490d226614c818574d99a38d8a899d45478f83cca04818a3549f061079bb139a5f78542eac63873499513460d48534345addf5f42b632475623d14fb49c16c1913d7fca019f59d09b253c3c98a480e1e3829c0942bec2da478bcc6bd42a00e953883a622497
e332a0cad0610ad6e691c6b967ad90634c73ec04fe216e586272dcb0474f98b336de5252042895310ab48c93277d4089d061968e76cbe194da0174f97dc512cd8e7b59bf351a8dad39acfdcb04edc62a275695045c4e405f4910bb9e4746f27915541fc653b5c81ec09f7f22ca2d945c9c916a2a7397bc8da4add1990945b7869c4a969a7a9c3f06a846882c6c28d6f9e6255ec96dd0b50e378054b2c89f6ee255312d330508e9cf4d43db
8812e8c6842612d5a895c3a87ca28230557fe717fe2b75117571d0ad475985ae3bd04550041d8e2744a5d5ca3c60058e9e6c96db379ceb90df427df9338850c7842948a6701dd0e853b4eb9f45ffc6386e56800c5001095d4544ad934e06f3385d1f25c243a9c653eed23b3983239a589ec23d206891e882a2e3869b1445bbc38907c2d461d42c0dfb57c7207e2adbfeaa2a4bc37ccceb5777cf36f58f8776a75b242a7d105babd2564d959b79b8bd
8008ac83d06f079cfbd0dba27aee89365262a904b62d740a3679dab01305cabb3ed30b0a4c2c92270aa581d227660586827dd99e2eddeb8cda5836e835c150d28a3648fe352698e878afe19f0df7882c2b1b9914125e09500c52b08b0b5db63a5e13348952af891d8f9f37229e609254868b26206698bc85a2ad82d3465cbccf9d4396c922d42d01e644cd6e7424ccb7b12c518d6d9faf166fc538bacc947fb149773f7c444ae7c95609959776b9e028502e45e0f6186c4fa51f4c80834f373d1f0b6130b770b6e1ce87
9603efdd952614d4e69ed4ed66af95260171e61cba687b0a797795a4154893fa33cc0b094125977b0a8f9ac673745782d074968c76d3e6d8d14e7af8628438c0833a45b1351b9bbd6daef69849be9f2e6653dc4042425b425810a99e474bb7325b0566c048e79c1bcad23f308725dd1db9c52769689ca480a4ad96d41f58a786974a8c942ebd7904e407db2b632f99eea9361fd976d2a25771c574ab81
8907a18f9d671a9bfa95c5a86aabcf634072fc50b1686f177778d4e3174583b433910b2e4820953415f6a5df3a7b44c7936dd9983383a8bbc34c36ff288413c4c77b4ea5350c9be86db3fd8910f7836a277a9101544c4411455ead9a474fa075153e27c006aa891a8f803d218f24941d93976a3b7398aa8deda29895481793c58f46c2d66bd43f1afd49cb2f606bc9f2e6255ad87cdeb4036bc138abcad76ead5b232e3d446ee2cf190c8d9b76a8b37b5e7c0bf6b71d7d42a50105c1964739224a1230
960ea9dbd04e169bec9fd0be2ea790635263fb00ac216e11787d9bed47618ffa35d65d1b57699a3b45a29dd62135428e966cd8db14c9fcd8c2497fef7dc319c79f7b44a3350b97ae7fa4ea8e0beac865274c9801410d6e5e4810be965d4fa07b5c0566e14faa9b16c3947671be28941db88d393d3cb1a181bea69d924654bdcb9f58c2ce61d43407e1498827636bcdffa3634cda76d6ab127d8068badd8363ec
b952fa8f836e1cccfbd0c0bd2ebd8c635925bb50bf3a78587c6fc6b74768b9991bf60b1d4c28893449a290c120355688d074908f33cee994da5836e835c150d29f2944be724f86fc2be1f19845f0893f27499117154f50454910bf90590ae17b461966c543b3cf008f95377188219256d083242d3c95a783a6e390804658a7d4da588adf62983d07ec42882f6a2ad0f9e8
a20cbb9f953f1083e283dfba3afbcf6e142fff1aa964304c606edffa574286f7608248121622916118e28580637348cb982dc9936581bd8edb0d31ff2ec2038cdf37168b385bceba2ab5fb9e48f9952c3c57833d120d6a6375608db07469871d4e3823df43b5bd00cabd16149e299c58a0a30e2672b4bd80b9aa8198037ab7d5894a85df7dd57f15
Hints

  • Ce se întâmplă dacă faceți XOR între un caracter [a-z] cu caracterul ' ' (spațiu)? Verificați de asemenea pentru [A-Z].
  • Nu puteți scrie un algoritm perfect care să rezolve problema din prima încercare, cel mai probabil va trebui să ghiciți. De ce?
  • Provocarea este interesantă, dar poate deveni muncitorească. Din fericire pentru noi, există o implementare open source care poate fi găsită aici. Vedeți mai jos cum poate fi rulat. Acest tool se folosește de observațiile de mai sus pentru a recupera câteva litere din cheie. Posibil ca nu toate literele deja existente să fie puse corect.

Cum se rulează

Pentru a rula utilitarul, trebuie să folosiți un sistem de operare unix-like (Linux, OSX, Cygwin). Pe Windows puteți să activați WSL (Windows Linux Subsystem) cum este detaliat aici:

  1. Căutați în bara de căutare din Windows “Turn Windows features on or off”
  2. Activați “Containers” și “Windows Subsystem for Linux”
  3. Restartați sistemul și instalați Ubuntu din Windows Store
  4. Deschideți un terminal (de exemplu, Command Prompt, PowerShell, Windows Terminal) și rulați “ubuntu”
  5. Așteptați ca instalarea să se termine și nu uitați să rulați “sudo apt-get update” și “sudo apt-get upgrade”

Rulare:

  1. Verificați că aveți instalat Python3, pip (python package manager) și git
  2. Rulați:
pip install urwid  # OR pip3 install urwid
git clone https://github.com/cosminacho/MTP.git
cd MTP
python mtp.py <ciphertexts filename>  # OR python3 mtp.py <ciphertexts filename>

`<ciphertexts filename>` trebuie să conțină cele 10 texte cifrate de mai sus.

În cazul în care programul trebuie să afișeze caractere non-printabile, posibil să crape. Există un issue deschis pe GitHub în acest sens, dar nu a fost rezolvat încă.

ic/labs/01.txt · Last modified: 2023/10/01 23:03 by razvan.smadu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0