Skip to content

Crypto

sereto.crypto

DerivedKeyResult

Bases: NamedTuple

Result of the Argon2 key derivation function.

Source code in sereto/crypto.py
22
23
24
25
26
class DerivedKeyResult(NamedTuple):
    """Result of the Argon2 key derivation function."""

    key: SecretBytes
    salt: TypeSalt16B

decrypt_file(file, keep_original=True)

Decrypts a .sereto file using AES-GCM encryption and saves it with a .tgz suffix.

This function retrieves a password from the system keyring, derives an encryption key using Argon2, parses the header (contains nonce and seed), and decrypts the file content. The decrypted data is then saved with a .tgz suffix and the original file is deleted (use keep_original=True to overwrite deletion).

Parameters:

Name Type Description Default
file FilePath

The path to the encrypted .sereto file.

required
keep_original bool

If True, the original encrypted file is kept. Defaults to False.

True

Raises:

Type Description
Abort

If the file size exceeds 1 GiB and the user chooses not to continue.

SeretoValueError

If the file is corrupted or not encrypted with SeReTo.

Returns:

Type Description
Path

Path to the decrypted file.

Source code in sereto/crypto.py
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
@validate_call
def decrypt_file(file: FilePath, keep_original: bool = True) -> Path:
    """Decrypts a .sereto file using AES-GCM encryption and saves it with a .tgz suffix.

    This function retrieves a password from the system keyring, derives an encryption key using Argon2, parses the
    header (contains nonce and seed), and decrypts the file content. The decrypted data is then saved with a .tgz
    suffix and the original file is deleted (use `keep_original=True` to overwrite deletion).

    Args:
        file: The path to the encrypted .sereto file.
        keep_original: If True, the original encrypted file is kept. Defaults to False.

    Raises:
        click.Abort: If the file size exceeds 1 GiB and the user chooses not to continue.
        SeretoValueError: If the file is corrupted or not encrypted with SeReTo.

    Returns:
        Path to the decrypted file.
    """
    if not file.is_file():
        raise SeretoPathError(f"File '{file}' does not exist")

    if file.suffix != ".sereto":
        raise SeretoValueError("Unsupported file format for decryption (not a .sereto)")

    # Retrieve the password from the system keyring
    try:
        ta_password: TypeAdapter[TypePassword] = TypeAdapter(TypePassword)  # hack for mypy
        password = ta_password.validate_python(keyring.get_password("sereto", "encrypt_attached_archive"))
    except ValidationError as e:
        Console().log(f"[yellow]Invalid password for archive encryption: {e.errors()[0]['msg']}")
        raise SeretoEncryptionError(f"encryption password is invalid: {e.errors()[0]['msg']}") from e

    Console().log(":locked: Found password for archive decryption.\nDecrypting archive...")

    assert_file_size_within_range(file=file, min_bytes=65, max_bytes=1_073_741_824, interactive=True)

    # Read the encrypted archive content
    data = file.read_bytes()

    # Extract the header
    if not data[:6] == b"SeReTo":
        raise SeretoValueError("Encrypted file is corrupted or not encrypted with SeReTo")

    # - nonce
    ta_nonce: TypeAdapter[TypeNonce12B] = TypeAdapter(TypeNonce12B)  # hack for mypy
    nonce = ta_nonce.validate_python(data[6:18])

    # - salt
    ta_salt: TypeAdapter[TypeSalt16B] = TypeAdapter(TypeSalt16B)  # hack for mypy
    salt = ta_salt.validate_python(data[18:34])

    # - encrypted data
    encrypted_data = data[64:]

    # Derive the key using Argon2id
    derived = derive_key_argon2(password=password, salt=salt)

    # Decrypt the data
    decrypted_data = AESGCM(derived.key.get_secret_value()).decrypt(
        nonce=nonce.get_secret_value(), data=encrypted_data, associated_data=None
    )

    # Write the decrypted data
    with NamedTemporaryFile(suffix=".tgz", delete=False) as tmp:
        (output := Path(tmp.name)).write_bytes(decrypted_data)

    Console().log(f"[green]+[/green] Decrypted archive to '{output}'")

    if not keep_original:
        file.unlink()
        Console().log(f"[red]-[/red] Deleted encrypted archive: '{file}'")

    return output

derive_key_argon2(password, salt=None, memory_cost=1048576, time_cost=4, parallelism=8)

Derive a key using Argon2id from a password.

Parameters:

Name Type Description Default
password TypePassword

Password to derive the key from.

required
salt TypeSalt16B | None

16 bytes long salt. If None, a random salt is generated.

None
memory_cost int

Memory cost in KiB. Defaults to 1 GiB.

1048576
time_cost int

Time cost (number of iterations). Defaults to 4.

4
parallelism int

Parallelism factor. Defaults to 8.

8

Returns:

Type Description
DerivedKeyResult

Derived key and salt.

