microdata-tools 1.14.0__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/PKG-INFO +20 -18
  2. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/README.md +19 -17
  3. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/__init__.py +7 -1
  4. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/packaging/__init__.py +16 -14
  5. microdata_tools-2.0.0/microdata_tools/packaging/_crypto_config.py +10 -0
  6. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/packaging/_decrypt.py +55 -41
  7. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/packaging/_encrypt.py +47 -41
  8. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/packaging/_utils.py +8 -8
  9. microdata_tools-2.0.0/microdata_tools/packaging/keys.py +192 -0
  10. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/pyproject.toml +1 -1
  11. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/.gitignore +0 -0
  12. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/LICENSE.md +0 -0
  13. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/packaging/exceptions/__init__.py +0 -0
  14. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/__init__.py +0 -0
  15. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/adapter/local_storage.py +0 -0
  16. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/temporal_attributes.py +0 -0
  17. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_id_types.py +0 -0
  18. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/BEDRIFT.json +0 -0
  19. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/BK_HELSESTASJONSKONSULTASJON.json +0 -0
  20. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/BRUKSENHET.json +0 -0
  21. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/FAMILIE.json +0 -0
  22. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/FENGSLINGER_TILGANG.json +0 -0
  23. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/FORETAK.json +0 -0
  24. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/HKDIR_HYU_OPPMELDING.json +0 -0
  25. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/HKDIR_STUDIESOKNAD.json +0 -0
  26. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/HUSHOLDNING.json +0 -0
  27. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/JOBB.json +0 -0
  28. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/KJORETOY.json +0 -0
  29. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/KOMMUNE.json +0 -0
  30. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/KRG_KREFTTILFELLE.json +0 -0
  31. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/KURS.json +0 -0
  32. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/MALEPUNKT.json +0 -0
  33. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/NPR_EPISODE.json +0 -0
  34. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/NPR_PROSEDYRE.json +0 -0
  35. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/NPR_SYKEHUSOPPHOLD.json +0 -0
  36. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/PERSON.json +0 -0
  37. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/SOESKEN.json +0 -0
  38. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/SOSTIL.json +0 -0
  39. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/TRAFIKKULYKKE.json +0 -0
  40. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/TRAFIKKULYKKE_PERSON.json +0 -0
  41. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/VIRKSOMHET.json +0 -0
  42. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/components/unit_type_variables/__init__.py +0 -0
  43. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/exceptions/__init__.py +0 -0
  44. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/model/__init__.py +0 -0
  45. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/model/metadata.py +0 -0
  46. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/steps/data_reader.py +0 -0
  47. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/steps/dataset_validator.py +0 -0
  48. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/steps/metadata_enricher.py +0 -0
  49. {microdata_tools-1.14.0 → microdata_tools-2.0.0}/microdata_tools/validation/steps/metadata_reader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microdata-tools
3
- Version: 1.14.0
3
+ Version: 2.0.0
4
4
  Summary: Tools for the microdata.no platform
5
5
  Author: microdata-developers
6
6
  License: MIT
@@ -35,14 +35,13 @@ The CSV file is optional in some cases.
35
35
  The `package_dataset()` function will encrypt and package your dataset as a tar archive. The process is as follows:
36
36
 
37
37
  1. Generate the symmetric key for a dataset.
38
- 2. Encrypt the dataset data (CSV) using the symmetric key and store the encrypted file as `<DATASET_NAME>.csv.encr`
39
- 3. Encrypt the symmetric key using the asymmetric RSA public key `microdata_public_key.pem`
40
- and store the encrypted file as `<DATASET_NAME>.symkey.encr`
41
- 4. Gather the encrypted CSV, encrypted symmetric key and metadata (JSON) file in one tar file.
38
+ 2. Encrypt the dataset data (CSV) using an AES-256-GCM symmetric key and store the encrypted file as `<DATASET_NAME>.csv.encr`
39
+ 3. Encrypt the symmetric key using HPKE with the combined ML-KEM-768/X25519 public key from `microdata_public_key.pem` and store the resulting HPKE ciphertext as <DATASET_NAME>.kem.encr
40
+ 4. Gather the encrypted CSV, ciphertext file and metadata (JSON) file in one tar file.
42
41
 
43
42
  ### Unpackage dataset
44
- The `unpackage_dataset()` function will untar and decrypt your dataset using the `microdata_private_key.pem`
45
- RSA private key.
43
+ The `unpackage_dataset()` function will untar and your dataset and use the combined ML-KEM-768/X25519 private key from `microdata_private_key.pem` to recover the symmetric key, which is then used to decrypt the dataset.
44
+
46
45
 
47
46
  The packaged file has to have the `<DATASET_NAME>.tar` extension. Its contents should be as follows:
48
47
 
@@ -50,30 +49,33 @@ The packaged file has to have the `<DATASET_NAME>.tar` extension. Its contents s
50
49
 
51
50
  ```<DATASET_NAME>.csv.encr``` : Optional encrypted dataset file.
52
51
 
53
- ```<DATASET_NAME>.symkey.encr``` : Optional encrypted file containing the symmetrical key used to decrypt the dataset file. Required if the `.csv.encr` file is present.
52
+ ```<DATASET_NAME>.kem.encr``` : Optional HPKE ciphertext file containing the encrypted symmetric key required to decrypt the dataset file. Required if the `.csv.encr` file is present.
54
53
 
55
- Decryption uses the RSA private key located at ```RSA_KEY_DIR```.
54
+ Decryption uses the combined ML-KEM-768/X25519 private key located at ```PRIVATE_KEY_DIR``` to recover the symmetric decryption key.
56
55
 
57
56
  The packaged file is then stored in `output_dir/archive/unpackaged` after a successful run or `output_dir/archive/failed` after an unsuccessful run.
58
57
 
58
+
59
59
  ## Example
60
- Python script that uses a RSA public key named `microdata_public_key.pem` and packages a dataset:
60
+
61
+ Store your metadata and data files according to the structure described above, and put
62
+ the provided public key in a directory of your choice.
63
+ Then:
61
64
 
62
65
  ```py
63
66
  from pathlib import Path
64
67
  from microdata_tools import package_dataset
65
68
 
66
- RSA_KEYS_DIRECTORY = Path("tests/resources/rsa_keys")
67
- DATASET_DIRECTORY = Path("tests/resources/input_package/DATASET_1")
68
- OUTPUT_DIRECTORY = Path("tests/resources/output")
69
-
70
69
  package_dataset(
71
- rsa_keys_dir=RSA_KEYS_DIRECTORY,
72
- dataset_dir=DATASET_DIRECTORY,
73
- output_dir=OUTPUT_DIRECTORY,
70
+ public_key_dir=Path("path/to/key_directory"),
71
+ dataset_dir=Path("path/to/MY_DATASET_NAME"),
72
+ output_dir=Path("path/to/output"),
74
73
  )
75
74
  ```
76
75
 
76
+ This produces `path/to/output/MY_DATASET_NAME.tar`, which can be uploaded to microdata.
77
+
78
+
77
79
  ### Validation
78
80
 
79
81
  Once you have your metadata and data files ready to go, they should be named and stored like this:
@@ -104,7 +106,7 @@ else:
104
106
  print(error)
105
107
  ```
106
108
 
107
- For a more in-depth explanation of usage visit [the usage documentation](https://statisticsnorway.github.io/microdata-tools/usage.md).
109
+ For a more in-depth explanation of usage visit [the usage documentation](https://statisticsnorway.github.io/microdata-tools/USAGE).
108
110
 
109
111
  ### Data format description
110
112
  A dataset as defined in microdata consists of one data file, and one metadata file.
@@ -21,14 +21,13 @@ The CSV file is optional in some cases.
21
21
  The `package_dataset()` function will encrypt and package your dataset as a tar archive. The process is as follows:
22
22
 
23
23
  1. Generate the symmetric key for a dataset.
24
- 2. Encrypt the dataset data (CSV) using the symmetric key and store the encrypted file as `<DATASET_NAME>.csv.encr`
25
- 3. Encrypt the symmetric key using the asymmetric RSA public key `microdata_public_key.pem`
26
- and store the encrypted file as `<DATASET_NAME>.symkey.encr`
27
- 4. Gather the encrypted CSV, encrypted symmetric key and metadata (JSON) file in one tar file.
24
+ 2. Encrypt the dataset data (CSV) using an AES-256-GCM symmetric key and store the encrypted file as `<DATASET_NAME>.csv.encr`
25
+ 3. Encrypt the symmetric key using HPKE with the combined ML-KEM-768/X25519 public key from `microdata_public_key.pem` and store the resulting HPKE ciphertext as <DATASET_NAME>.kem.encr
26
+ 4. Gather the encrypted CSV, ciphertext file and metadata (JSON) file in one tar file.
28
27
 
29
28
  ### Unpackage dataset
30
- The `unpackage_dataset()` function will untar and decrypt your dataset using the `microdata_private_key.pem`
31
- RSA private key.
29
+ The `unpackage_dataset()` function will untar and your dataset and use the combined ML-KEM-768/X25519 private key from `microdata_private_key.pem` to recover the symmetric key, which is then used to decrypt the dataset.
30
+
32
31
 
33
32
  The packaged file has to have the `<DATASET_NAME>.tar` extension. Its contents should be as follows:
34
33
 
@@ -36,30 +35,33 @@ The packaged file has to have the `<DATASET_NAME>.tar` extension. Its contents s
36
35
 
37
36
  ```<DATASET_NAME>.csv.encr``` : Optional encrypted dataset file.
38
37
 
39
- ```<DATASET_NAME>.symkey.encr``` : Optional encrypted file containing the symmetrical key used to decrypt the dataset file. Required if the `.csv.encr` file is present.
38
+ ```<DATASET_NAME>.kem.encr``` : Optional HPKE ciphertext file containing the encrypted symmetric key required to decrypt the dataset file. Required if the `.csv.encr` file is present.
40
39
 
41
- Decryption uses the RSA private key located at ```RSA_KEY_DIR```.
40
+ Decryption uses the combined ML-KEM-768/X25519 private key located at ```PRIVATE_KEY_DIR``` to recover the symmetric decryption key.
42
41
 
43
42
  The packaged file is then stored in `output_dir/archive/unpackaged` after a successful run or `output_dir/archive/failed` after an unsuccessful run.
44
43
 
44
+
45
45
  ## Example
46
- Python script that uses a RSA public key named `microdata_public_key.pem` and packages a dataset:
46
+
47
+ Store your metadata and data files according to the structure described above, and put
48
+ the provided public key in a directory of your choice.
49
+ Then:
47
50
 
48
51
  ```py
49
52
  from pathlib import Path