Source code in sereto/crypto.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@validate_call
def derive_key_argon2(
    password: TypePassword,
    salt: TypeSalt16B | None = None,
    memory_cost: int = 1_048_576,
    time_cost: int = 4,
    parallelism: int = 8,
) -> DerivedKeyResult:
    """Derive a key using Argon2id from a password.

    Args:
        password: Password to derive the key from.
        salt: 16 bytes long salt. If None, a random salt is generated.
        memory_cost: Memory cost in KiB. Defaults to 1 GiB.
        time_cost: Time cost (number of iterations). Defaults to 4.
        parallelism: Parallelism factor. Defaults to 8.

    Returns:
        Derived key and salt.
    """
    # Generate a salt if not provided (16 bytes)
    if salt is None:
        ta_salt: TypeAdapter[TypeSalt16B] = TypeAdapter(TypeSalt16B)  # hack for mypy
        salt = ta_salt.validate_python(os.urandom(16))

    # Prepare the Argon2id key derivation function
    kdf = Argon2id(
        salt=salt.get_secret_value(),
        length=32,  # Desired key length in bytes (32 bytes = 256 bits for AES-256)
        iterations=time_cost,
        lanes=parallelism,
        memory_cost=memory_cost,
    )

    # Derive a key using Argon2id
    ta_key: TypeAdapter[SecretBytes] = TypeAdapter(SecretBytes)  # hack for mypy
    key = ta_key.validate_python(kdf.derive(password.get_secret_value().encode(encoding="utf-8")))

    return DerivedKeyResult(key=key, salt=salt)

encrypt_file(file, keep_original=False)

Encrypts a given file using AES-GCM encryption and saves it with a .sereto suffix.

This function retrieves a password from the system keyring, derives an encryption key using Argon2, and encrypts the file content. The encrypted data is then saved with a specific header and the original file is deleted (use keep_original=True to overwrite deletion).

Parameters:

Name Type Description Default
file FilePath

The path to the file to be encrypted.

required
keep_original bool

If True, the original encrypted file is kept. Defaults to False.

False

Returns:

Type Description
Path

Path to the encrypted file with suffix .sereto.

Raises:

Type Description
SeretoEncryptionError

If the password is not found in the system keyring.

SeretoPathError

If the provided file does not exist.

SeretoValueError

If the file size exceeds 1 GiB and the user chooses not to continue.

Source code in sereto/crypto.py
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
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
136
@validate_call
def encrypt_file(file: FilePath, keep_original: bool = False) -> Path:
    """Encrypts a given file using AES-GCM encryption and saves it with a .sereto suffix.

    This function retrieves a password from the system keyring, derives an encryption key using Argon2, and encrypts
    the file content. The encrypted data is then saved with a specific header and the original file is deleted (use
    `keep_original=True` to overwrite deletion).

    Args:
        file: The path to the file to be encrypted.
        keep_original: If True, the original encrypted file is kept. Defaults to False.

    Returns:
        Path to the encrypted file with suffix `.sereto`.

    Raises:
        SeretoEncryptionError: If the password is not found in the system keyring.
        SeretoPathError: If the provided file does not exist.
        SeretoValueError: If the file size exceeds 1 GiB and the user chooses not to continue.
    """
    if not file.is_file():
        raise SeretoPathError(f"file '{file}' does not exist")

    # Retrieve the password from the system keyring
    try:
        ta_password: TypeAdapter[TypePassword] = TypeAdapter(TypePassword)  # hack for mypy
        password = ta_password.validate_python(keyring.get_password("sereto", "encrypt_attached_archive"))
    except ValidationError as e:
        Console().log(f"[yellow]Invalid password for archive encryption: {e.errors()[0]['msg']}")
        raise SeretoEncryptionError(f"encryption password is invalid: {e.errors()[0]['msg']}") from e

    assert_file_size_within_range(file=file, max_bytes=1_073_741_824, interactive=True)

    Console().log(":locked: Found password for archive encryption.\nEncrypting archive...")

    # Read the file content
    data = file.read_bytes()

    # Derive the key using Argon2id
    derived = derive_key_argon2(password=password)

    # Generate a 12-byte random nonce - IV for AES
    # - NIST recommends a 96-bit IV length for best performance - https://csrc.nist.gov/pubs/sp/800/38/d/final
    ta_nonce: TypeAdapter[TypeNonce12B] = TypeAdapter(TypeNonce12B)  # hack for mypy
    nonce = ta_nonce.validate_python(os.urandom(12))

    # Encrypt the data
    encrypted_data = AESGCM(derived.key.get_secret_value()).encrypt(
        nonce=nonce.get_secret_value(), data=data, associated_data=None
    )

    # Prepare the header (64 bytes long)
    header = b"SeReTo" + nonce.get_secret_value() + derived.salt.get_secret_value()
    header = header.ljust(64, b"\x00")

    # Write the encrypted data into a new file
    with NamedTemporaryFile(suffix=".sereto", delete=False) as tmp:
        (encrypted_path := Path(tmp.name)).write_bytes(header + encrypted_data)

    # Delete the original file if `keep_file=False`
    if not keep_original:
        file.unlink()

    Console().log("[green]Archive successfully encrypted")

    # Return the path to the encrypted file
    return encrypted_path