50
53
  from microdata_tools import package_dataset
51
54
 
52
- RSA_KEYS_DIRECTORY = Path("tests/resources/rsa_keys")
53
- DATASET_DIRECTORY = Path("tests/resources/input_package/DATASET_1")
54
- OUTPUT_DIRECTORY = Path("tests/resources/output")
55
-
56
55
  package_dataset(
57
- rsa_keys_dir=RSA_KEYS_DIRECTORY,
58
- dataset_dir=DATASET_DIRECTORY,
59
- output_dir=OUTPUT_DIRECTORY,
56
+ public_key_dir=Path("path/to/key_directory"),
57
+ dataset_dir=Path("path/to/MY_DATASET_NAME"),
58
+ output_dir=Path("path/to/output"),
60
59
  )
61
60
  ```
62
61
 
62
+ This produces `path/to/output/MY_DATASET_NAME.tar`, which can be uploaded to microdata.
63
+
64
+
63
65
  ### Validation
64
66
 
65
67
  Once you have your metadata and data files ready to go, they should be named and stored like this:
@@ -90,7 +92,7 @@ else:
90
92
  print(error)
91
93
  ```
92
94
 
93
- For a more in-depth explanation of usage visit [the usage documentation](https://statisticsnorway.github.io/microdata-tools/usage.md).
95
+ For a more in-depth explanation of usage visit [the usage documentation](https://statisticsnorway.github.io/microdata-tools/USAGE).
94
96
 
95
97
  ### Data format description
96
98
  A dataset as defined in microdata consists of one data file, and one metadata file.
@@ -1,4 +1,8 @@
1
- from microdata_tools.packaging import package_dataset, unpackage_dataset
1
+ from microdata_tools.packaging import (
2
+ package_dataset,
3
+ unpackage_dataset,
4
+ )
5
+ from microdata_tools.packaging.keys import PrivateKey, PublicKey
2
6
  from microdata_tools.validation import (
3
7
  get_unit_id_type_for_unit_type,
4
8
  validate_dataset,
@@ -11,4 +15,6 @@ __all__ = [
11
15
  "validate_dataset",
12
16
  "validate_metadata",
13
17
  "get_unit_id_type_for_unit_type",
18
+ "PrivateKey",
19
+ "PublicKey",
14
20
  ]
@@ -23,14 +23,15 @@ logger = logging.getLogger()
23
23
 
24
24
 
25
25
  def package_dataset(
26
- rsa_keys_dir: Path, dataset_dir: Path, output_dir: Path
26
+ public_key_dir: Path, dataset_dir: Path, output_dir: Path
27
27
  ) -> None:
28
28
  """
29
29
  Packages a dataset. It will encrypt and tar the dataset using
30
- the provided RSA public key. Only the CSV file will be encrypted.
30
+ the provided combined ML-KEM-768/X25519 public key.
31
+ Only the CSV file will be encrypted.
31
32
  Creates a checksum file for the CSV file.
32
33
 
33
- :param rsa_keys_dir:
34
+ :param public_key_dir:
34
35
  directory containing public key file microdata_public_key.pem
35
36
  :param dataset_dir:
36
37
  directory containing the dataset files (CSV and JSON)
@@ -59,7 +60,7 @@ def package_dataset(
59
60
  if len(csv_files) == 1:
60
61
  write_checksum_to_file(csv_files[0])
61
62
  encrypt_dataset(
62
- rsa_keys_dir=rsa_keys_dir,
63
+ public_key_dir=public_key_dir,
63
64
  dataset_dir=dataset_dir,
64
65
  output_dir=output_dir,
65
66
  )
@@ -67,10 +68,10 @@ def package_dataset(
67
68
  if not dataset_output_dir.exists():
68
69
  os.makedirs(dataset_output_dir)
69
70
 
70
- if Path(dataset_dir / f"{dataset_name}.md5").exists():
71
+ if Path(dataset_dir / f"{dataset_name}.sha256").exists():
71
72
  shutil.move(
72
- dataset_dir / f"{dataset_name}.md5",
73
- dataset_output_dir / f"{dataset_name}.md5",
73
+ dataset_dir / f"{dataset_name}.sha256",
74
+ dataset_output_dir / f"{dataset_name}.sha256",
74
75
  )
75
76
 
76
77
  shutil.copyfile(
@@ -87,17 +88,18 @@ def package_dataset(
87
88
 
88
89
  def unpackage_dataset(
89
90
  packaged_file_path: Path,
90
- rsa_keys_dir: Path,
91
+ private_key_dir: Path,
91
92
  output_dir: Path,
92
93
  ) -> None:
93
94
  """
94
95
  Unpackages a dataset. It will untar and decrypt the dataset using
95
- the provided RSA private key. Only the CSV file will be decrypted.
96
+ the provided combined ML-KEM-768/X25519 private key.
97
+ Only the CSV file will be decrypted.
96
98
  Validates the checksum of the CSV file.
97
99
 
98
100
  :param packaged_file_path:
99
101
  a Path to the .tar file containing the dataset files
100
- :param rsa_keys_dir:
102
+ :param private_key_dir:
101
103
  directory containing the private key file microdata_private_key.pem
102
104
  :param output_dir:
103
105
  output directory
@@ -105,12 +107,12 @@ def unpackage_dataset(
105
107
  None
106
108
  """
107
109
  check_exists(packaged_file_path)
108
- check_exists(rsa_keys_dir)
110
+ check_exists(private_key_dir)
109
111
 
110
112
  if not output_dir.exists():
111
113
  os.makedirs(output_dir)
112
114
 
113
- private_key_path = rsa_keys_dir / "microdata_private_key.pem"
115
+ private_key_path = private_key_dir / "microdata_private_key.pem"
114
116
  check_exists(private_key_path)
115
117
 
116
118
  dataset_name = packaged_file_path.stem
@@ -119,7 +121,7 @@ def unpackage_dataset(
119
121
 
120
122
  try:
121
123
  untar_encrypted_dataset(packaged_file_path, dataset_name, dataset_dir)
122
- decrypt(rsa_keys_dir, dataset_dir, output_dir)
124
+ decrypt(private_key_dir, dataset_dir, output_dir)
123
125
  _validate_csv_consistency(dataset_name, dataset_dir, output_dir)
124
126
 
125
127
  if Path(dataset_dir).exists():
@@ -139,5 +141,5 @@ def _validate_csv_consistency(
139
141
  output_dir / dataset_name / f"{dataset_name}.csv"
140
142
  )
141
143
  compare_checksum_with_file(
142
- dataset_dir / f"{dataset_name}.md5", calculated_checksum
144
+ dataset_dir / f"{dataset_name}.sha256", calculated_checksum
143
145
  )
@@ -0,0 +1,10 @@
1
+ from cryptography.hazmat.primitives.hpke import (
2
+ AEAD,
3
+ KDF,
4
+ KEM,
5
+ Suite,
6
+ )
7
+
8
+ NONCE_SIZE_BYTES = 12
9
+ HPKE_SUITE = Suite(KEM.MLKEM768_X25519, KDF.HKDF_SHA256, AEAD.AES_256_GCM)
10
+ HPKE_INFO = b"microdata-tools symmetric-key encryption"
@@ -5,24 +5,34 @@ import tarfile
5
5
  from pathlib import Path
6
6
  from typing import List, Tuple
7
7
 
8
- from cryptography.fernet import Fernet, InvalidToken
9
- from cryptography.hazmat.backends import default_backend
10
- from cryptography.hazmat.primitives import hashes, serialization
11
- from cryptography.hazmat.primitives.asymmetric import padding, rsa
8
+ from cryptography.exceptions import InvalidTag
9
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
10
+ from cryptography.hazmat.primitives.hpke import (
11
+ MLKEM768X25519PrivateKey,
12
+ )
12
13
 
13
- from microdata_tools.packaging._utils import check_exists
14
+ from microdata_tools.packaging._crypto_config import (
15
+ HPKE_INFO,
16
+ HPKE_SUITE,
17
+ NONCE_SIZE_BYTES,
18
+ )
19
+ from microdata_tools.packaging._utils import (
20
+ check_exists,
21
+ )
14
22
  from microdata_tools.packaging.exceptions import (
15
23
  InvalidKeyError,
16
24
  InvalidTarFileContents,
17
25
  )
26
+ from microdata_tools.packaging.keys import PrivateKey
18
27
 
19
28
  logger = logging.getLogger()
20
29
 
21
30
 
22
- def decrypt(rsa_keys_dir: Path, dataset_dir: Path, output_dir: Path) -> None:
31
+ def decrypt(private_key_dir: Path, dataset_dir: Path, output_dir: Path) -> None:
23
32
  """
24
33
  Decrypts a dataset as follows:
25
- 1. Decrypts the symmetric key using the RSA private key.
34
+ 1. Recovers the symmetric key using the hybrid private key
35
+ (HPKE with ML-KEM-768 + X25519).
26
36
  2. Decrypts each chunk using the symmetric key.
27
37
  3. Merges the decrypted chunks into a single file.
28
38
  """
@@ -39,36 +49,35 @@ def decrypt(rsa_keys_dir: Path, dataset_dir: Path, output_dir: Path) -> None:
39
49
  decrypted_dir = output_dataset_dir / "decrypted"
40
50
  os.makedirs(decrypted_dir, exist_ok=True)
41
51
 
42
- if not output_dataset_dir.exists():
43
- os.makedirs(output_dataset_dir)
44
-
45
52
  if chunk_dir.exists() and first_encrypted_chunk.exists():
46
53
  logger.info(f"Encrypted file found in {dataset_dir}")
47
54
 
48
- with open(
49
- Path(rsa_keys_dir / "microdata_private_key.pem"), "rb"
50
- ) as key_file:
51
- private_key = serialization.load_pem_private_key(
52
- key_file.read(), password=None, backend=default_backend()
55
+ # Read private key from file
56
+ private_key = PrivateKey.load_from_file(
57
+ Path(private_key_dir / "microdata_private_key.pem")
58
+ ).to_hpke_key()
59
+
60
+ if not isinstance(private_key, MLKEM768X25519PrivateKey):
61
+ raise TypeError(
62
+ "Private key is not a hybrid ML-KEM-768-X25519 private key."
53
63
  )
64
+ # Read the hpke ciphertext from file and recover the symmetric key
65
+ hpke_ciphertext_path = Path(dataset_dir / f"{dataset_name}.kem.encr")
66
+ check_exists(hpke_ciphertext_path)
54
67
 
55
- encrypted_symkey = Path(dataset_dir / f"{dataset_name}.symkey.encr")
56
- check_exists(encrypted_symkey)
57
-
58
- if not isinstance(private_key, rsa.RSAPrivateKey):
59
- raise TypeError("Privatkey is not RSA. Cannot use .decrypt().")
60
-
61
- with open(encrypted_symkey, "rb") as f:
62
- symkey = f.read()
63
- decrypted_symkey = private_key.decrypt(
64
- symkey,
65
- padding.OAEP(
66
- mgf=padding.MGF1(algorithm=hashes.SHA256()),
67
- algorithm=hashes.SHA256(),
68
- label=None,
69
- ),
70
- )
71
- fernet = Fernet(decrypted_symkey)
68
+ with open(hpke_ciphertext_path, "rb") as f:
69
+ hpke_ciphertext = f.read()
70
+
71
+ try:
72
+ decrypted_symkey = HPKE_SUITE.decrypt(
73
+ hpke_ciphertext, private_key, info=HPKE_INFO
74
+ )
75
+ except Exception as e:
76
+ raise InvalidKeyError(
77
+ "Unable to recover symmetric key. Is the private key correct?"
78
+ ) from e
79
+
80
+ aesgcm = AESGCM(decrypted_symkey)
72
81
 
73
82
  # Decrypt all the encrypted csv files in the directory
74
83
  for encrypted_file in chunk_dir.iterdir():
@@ -78,14 +87,18 @@ def decrypt(rsa_keys_dir: Path, dataset_dir: Path, output_dir: Path) -> None:
78
87
  # Decrypt csv file
79
88
  with open(encrypted_file, "rb") as file:
80
89
  data = file.read()
90
+ nonce, ciphertext = (
91
+ data[:NONCE_SIZE_BYTES],
92
+ data[NONCE_SIZE_BYTES:],
93
+ )
81
94
 
82
95
  try:
83
- decrypted_data = fernet.decrypt(data)
96
+ decrypted_data = aesgcm.decrypt(nonce, ciphertext, None)
84
97
 
85
98
  with open(decrypted_dir / f"{csv_file}", "wb") as file:
86
99
  file.write(decrypted_data)
87
100
 
88
- except InvalidToken as exc:
101
+ except InvalidTag as exc:
89
102
  raise InvalidKeyError(
90
103
  f"Not able to decrypt {encrypted_file}, "
91
104
  f"is symkey correct?"
@@ -93,8 +106,8 @@ def decrypt(rsa_keys_dir: Path, dataset_dir: Path, output_dir: Path) -> None:
93
106
 
94
107
  logger.debug(f"Decrypted {encrypted_file}")
95
108
 
96
- del symkey
97
- del fernet
109
+ del decrypted_symkey
110
+ del aesgcm
98
111
 
99
112
  # Merges the decrypted csv files into a single file
100
113
  _combine_csv_files(decrypted_dir, dataset_dir / f"{dataset_name}.csv")
@@ -140,14 +153,15 @@ def untar_encrypted_dataset(
140
153
 
141
154
 
142
155
  def _validate_tar_contents(files: List[str], dataset_name: str) -> None:
143
- if f"{dataset_name}.json" not in files:
156
+ filenames = {os.path.basename(f) for f in files}
157
+ if f"{dataset_name}.json" not in filenames:
144
158
  raise InvalidTarFileContents(f"{dataset_name}.json not in .tar file")
145
159
 
146
160
  if len(files) > 1:
147
- if f"{dataset_name}.symkey.encr" not in files:
161
+ if f"{dataset_name}.kem.encr" not in filenames:
148
162
  raise InvalidTarFileContents(
149
163
  f"Tar file for {dataset_name} does not contain the required "
150
- f"{dataset_name}.symkey.encr file"
164
+ f"{dataset_name}.kem.encr file"
151
165
  )
152
166
 
153
167
  chunk_files = [str for str in files if str.endswith(".csv.encr")]
@@ -157,10 +171,10 @@ def _validate_tar_contents(files: List[str], dataset_name: str) -> None:
157
171
  f"Tar file for {dataset_name} does not contain any chunks files"
158
172
  )
159
173
 
160
- if f"{dataset_name}.md5" not in files:
174
+ if f"{dataset_name}.sha256" not in filenames:
161
175
  raise InvalidTarFileContents(
162
176
  f"Tar file for {dataset_name} does not contain the required "
163
- f"{dataset_name}.md5 file"
177
+ f"{dataset_name}.sha256 file"
164
178
  )
165
179
 
166
180
 
@@ -4,13 +4,21 @@ import shutil
4
4
  import tarfile
5
5
  from pathlib import Path
6
6
 
7
- from cryptography.fernet import Fernet
8
- from cryptography.hazmat.backends import default_backend
9
- from cryptography.hazmat.primitives import hashes, serialization
10
- from cryptography.hazmat.primitives.asymmetric import padding, rsa
11
-
12
- from microdata_tools.packaging._utils import check_exists
7
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8
+ from cryptography.hazmat.primitives.hpke import (
9
+ MLKEM768X25519PublicKey,
10
+ )
11
+
12
+ from microdata_tools.packaging._crypto_config import (
13
+ HPKE_INFO,
14
+ HPKE_SUITE,
15
+ NONCE_SIZE_BYTES,
16
+ )
17
+ from microdata_tools.packaging._utils import (
18
+ check_exists,
19
+ )
13
20
  from microdata_tools.packaging.exceptions import ValidationException
21
+ from microdata_tools.packaging.keys import PublicKey
14
22
 
15
23
  logger = logging.getLogger()
16
24
 
@@ -18,7 +26,7 @@ CHUNK_SIZE_BYTES = 250_000_000 # 250 MB per chunk
18
26
 
19
27
 
20
28
  def encrypt_dataset(
21
- rsa_keys_dir: Path,
29
+ public_key_dir: Path,
22
30
  dataset_dir: Path,
23
31
  output_dir: Path,
24
32
  ) -> None:
@@ -27,28 +35,29 @@ def encrypt_dataset(
27
35
  1. Generates the symmetric key for this dataset.
28
36
  2. Splits the dataset into chunks.
29
37
  3. Encrypts each chunk using the symmetric key.
30
- 4. Encrypts the symmetric key using the RSA public key.
38
+ 4. Uses HPKE with a hybrid ML-KEM-768/X25519 public key to encrypt the
39
+ symmetric key and stores the resulting encapsulated ciphertext.
31
40
  """
32
41
 
33
- check_exists(rsa_keys_dir)
42
+ check_exists(public_key_dir)
34
43
  check_exists(dataset_dir)
35
44
 
36
45
  if not output_dir.exists():
37
46
  os.makedirs(output_dir)
38
47
 
39
- public_key_location = rsa_keys_dir / "microdata_public_key.pem"
48
+ public_key_location = public_key_dir / "microdata_public_key.pem"
40
49
  check_exists(public_key_location)
41
50
 
42
51
  # Read public key from file
43
- with open(public_key_location, "rb") as key_file:
44
- public_key = serialization.load_pem_public_key(
45
- key_file.read(), backend=default_backend()
46
- )
52
+ public_key = PublicKey.load_from_file(public_key_location).to_hpke_key()
47
53
 
54
+ if not isinstance(public_key, MLKEM768X25519PublicKey):
55
+ raise TypeError(
56
+ "Public key is not a hybrid ML-KEM-768-X25519 public key."
57
+ )
48
58
  csv_files = [
49
59
  file for file in dataset_dir.iterdir() if file.suffix == ".csv"
50
60
  ]
51
-
52
61
  csv_file = csv_files[0]
53
62
  dataset_name = csv_file.stem
54
63
 
@@ -62,11 +71,10 @@ def encrypt_dataset(
62
71
  os.makedirs(dataset_output_dir)
63
72
  os.makedirs(dataset_output_dir / "chunks", exist_ok=True)
64
73
 
65
- encrypted_symkey_file = dataset_output_dir / f"{dataset_name}.symkey.encr"
74
+ hpke_ciphertext_file = dataset_output_dir / f"{dataset_name}.kem.encr"
66
75
 
67
- # Generate and store symmetric key for this file
68
- symkey = Fernet.generate_key()
69
- fernet = Fernet(symkey)
76
+ symkey = AESGCM.generate_key(bit_length=256)
77
+ aesgcm = AESGCM(symkey)
70
78
 
71
79
  # Encrypt csv file
72
80
  chunk_count = 0
@@ -79,7 +87,9 @@ def encrypt_dataset(
79
87
  break
80
88
 
81
89
  chunk_count += 1
82
- encrypted = fernet.encrypt(data)
90
+ nonce = os.urandom(NONCE_SIZE_BYTES)
91
+ # The nonce is stored along with the ciphertext to decrypt later
92
+ encrypted = nonce + aesgcm.encrypt(nonce, data, None)
83
93
 
84
94
  chunk_file = (
85
95
  dataset_output_dir / "chunks" / f"{chunk_count}.csv.encr"
@@ -89,27 +99,23 @@ def encrypt_dataset(
89
99
 
90
100
  logger.debug(f"Csv file {csv_file} encrypted into {chunk_count} chunks")
91
101
 
92
- if not isinstance(public_key, rsa.RSAPublicKey):
93
- raise TypeError("Public key is not RSA. Cannot use .encrypt().")
102
+ # Encrypt symmetric key using HPKE and store the ciphertext
103
+ try:
104
+ hpke_ciphertext = HPKE_SUITE.encrypt(symkey, public_key, info=HPKE_INFO)
105
+ except Exception as e:
106
+ raise ValidationException(
107
+ "Failed to encrypt the dataset key using HPKE. "
108
+ "Please check that the public key is valid and try again."
109
+ ) from e
94
110
 
95
- encrypted_sym_key = public_key.encrypt(
96
- symkey,
97
- padding.OAEP(
98
- mgf=padding.MGF1(algorithm=hashes.SHA256()),
99
- algorithm=hashes.SHA256(),
100
- label=None,
101
- ),
102
- )
111
+ with open(hpke_ciphertext_file, "wb") as file:
112
+ file.write(hpke_ciphertext)
103
113
 
104
114
  del symkey
105
- del fernet
106
-
107
- # Store encrypted symkey to file
108
- with open(encrypted_symkey_file, "wb") as file:
109
- file.write(encrypted_sym_key)
115
+ del aesgcm
110
116
 
111
117
  logger.debug(
112
- f"Key file for {csv_file} encrypted into {encrypted_symkey_file}"
118
+ f"HPKE-encrypted key ciphertext stored in {hpke_ciphertext_file}"
113
119
  )
114
120
 
115
121
 
@@ -143,16 +149,16 @@ def tar_encrypted_dataset(input_dir: Path, dataset_name: str) -> None:
143
149
  if len(chunk_files) == 0:
144
150
  raise ValidationException(f"No files found in {chunk_dir}")
145
151
 
146
- md5_file = dataset_dir / f"{dataset_name}.md5"
147
- if not md5_file.exists():
152
+ sha256_file = dataset_dir / f"{dataset_name}.sha256"
153
+ if not sha256_file.exists():
148
154
  raise ValidationException(
149
- f"The required file {md5_file} is missing"
155
+ f"The required file {sha256_file} is missing"
150
156
  )
151
157
 
152
158
  files_to_tar.extend(
153
159
  [
154
- dataset_dir / f"{dataset_name}.symkey.encr",
155
- dataset_dir / f"{dataset_name}.md5",
160
+ dataset_dir / f"{dataset_name}.kem.encr",
161
+ dataset_dir / f"{dataset_name}.sha256",
156
162
  ]
157
163
  )
158
164
 
@@ -14,29 +14,29 @@ def check_exists(path: Path) -> None:
14
14
 
15
15
  def calculate_checksum(csv_file: Path) -> str:
16
16
  """
17
- Reads a file in chunks and returns the MD5 hash of the file
17
+ Reads a file in chunks and returns the SHA-256 hash of the file
18
18
  """
19
19
  CHUNK_SIZE = 32768
20
- hash_md5 = hashlib.md5()
20
+ hash_sha256 = hashlib.sha256()
21
21
  with open(csv_file, "rb") as f:
22
22
  for chunk in iter(lambda: f.read(CHUNK_SIZE), b""):
23
- hash_md5.update(chunk)
23
+ hash_sha256.update(chunk)
24
24
 
25
- return hash_md5.hexdigest()
25
+ return hash_sha256.hexdigest()
26
26
 
27
27
 
28
28
  def write_checksum_to_file(csv_file: Path) -> None:
29
- hash_file = str(csv_file).replace(".csv", ".md5")
29
+ hash_file = str(csv_file).replace(".csv", ".sha256")
30
30
  with open(hash_file, "w") as file:
31
31
  file.write(calculate_checksum(csv_file))
32
32
 
33
33
 
34
34
  def compare_checksum_with_file(
35
- md5_file: Path, calculated_checksum: str
35
+ sha256_file: Path, calculated_checksum: str
36
36
  ) -> None:
37
- with open(md5_file, "r") as file:
37
+ with open(sha256_file, "r") as file:
38
38
  checksum_from_file = file.readlines()[0]
39
39
  if calculated_checksum != checksum_from_file.strip():
40
40
  raise CsvConsistencyException(
41
- "MD5 checksums do not match. The csv file may be corrupted!"
41
+ "SHA-256 checksums do not match. The csv file may be corrupted!"
42
42
  )
@@ -0,0 +1,192 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ from cryptography.hazmat.primitives import serialization
5
+ from cryptography.hazmat.primitives.asymmetric import mlkem, x25519
6
+ from cryptography.hazmat.primitives.hpke import (
7
+ MLKEM768X25519PrivateKey,
8
+ MLKEM768X25519PublicKey,
9
+ )
10
+
11
+ PEM_PRIVATE_BLOCK = re.compile(
12
+ rb"-----BEGIN PRIVATE KEY-----.*?-----END PRIVATE KEY-----",
13
+ re.DOTALL,
14
+ )
15
+
16
+ PEM_PUBLIC_BLOCK = re.compile(
17
+ rb"-----BEGIN PUBLIC KEY-----.*?-----END PUBLIC KEY-----",
18
+ re.DOTALL,
19
+ )
20
+
21
+
22
+ class PrivateKey:
23
+ """Hybrid ML-KEM-768 + X25519 private key for use with HPKE."""
24
+
25
+ FILENAME = "microdata_private_key.pem"
26
+
27
+ def __init__(
28
+ self,
29
+ mlkem_key: mlkem.MLKEM768PrivateKey,
30
+ x25519_key: x25519.X25519PrivateKey,
31
+ ) -> None:
32
+ self.mlkem_key = mlkem_key
33
+ self.x25519_key = x25519_key
34
+
35
+ @classmethod
36
+ def generate(cls) -> "PrivateKey":
37
+ """Generates a new hybrid private key."""
38
+ return cls(
39
+ mlkem_key=mlkem.MLKEM768PrivateKey.generate(),
40
+ x25519_key=x25519.X25519PrivateKey.generate(),
41
+ )
42
+
43
+ @classmethod
44
+ def load_from_file(cls, path: Path) -> "PrivateKey":
45
+ """Loads a hybrid private key from a PEM file containing one
46
+ ML-KEM-768 and one X25519 PRIVATE KEY block, in any order."""
47
+ blocks = PEM_PRIVATE_BLOCK.findall(path.read_bytes())
48
+ if len(blocks) != 2:
49
+ raise ValueError(
50
+ f"Expected 2 PEM blocks in hybrid private key file, "
51
+ f"found {len(blocks)}"
52
+ )
53
+
54
+ parsed_keys = [
55
+ serialization.load_pem_private_key(block, password=None)
56
+ for block in blocks
57
+ ]
58
+
59
+ mlkem_key = next(
60
+ (
61
+ key
62
+ for key in parsed_keys
63
+ if isinstance(key, mlkem.MLKEM768PrivateKey)
64
+ ),
65
+ None,
66
+ )
67
+ x25519_key = next(
68
+ (
69
+ key
70
+ for key in parsed_keys
71
+ if isinstance(key, x25519.X25519PrivateKey)
72
+ ),
73
+ None,
74
+ )
75
+
76
+ if mlkem_key is None or x25519_key is None:
77
+ raise ValueError(
78
+ "Hybrid private key file must contain one ML-KEM-768 key "
79
+ "and one X25519 key."
80
+ )
81
+
82
+ return cls(mlkem_key=mlkem_key, x25519_key=x25519_key)
83
+
84
+ def public_key(self) -> "PublicKey":
85
+ """Returns the matching hybrid PublicKey."""
86
+ return PublicKey(
87
+ mlkem_key=self.mlkem_key.public_key(),
88
+ x25519_key=self.x25519_key.public_key(),
89
+ )
90
+
91
+ def serialize(self) -> bytes:
92
+ """Serializes both halves as concatenated PKCS8 PEM blocks."""
93
+ return self.mlkem_key.private_bytes(
94
+ encoding=serialization.Encoding.PEM,
95
+ format=serialization.PrivateFormat.PKCS8,
96
+ encryption_algorithm=serialization.NoEncryption(),
97
+ ) + self.x25519_key.private_bytes(
98
+ encoding=serialization.Encoding.PEM,
99
+ format=serialization.PrivateFormat.PKCS8,
100
+ encryption_algorithm=serialization.NoEncryption(),
101
+ )
102
+
103
+ def write_to_file(self, target_dir: Path) -> None:
104
+ """Writes the serialized key to
105
+ ``<target_dir>/<PrivateKey.FILENAME>``."""
106
+ (target_dir / self.FILENAME).write_bytes(self.serialize())
107
+
108
+ def to_hpke_key(self) -> MLKEM768X25519PrivateKey:
109
+ """Returns the combined HPKE-usable key object."""
110
+ return MLKEM768X25519PrivateKey(
111
+ mlkem_key=self.mlkem_key,
112
+ x25519_key=self.x25519_key,
113
+ )
114
+
115
+
116
+ class PublicKey:
117
+ """Hybrid ML-KEM-768 + X25519 public key for use with HPKE."""
118
+
119
+ FILENAME = "microdata_public_key.pem"
120
+
121
+ def __init__(
122
+ self,
123
+ mlkem_key: mlkem.MLKEM768PublicKey,
124
+ x25519_key: x25519.X25519PublicKey,
125
+ ) -> None:
126
+ self.mlkem_key = mlkem_key
127
+ self.x25519_key = x25519_key
128
+
129
+ @classmethod
130
+ def load_from_file(cls, path: Path) -> "PublicKey":
131
+ """Loads a hybrid public key from a PEM file containing one
132
+ ML-KEM-768 and one X25519 PUBLIC KEY block, in any order."""
133
+ return cls.from_pem(path.read_bytes())
134
+
135
+ @classmethod
136
+ def from_pem(cls, pem_data: bytes) -> "PublicKey":
137
+ """Loads a hybrid public key from PEM data containing one
138
+ ML-KEM-768 and one X25519 PUBLIC KEY block, in any order."""
139
+ blocks = PEM_PUBLIC_BLOCK.findall(pem_data)
140
+ if len(blocks) != 2:
141
+ raise ValueError(
142
+ "Expected 2 PEM blocks in hybrid public key file, "
143
+ f"found {len(blocks)}"
144
+ )
145
+
146
+ parsed_keys = [serialization.load_pem_public_key(b) for b in blocks]
147
+ mlkem_key = next(
148
+ (
149
+ key
150
+ for key in parsed_keys
151
+ if isinstance(key, mlkem.MLKEM768PublicKey)
152
+ ),
153
+ None,
154
+ )
155
+ x25519_key = next(
156
+ (
157
+ key
158
+ for key in parsed_keys
159
+ if isinstance(key, x25519.X25519PublicKey)
160
+ ),
161
+ None,
162
+ )
163
+
164
+ if mlkem_key is None or x25519_key is None:
165
+ raise ValueError(
166
+ "Hybrid public key file must contain one ML-KEM-768 key "
167
+ "and one X25519 key."
168
+ )
169
+
170
+ return cls(mlkem_key=mlkem_key, x25519_key=x25519_key)
171
+
172
+ def serialize(self) -> bytes:
173
+ """Serializes both keys as concatenated PEM blocks."""
174
+ return self.mlkem_key.public_bytes(
175
+ encoding=serialization.Encoding.PEM,
176
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
177
+ ) + self.x25519_key.public_bytes(
178
+ encoding=serialization.Encoding.PEM,
179
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
180
+ )
181
+
182
+ def write_to_file(self, target_dir: Path) -> None:
183
+ """Writes the serialized keys to
184
+ ``<target_dir>/<PublicKey.FILENAME>``."""
185
+ (target_dir / self.FILENAME).write_bytes(self.serialize())
186
+
187
+ def to_hpke_key(self) -> MLKEM768X25519PublicKey:
188
+ """Returns the combined HPKE-usable key object."""
189
+ return MLKEM768X25519PublicKey(
190
+ mlkem_key=self.mlkem_key,
191
+ x25519_key=self.x25519_key,
192
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "microdata-tools"
3
- version = "1.14.0"
3
+ version = "2.0.0"
4
4
  description = "Tools for the microdata.no platform"
5
5
  authors = [{ name = "microdata-developers" }]
6
6
  license = { text = "MIT" }