raster-integrity 0.0.2__py2.py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ __version__ = "0.0.2"
@@ -0,0 +1,45 @@
1
+ import argparse
2
+ import os
3
+ from osgeo import gdal
4
+ from technique_factory import TechniqueFactory
5
+
6
+
7
+ def parse_tech_args(item):
8
+ if "=" not in item:
9
+ raise argparse.ArgumentTypeError(f"Expected KEY=VALUE, given: {item}")
10
+ key, value = item.split("=", 1)
11
+ return key.strip(), value.strip()
12
+
13
+
14
+ def main():
15
+ parser = argparse.ArgumentParser(
16
+ description="Execute integrity technique on raster file",
17
+ formatter_class=argparse.RawTextHelpFormatter,
18
+ )
19
+ parser.add_argument("--input", "-i", required=True, help="Path of raster file")
20
+ parser.add_argument(
21
+ "--arg",
22
+ "-a",
23
+ action="append",
24
+ type=parse_tech_args,
25
+ metavar="KEY=VALUE",
26
+ help="Extra argument for the technique, repeatable (e.g. -a precision=2)",
27
+ )
28
+ parser.add_argument(
29
+ "--technique", "-t", required=True, help=TechniqueFactory.help_text()
30
+ )
31
+ args = parser.parse_args()
32
+
33
+ if not os.path.exists(args.input):
34
+ parser.error(f"File not found: {args.input}")
35
+
36
+ try:
37
+ method = TechniqueFactory.create(args.technique, file=args.input)
38
+ except (ValueError, TypeError) as e:
39
+ parser.error(str(e))
40
+
41
+ print(method.create_integrity(**dict(args.arg or [])))
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
@@ -0,0 +1,122 @@
1
+ from osgeo import gdal
2
+ from pathlib import Path
3
+ import argparse, os, sys
4
+
5
+ gdal.UseExceptions()
6
+
7
+
8
+ def create_metadata_file(file_path, metadata=None):
9
+ if os.path.exists(f"{file_path}.aux.xml"):
10
+ os.remove(f"{file_path}.aux.xml")
11
+ if metadata is not None:
12
+ for key, value in metadata.items():
13
+ if not isinstance(key, str) or not key.strip():
14
+ raise ValueError(f"Invalid metadata-key: {key!r}")
15
+ if value is None:
16
+ raise ValueError(f"Metadata-value for '{key}' is empty")
17
+
18
+ gdalPamEnabled = gdal.GetConfigOption("GDAL_PAM_ENABLED")
19
+ gdalGeorefSrcs = gdal.GetConfigOption("GDAL_GEOREF_SOURCES")
20
+
21
+ gdal.SetConfigOption("GDAL_PAM_ENABLED", "YES")
22
+ gdal.SetConfigOption("GDAL_GEOREF_SOURCES", "PAM,INTERNAL")
23
+
24
+ ds = gdal.Open(file_path, gdal.GA_ReadOnly)
25
+
26
+ srs = ds.GetProjection()
27
+ gt = ds.GetGeoTransform()
28
+ band = ds.GetRasterBand(1)
29
+ nodata = band.GetNoDataValue()
30
+
31
+ if nodata == None:
32
+ max_val = band.GetMaximum()
33
+ min_val = band.GetMinimum()
34
+ # Compute if not available
35
+ if max_val is None or min_val is None:
36
+ # False means calculate on full resolution
37
+ min_val, max_val = band.ComputeRasterMinMax(False)
38
+
39
+ # make decision - first max at last min (min is prior)
40
+ if max_val > 99999:
41
+ nodata = max_val
42
+ if min_val < -9999:
43
+ nodata = min_val
44
+
45
+ ds = None
46
+
47
+ ds2 = gdal.OpenEx(
48
+ file_path,
49
+ gdal.OF_RASTER,
50
+ open_options=["GEOREF_SOURCES=PAM"], # PAM has priority
51
+ )
52
+ if metadata:
53
+ for key, value in metadata.items():
54
+ ds2.SetMetadataItem(key, str(value))
55
+ ds2.SetProjection(srs)
56
+ ds2.SetGeoTransform(gt)
57
+ if nodata is not None:
58
+ ds2.GetRasterBand(1).SetNoDataValue(nodata)
59
+ ds2.GetRasterBand(1).ComputeStatistics(False)
60
+
61
+ ds2.FlushCache()
62
+ ds2 = None
63
+ gdal.SetConfigOption("GDAL_PAM_ENABLED", gdalPamEnabled)
64
+ gdal.SetConfigOption("GDAL_GEOREF_SOURCES", gdalGeorefSrcs)
65
+
66
+
67
+ def parse_meta(item):
68
+ if "=" not in item:
69
+ raise argparse.ArgumentTypeError(f"Expected KEY=VALUE, given: {item}")
70
+ key, value = item.split("=", 1)
71
+ return key.strip(), value.strip()
72
+
73
+
74
+ def main():
75
+ parser = argparse.ArgumentParser(
76
+ description=(
77
+ "Enforce raster data to include metadata,"
78
+ "\n"
79
+ "ie: raster_integrity.setup -f raster.tif -m Corporation='CORP' Survey_Year=2025 Datum=LAT"
80
+ ),
81
+ formatter_class=argparse.RawTextHelpFormatter,
82
+ )
83
+
84
+ parser.add_argument(
85
+ "--meta",
86
+ "-m",
87
+ action="append",
88
+ type=parse_meta,
89
+ help="Metadata as KEY=VALUE (repeatable ie: -m Corporation='CORP' Survey_Year=2025 Datum=LAT)",
90
+ )
91
+
92
+ parser.add_argument(
93
+ "--file", "-f", required=True, help="Raster file to create a metadatafile"
94
+ )
95
+
96
+ args = parser.parse_args()
97
+
98
+ if not os.path.exists(args.file):
99
+ print(f"ERROR: File not found: {args.file}")
100
+ sys.exit(1)
101
+ metadata = dict(args.meta) if args.meta else None
102
+ create_metadata_file(args.file, metadata)
103
+
104
+ gdal.SetConfigOption("GDAL_PAM_ENABLED", "YES")
105
+ ds = gdal.Open(args.file, gdal.GA_ReadOnly)
106
+ band = ds.GetRasterBand(1)
107
+ band_metadata = band.GetMetadata()
108
+ print("+" + "-" * len(ds.GetDriver().GetName()) + "+")
109
+ print("|" + ds.GetDriver().GetName() + "|")
110
+ print("+" + "-" * len(ds.GetDriver().GetName()) + "+")
111
+ print(args.file)
112
+ print("-" * len(args.file))
113
+ print("DATASET METADATA")
114
+ print(ds.GetMetadata())
115
+ print("BAND 1 METADATA")
116
+ print(band_metadata)
117
+ ds.FlushCache()
118
+ ds = None
119
+
120
+
121
+ if __name__ == "__main__":
122
+ main()
@@ -0,0 +1,92 @@
1
+ """
2
+ technique_factory.py
3
+ =====================
4
+ Factory-implementation for creating of different types of
5
+ hashing- and validation techniques based on short codes.
6
+ """
7
+
8
+ from enum import Enum
9
+ from techniques.file_hash import FileHash
10
+ from techniques.merkle_tree_hash import *
11
+ from techniques.archiving import *
12
+ from techniques.timestamping import *
13
+
14
+
15
+ class TechniqueType(Enum):
16
+ FILE_HASH = "fh"
17
+ MERKLE = "me"
18
+ MERKLE_ROBUST = "mr"
19
+ MERKLE_SQLITE = "ms"
20
+ BAGIT_ARCHIVING = "ba"
21
+ TIMESTAMPING = "ts"
22
+
23
+
24
+ class TechniqueFactory:
25
+ _mapping = {
26
+ TechniqueType.FILE_HASH: FileHash,
27
+ TechniqueType.MERKLE: MerkleTreeInmemoryHash,
28
+ TechniqueType.MERKLE_ROBUST: MerkleTreeInmemoryRobustHash,
29
+ TechniqueType.MERKLE_SQLITE: MerkleTreeSqliteHash,
30
+ TechniqueType.BAGIT_ARCHIVING: BagItArchive,
31
+ TechniqueType.TIMESTAMPING: Timestamping,
32
+ }
33
+
34
+ @classmethod
35
+ def create(self, code: str, **kwargs):
36
+ try:
37
+ hash_type = TechniqueType(code)
38
+ except ValueError:
39
+ raise ValueError(
40
+ f"Unknown technique code '{code}'.\n"
41
+ f"Available codes:\n{self.help_text()}"
42
+ )
43
+
44
+ return self._mapping[hash_type](kwargs)
45
+
46
+
47
+ class TechniqueFactory:
48
+ _mapping = {
49
+ TechniqueType.FILE_HASH: FileHash,
50
+ TechniqueType.MERKLE: MerkleTreeInmemoryHash,
51
+ TechniqueType.MERKLE_ROBUST: MerkleTreeInmemoryRobustHash,
52
+ TechniqueType.MERKLE_SQLITE: MerkleTreeSqliteHash,
53
+ TechniqueType.BAGIT_ARCHIVING: BagItArchive,
54
+ TechniqueType.TIMESTAMPING: Timestamping,
55
+ }
56
+
57
+ @classmethod
58
+ def create(cls, code: str, **kwargs):
59
+ try:
60
+ technique_type = TechniqueType(code)
61
+ except ValueError:
62
+ raise ValueError(
63
+ f"Unknown technique code '{code}'.\n"
64
+ f"Available codes:\n{cls.help_text()}"
65
+ ) from None
66
+
67
+ return cls._mapping[technique_type](**kwargs)
68
+
69
+ @staticmethod
70
+ def help_text():
71
+ return (
72
+ "Techniques and their extra arguments (use -a KEY=VALUE):\n"
73
+ "\n"
74
+ " fh File hashing (digest of the file bytes)\n"
75
+ " algorithm hashlib algorithm [default: sha256]\n"
76
+ "\n"
77
+ " me Merkle tree with hashing (in memory)\n"
78
+ " algorithm hashlib algorithm [default: sha256]\n"
79
+ "\n"
80
+ " mr Merkle tree with robust hashing\n"
81
+ " algorithm hashlib algorithm [default: sha256]\n"
82
+ " precision decimals to round (int) [default: 3]\n"
83
+ "\n"
84
+ " ms Merkle tree with hashing, stored in SQLite (<file>.db)\n"
85
+ " algorithm hashlib algorithm [default: sha256]\n"
86
+ "\n"
87
+ " ba RFC 8493 BagIt archiving\n"
88
+ " root_hash fingerprint stored in the bag [required]\n"
89
+ "\n"
90
+ " ts RFC 3161 trusted timestamping\n"
91
+ " file file used for timestamping [required]\n"
92
+ )
File without changes
@@ -0,0 +1,99 @@
1
+ """
2
+ archiving.py
3
+ ===================
4
+ BagIt packaging for long-term archiving.
5
+
6
+ BagIt (RFC 8493) is a hierarchical file packaging format for the storage and
7
+ transfer of digital content. A "bag" is a directory containing the payload
8
+ files together with a manifest of checksums, so that the completeness and
9
+ integrity of the content can be verified at any time. This provides:
10
+
11
+ - Completeness check: all files in the manifest are present in the bag
12
+ - Integrity verification: checksums confirm that no file was altered
13
+ - Self-describing packages: metadata travels with the data
14
+ - Portability: plain files and text manifests, no special software required
15
+
16
+ Structure of a bag:
17
+ bag/
18
+ ├── bagit.txt BagIt version and tag file encoding
19
+ ├── bag-info.txt descriptive metadata (source, date, contact, ...)
20
+ ├── manifest-sha256.txt checksums of all payload files
21
+ ├── tagmanifest-sha256.txt checksums of the tag files
22
+ └── data/ the payload (e.g. the bathymetric datasets)
23
+
24
+ Difference from timestamping:
25
+ BagIt: checksums + metadata → proves what a package contains
26
+ Timestamping: independent third party → proves when it existed
27
+ The two are complementary: a bag manifest can be timestamped to prove
28
+ both the content and the time of archiving.
29
+
30
+ Use at Hydrographic Offices:
31
+ - Packaging bathymetric datasets (e.g. GeoTIFF) for long-term archiving
32
+ - Detecting bit rot or accidental modification of archived surveys
33
+ - Transferring datasets to other parties with verifiable integrity
34
+
35
+ Usage:
36
+ # Create a bag from a directory
37
+ bag = bagit.make_bag("survey_2024/", {"Source-Organization": "Flemish Hydrography"})
38
+
39
+ # Validate an existing bag
40
+ bag = bagit.Bag("survey_2024/")
41
+ bag.validate()
42
+ """
43
+
44
+ import bagit
45
+ import os
46
+ import shutil
47
+ from osgeo import gdal
48
+ from .technique import Technique
49
+
50
+ gdal.UseExceptions()
51
+
52
+
53
+ class BagItArchive(Technique):
54
+ def label(self):
55
+ return f"archive_bagit"
56
+
57
+ def _house_holding(self):
58
+ directory = self._get_directory(self._file)
59
+
60
+ if os.path.exists(directory):
61
+ shutil.rmtree(directory)
62
+ os.makedirs(directory)
63
+
64
+ filename = os.path.basename(self._file)
65
+ shutil.copy(self._file, os.path.join(directory, filename))
66
+
67
+ aux = f"{self._file}.aux.xml"
68
+ if os.path.exists(aux):
69
+ shutil.copy(aux, os.path.join(directory, f"{filename}.aux.xml"))
70
+
71
+ def create_integrity(self, **kwargs):
72
+ """
73
+ Parameters
74
+ ----------
75
+ root_hash : str
76
+ Integrity hash key for storing into bag, for validation on data/info content
77
+ """
78
+ if "root_hash" not in kwargs:
79
+ raise TypeError("Missing required keyword argument: 'root_hash'")
80
+
81
+ self._root_hash = kwargs["root_hash"]
82
+ self._house_holding()
83
+ bag = bagit.make_bag(
84
+ self._get_directory(self._file),
85
+ {
86
+ "fingerprint": self._root_hash,
87
+ },
88
+ )
89
+ return bag
90
+
91
+ def _get_directory(self, file):
92
+ root, _ = os.path.splitext(file)
93
+ return f"{root}_bagit"
94
+
95
+ def _create_directory(self, file):
96
+ dir = self._get_directory(file)
97
+ if not os.path.exists(dir):
98
+ os.mkdir(dir)
99
+ return dir
@@ -0,0 +1,73 @@
1
+ """
2
+ file_hash.py
3
+ ============
4
+ Cryptographic file hashing for integrity verification.
5
+
6
+ A cryptographic hash function maps a file of arbitrary size to a fixed-length
7
+ digest (the "fingerprint" of the file). Any change to the file, however small,
8
+ results in a completely different digest. This provides:
9
+
10
+ - Integrity verification: detect accidental corruption or modification
11
+ - Change detection: compare a file against a previously stored digest
12
+ - Deduplication: identical content yields an identical digest
13
+
14
+ The default algorithm is SHA-256 (SHA-2 family, specified in NIST FIPS 180-4).
15
+ Any algorithm supported by Python's hashlib can be selected instead.
16
+ Files are read in 64 KB chunks, so large datasets (e.g. GeoTIFF rasters or
17
+ point clouds) can be hashed without loading them into memory.
18
+
19
+ Difference from HMAC and timestamping:
20
+ File hash: digest of the content only -> proves the file is unchanged
21
+ relative to a known digest
22
+ HMAC: digest + shared secret key -> also proves who created it
23
+ Timestamping: digest + independent third party -> proves when it existed
24
+ A plain hash offers no authenticity: anyone can recompute it. It is
25
+ therefore only trustworthy when the reference digest itself is stored
26
+ or transmitted through a trusted channel (e.g. in a signed or
27
+ timestamped manifest).
28
+
29
+ Used for:
30
+ - Fixity checks on archived bathymetric datasets
31
+ - Verifying that a dataset was transferred without corruption
32
+
33
+ Usage:
34
+ hasher = FileHash(algorithm="sha256")
35
+
36
+ # Create the digest of a file
37
+ digest = hasher.create_integrity("dataset.tif")
38
+
39
+ # Verify a file against a stored digest
40
+ ok = hasher.verify("dataset.tif", integrity=digest)
41
+ """
42
+
43
+ import os
44
+ import hashlib
45
+ from osgeo import gdal
46
+ from .technique import Technique
47
+
48
+ gdal.UseExceptions()
49
+
50
+
51
+ class FileHash(Technique):
52
+ def label(self):
53
+ return "file_hash"
54
+
55
+ def create_integrity(self, **kwargs):
56
+ """
57
+ Parameters
58
+ ----------
59
+ algorithm : str
60
+ kind of algorithm to used for creating hash key
61
+ standard: "sha256"
62
+ """
63
+ if "algorithm" in kwargs:
64
+ self._algorithm = kwargs["algorithm"]
65
+ else:
66
+ self._algorithm = "sha256"
67
+
68
+ hasher = hashlib.new(self._algorithm)
69
+ with open(self._file, "rb") as f:
70
+ while chunk := f.read(65536): # 64KB chunks
71
+ hasher.update(chunk)
72
+ digest = hasher.hexdigest()
73
+ return digest
@@ -0,0 +1,262 @@
1
+ """
2
+ merkle_tree_hash.py
3
+ ===================
4
+ Content-based integrity hashing of raster datasets with a Merkle tree.
5
+
6
+ Where a plain file hash (see FileHash) fingerprints the stored bytes of a
7
+ file, the classes in this module fingerprint the *content* of a raster: the
8
+ pixel values of the first band, read through GDAL. Every raster row becomes
9
+ one leaf of a Merkle tree (a binary tree in which every node is the hash of
10
+ its children). The root of the tree, hex-encoded, is the integrity value of
11
+ the dataset. This provides:
12
+
13
+ - Format independence: the same values stored with a different
14
+ compression or tiling yield the same fingerprint
15
+ - Low memory use: the raster is read and hashed row by row
16
+ - Change localisation: a persisted tree can be used to find which rows
17
+ changed, or to prove that a row is part of the dataset
18
+ - Tolerance (robust variant): insensitive to small rounding differences
19
+
20
+ Classes:
21
+ MerkleTreeInmemoryHash
22
+ Tree kept in memory. Nothing is written to disk.
23
+ MerkleTreeSqliteHash
24
+ Same fingerprint, but the tree is persisted in a SQLite database next
25
+ to the dataset ("<file>.db"). Suited when the tree is needed after
26
+ the calculation, for instance to generate proofs.
27
+ MerkleTreeInmemoryRobustHash
28
+ Rounds values before hashing, so that small numerical differences do
29
+ not change the fingerprint (see "Robust hashing").
30
+
31
+ What is hashed (in this order):
32
+ 1. Dataset metadata items whose key starts with "STATISTICS" or
33
+ "Corporation", sorted by key and stored as "key=value"
34
+ 2. The same selection from the metadata of band 1
35
+ 3. For each raster row: the values with NoData removed, as float32 bytes
36
+
37
+ Not part of the fingerprint: georeferencing (geotransform, CRS), other
38
+ bands, other metadata, and the position of NoData cells within a row.
39
+
40
+ Robust hashing:
41
+ Before hashing, values are rounded to `precision` decimals (default 3)
42
+ and converted to absolute values. The sign-insensitivity makes the
43
+ fingerprint independent of the sign convention (e.g. depths stored as
44
+ positive or negative numbers). For datasets with a negative mean,
45
+ STATISTICS_MINIMUM and STATISTICS_MAXIMUM are swapped accordingly.
46
+
47
+ 12.123 == 12.1234555 (precision=3)
48
+ 12.123 != 12.456
49
+
50
+ Limitations:
51
+ - Rounding is not a true tolerance: two values that differ by less than
52
+ 10**-precision can still fall on different sides of a rounding
53
+ boundary and produce different fingerprints.
54
+ - A value cannot be distinguished from its negative, so the robust
55
+ variant is only meaningful for datasets whose values share one sign.
56
+
57
+ Difference from a file hash:
58
+ File hash: digest of the stored bytes -> proves the file is unchanged
59
+ Merkle hash: digest of the raster values -> proves the data is unchanged,
60
+ even if the file was rewritten (e.g. recompressed)
61
+
62
+ Use at Flemish Hydrography:
63
+ - Fixity checks on archived bathymetric rasters (e.g. GeoTIFF)
64
+ - Verifying that a dataset kept its values after format conversion
65
+ - Producing the fingerprints that are timestamped (RFC 3161) or listed
66
+ in a BagIt manifest
67
+
68
+ Requires: GDAL (osgeo), NumPy and pymerkle.
69
+
70
+ Usage:
71
+ hasher = MerkleTreeInmemoryHash(algorithm="sha256")
72
+
73
+ # Create the fingerprint of a raster
74
+ fingerprint = hasher.create_integrity("dataset.tif")
75
+
76
+ # Verify a raster against a stored fingerprint
77
+ ok = hasher.verify("dataset.tif", integrity=fingerprint)
78
+
79
+ # Tolerant of rounding differences beyond the third decimal
80
+ robust = MerkleTreeInmemoryRobustHash(precision=3)
81
+ fingerprint = robust.create_integrity("dataset.tif")
82
+ """
83
+
84
+ from osgeo import gdal
85
+ import numpy as np
86
+ from pymerkle import SqliteTree, InmemoryTree
87
+ import hashlib
88
+ import sys
89
+ import os
90
+ from .technique import Technique
91
+
92
+ gdal.UseExceptions()
93
+
94
+
95
+ class MerkleTreeInmemoryHash(Technique):
96
+ def _getDatasource(self, file):
97
+ ds = gdal.Open(file)
98
+ if ds is None:
99
+ raise FileNotFoundError(f"GDAL cannot open file: {file}")
100
+ return ds
101
+
102
+ def _getMerkleTree(self):
103
+ self._house_holding()
104
+ return InmemoryTree(algorithm=self._algorithm)
105
+
106
+ def _normalize_to_prec(self, value):
107
+ return value
108
+
109
+ def _addMetadataToTree(self, metadata, tree):
110
+ # ADDING metadata
111
+ # Sorted, be sure all metadata items are stored in the same way
112
+ # Dictionary sort is not guaranteed
113
+ filtered = sorted(
114
+ (
115
+ (k, self._normalize_to_prec(v))
116
+ for k, v in metadata
117
+ if k.startswith("STATISTICS") or k.startswith("Corporation")
118
+ ),
119
+ key=lambda x: x[0], # sorteer op key
120
+ )
121
+
122
+ for meta_key, meta_value in filtered:
123
+ tree.append_entry(f"{meta_key}={meta_value}".encode("utf-8"))
124
+
125
+ def label(self):
126
+ return "merkle_tree_inmemory_hash"
127
+
128
+ def _house_holding(self):
129
+ db_path = f"{self._file}.db"
130
+ if os.path.exists(db_path):
131
+ os.remove(db_path)
132
+
133
+ def create_integrity(self, **kwargs):
134
+ """
135
+ Parameters
136
+ ----------
137
+ algorithm : str
138
+ kind of algorithm to used for creating hash key
139
+ standard: "sha256"
140
+ """
141
+ self._algorithm = "sha256"
142
+ if "algorithm" in kwargs:
143
+ self._algorithm = kwargs["algorithm"]
144
+
145
+ ds = self._getDatasource(self._file)
146
+ band = ds.GetRasterBand(1)
147
+ nodata = band.GetNoDataValue()
148
+
149
+ cols = ds.RasterXSize
150
+ rows = ds.RasterYSize
151
+
152
+ tree = self._getMerkleTree()
153
+
154
+ # ADDING metadata
155
+ self._addMetadataToTree(ds.GetMetadata().items(), tree)
156
+ self._addMetadataToTree(band.GetMetadata().items(), tree)
157
+
158
+ for y in range(rows):
159
+ row = band.ReadAsArray(0, y, cols, 1)[0]
160
+ # mask nodata
161
+ if nodata is not None:
162
+ row = row[row != nodata]
163
+ # No X/Y, no geotransform, only structure + value
164
+ packed = row.astype(np.float32).tobytes()
165
+ tree.append_entry(packed)
166
+ root = tree.get_state()
167
+ # band.SetMetadataItem("fingerprint", root.hex())
168
+ ds = None
169
+ band = None
170
+ return root.hex()
171
+
172
+
173
+ class MerkleTreeSqliteHash(MerkleTreeInmemoryHash):
174
+ def label(self):
175
+ return "merkle_tree_sqlite_hash"
176
+
177
+ def _getMerkleTree(self):
178
+ self._house_holding()
179
+ return SqliteTree(f"{self._file}.db", algorithm=self._algorithm)
180
+
181
+
182
+ class MerkleTreeInmemoryRobustHash(MerkleTreeInmemoryHash):
183
+ """
184
+ Merkle Tree integriteitscontrole with robust hashing.
185
+
186
+ The difference with regular hashing:
187
+ - Values will be rounded on `precision` decimals before hashing.
188
+ It makes the hash tolerant for non-content changes such as:
189
+ 12.123 == 12.1234555 (precision=3)
190
+ -5.25 == -5.2499999 (precision=2)
191
+ It detects real content changes:
192
+ 12.123 != 12.456
193
+ """
194
+
195
+ def label(self):
196
+ return f"merkle_tree_robust_hash_p{self._precision}"
197
+
198
+ def _normalize_to_prec(self, value):
199
+ try:
200
+ return abs(round(float(value), self._precision))
201
+ except ValueError:
202
+ return value
203
+
204
+ def _robust_metadata(self, metadata):
205
+ if "STATISTICS_MEAN" in metadata and float(metadata["STATISTICS_MEAN"]) < 0:
206
+ new_min = metadata["STATISTICS_MAXIMUM"]
207
+ metadata["STATISTICS_MAXIMUM"] = metadata["STATISTICS_MINIMUM"]
208
+ metadata["STATISTICS_MINIMUM"] = new_min
209
+ return metadata
210
+
211
+ def create_integrity(self, **kwargs):
212
+ """
213
+ Parameters
214
+ ----------
215
+ algorithm : str
216
+ kind of algorithm to used for creating hash key
217
+ standard: "sha256"
218
+ precision : int
219
+ Number of decimals to be rounded before hashing.
220
+ High value = sensitive for small changes.
221
+ Low value = more tolerant for rounding differences.
222
+
223
+ Recommendation for raster data:
224
+ precision=2 -> tolerant up to 0.01m (ie. after lossy compression)
225
+ precision=3 -> tolerant up to 0.001m (recommended standard)
226
+ precision=4 -> tolerant up to 0.0001m (strict)
227
+ """
228
+ self._algorithm = "sha256"
229
+ if "algorithm" in kwargs:
230
+ self._algorithm = kwargs["algorithm"]
231
+
232
+ self._precision = 3
233
+ if "precision" in kwargs:
234
+ self._precision = kwargs["precision"]
235
+
236
+ ds = self._getDatasource(self._file)
237
+ band = ds.GetRasterBand(1)
238
+ nodata = band.GetNoDataValue()
239
+
240
+ cols = ds.RasterXSize
241
+ rows = ds.RasterYSize
242
+
243
+ tree = self._getMerkleTree()
244
+
245
+ # ADDING metadata
246
+ self._addMetadataToTree(self._robust_metadata(ds.GetMetadata()).items(), tree)
247
+ self._addMetadataToTree(self._robust_metadata(band.GetMetadata()).items(), tree)
248
+
249
+ for y in range(rows):
250
+ row = band.ReadAsArray(0, y, cols, 1)[0]
251
+ # mask nodata
252
+ if nodata is not None:
253
+ row = row[row != nodata]
254
+
255
+ row = np.abs(np.round(row, decimals=self._precision))
256
+ packed = row.astype(np.float32).tobytes()
257
+ tree.append_entry(packed)
258
+
259
+ root = tree.get_state()
260
+ band = None
261
+ ds = None
262
+ return root.hex()
@@ -0,0 +1,17 @@
1
+ import os
2
+
3
+
4
+ class Technique:
5
+ def __init__(self, **kwargs):
6
+ """
7
+ Parameters
8
+ ----------
9
+ file : str
10
+ Raster file for creating integrity
11
+ """
12
+ if "file" not in kwargs:
13
+ raise TypeError("Missing required keyword argument: 'file'")
14
+
15
+ self._file = kwargs["file"]
16
+ if not os.path.exists(self._file):
17
+ raise ValueError(f"File '{self._file}' does not exist")
@@ -0,0 +1,174 @@
1
+ """
2
+ timestamping.py
3
+ ===============
4
+ RFC 3161 Trusted Timestamping.
5
+
6
+ RFC 3161 is an internet standard for trusted timestamping. A
7
+ Timestamp Authority (TSA) cryptographically certifies that a given
8
+ hash value existed at a specific point in time. This provides:
9
+
10
+ - Integrity verification: the data has not been modified since the timestamp
11
+ - Non-repudiation: the time has been confirmed by an independent party
12
+ - Legal evidentiary value: relevant for liability and archiving
13
+
14
+ Difference from HMAC:
15
+ HMAC: shared key between two parties → internal use
16
+ Timestamping: independent third party → external proof, legally enforceable
17
+
18
+ Use at Hydrographic Offices:
19
+ - Long-term archiving of bathymetric datasets
20
+ - Legal proof of the state of a dataset at a specific point in time
21
+ - Demonstrating when a survey was completed
22
+
23
+ Usage:
24
+ # Compute timestamp (online via FreeTSA)
25
+ openssl ts -reply -in file.tsr -text
26
+ openssl ts -verify -in file.tsr -data file.txt -CAfile tsa_cert/cacert.pem
27
+
28
+ # Verify a timestamp
29
+ python timestamping.py --verify dataset.tif --timestamp dataset.tif.tsr
30
+
31
+ """
32
+
33
+ from rfc3161_client import (
34
+ TimestampRequestBuilder,
35
+ decode_timestamp_response,
36
+ VerifierBuilder,
37
+ VerificationError,
38
+ )
39
+ import secrets
40
+ import requests
41
+ from cryptography import x509
42
+ from cryptography.hazmat.primitives import hashes
43
+ import certifi
44
+ import os
45
+ import subprocess
46
+ from pathlib import Path
47
+ import urllib.request
48
+
49
+ TSA_EINDPUNTEN = {
50
+ "freetsa": "https://freetsa.org/tsr",
51
+ "digicert": "https://timestamp.digicert.com",
52
+ "globalsign": "http://timestamp.globalsign.com/tsa/r45standard",
53
+ }
54
+ CACERT_URL = "https://freetsa.org/files/cacert.pem"
55
+
56
+
57
+ class Timestamping:
58
+
59
+ def __init__(self, **kwargs):
60
+ """
61
+ Parameters
62
+ ----------
63
+ file : str or Path
64
+ File used for timestamping
65
+ """
66
+ if "file" not in kwargs:
67
+ raise TypeError("Missing required keyword argument: 'file'")
68
+
69
+ self._file = Path(kwargs["file"])
70
+ if not self._file.is_file():
71
+ raise FileNotFoundError(f"File not found for Timestamping: {self._file}")
72
+
73
+ def label(self):
74
+ return f"timestamping"
75
+
76
+ # def _house_holding(self, file=""):
77
+ # path = f"{file}.tsr"
78
+ # if os.path.exists(path):
79
+ # os.remove(path)
80
+
81
+ # def verify(self, url="output.xyz", integrity=""):
82
+ # """
83
+ # Clones this functionality
84
+ # openssl ts -verify -in file.tsr -data file.txt -CAfile tsa_cert/cacert.pem
85
+ # """
86
+ # ca_path = "data/tsa_cert/cacert.pem"
87
+ # self._ensure_file(ca_path)
88
+ # data_path = self._file
89
+ # try:
90
+ # with open(url, "rb") as f:
91
+ # tsr_bytes = f.read()
92
+
93
+ # tsr_object = decode_timestamp_response(tsr_bytes)
94
+
95
+ # with open(data_path, "rb") as f:
96
+ # message_data = f.read()
97
+
98
+ # with open(ca_path, "rb") as f:
99
+ # ca_certs = x509.load_pem_x509_certificates(f.read())
100
+
101
+ # builder = VerifierBuilder()
102
+ # for cert in ca_certs:
103
+ # builder.add_root_certificate(cert)
104
+
105
+ # verifier = builder.build()
106
+
107
+ # verifier.verify_message(tsr_object, message_data)
108
+
109
+ # print(" Verification: OK")
110
+ # return True
111
+
112
+ # except VerificationError as ve:
113
+ # print(f" Verification FAILED: Cryptografische fout of mismatch: {ve}")
114
+ # return False
115
+ # except Exception as e:
116
+ # print(f" Fout bij het uitvoeren van de verificatie: {e}")
117
+ # return False
118
+
119
+ def create_integrity(self, **kwargs):
120
+ """
121
+ Parameters
122
+ ----------
123
+ tsr_name : str
124
+ Name given when timestamp is done and a timestamp is made
125
+ """
126
+ if "tsr_name" not in kwargs:
127
+ raise TypeError("Missing required keyword argument: 'tsr_name'")
128
+
129
+ tsr_name = kwargs["tsr_name"]
130
+ tsa_name = "freetsa"
131
+ tsa_url = TSA_EINDPUNTEN.get(tsa_name)
132
+
133
+ with open(self._file, "rb") as f:
134
+ message = f.read()
135
+
136
+ # Verzoek bouwen
137
+ request = TimestampRequestBuilder().data(message).nonce().build()
138
+ with open(f"{tsr_name}.tsq", "wb") as f:
139
+ f.write(request.as_bytes())
140
+ # print(request.nonce)
141
+ # Sturen naar TSA
142
+ response = requests.post(
143
+ tsa_url,
144
+ data=request.as_bytes(),
145
+ headers={"Content-Type": "application/timestamp-query"},
146
+ )
147
+ timestamp_response = decode_timestamp_response(response.content)
148
+ tsr = timestamp_response.as_bytes()
149
+ with open(f"{tsr_name}.tsr", "wb") as f:
150
+ f.write(tsr)
151
+
152
+ return f"{tsr_name}.tsr"
153
+
154
+ def _ensure_file(self, path, url=CACERT_URL):
155
+ """Download `url` to `path` if the file does not exist yet."""
156
+ if os.path.exists(path):
157
+ return path
158
+
159
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
160
+
161
+ tmp_path = f"{path}.part"
162
+ try:
163
+ with urllib.request.urlopen(url, timeout=30) as response, open(
164
+ tmp_path, "wb"
165
+ ) as out:
166
+ out.write(response.read())
167
+ os.replace(
168
+ tmp_path, path
169
+ ) # atomair: nooit een half bestand onder de echte naam
170
+ except Exception:
171
+ if os.path.exists(tmp_path):
172
+ os.remove(tmp_path)
173
+ raise
174
+ return path
@@ -0,0 +1,55 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from technique_factory import TechniqueFactory, TechniqueType
5
+
6
+
7
+ def parse_tech_args(item):
8
+ if "=" not in item:
9
+ raise argparse.ArgumentTypeError(f"Expected KEY=VALUE, given: {item}")
10
+ key, value = item.split("=", 1)
11
+ return key.strip(), value.strip()
12
+
13
+
14
+ def main():
15
+ parser = argparse.ArgumentParser(
16
+ description="Raster data verification",
17
+ formatter_class=argparse.RawTextHelpFormatter,
18
+ )
19
+ parser.add_argument(
20
+ "--source",
21
+ "-s",
22
+ required=True,
23
+ help="Hash key or Timestamp checking the intregrity",
24
+ )
25
+ parser.add_argument(
26
+ "--arg",
27
+ "-a",
28
+ action="append",
29
+ type=parse_tech_args,
30
+ metavar="KEY=VALUE",
31
+ help="Extra argument for the technique, repeatable (e.g. -a precision=2)",
32
+ )
33
+ parser.add_argument(
34
+ "--technique",
35
+ "-t",
36
+ default="fh",
37
+ required=True,
38
+ help=f"Used technique for verification: \n {TechniqueFactory.help_text()}",
39
+ )
40
+
41
+ args = parser.parse_args()
42
+
43
+ if not os.path.exists(args.source):
44
+ print(f"ERROR: File not found: {args.source}")
45
+ sys.exit(1)
46
+ try:
47
+ techn = TechniqueFactory.create(args.technique, **dict(args.arg or []))
48
+ print(f"Verification result:{techn.verify(args.source)}")
49
+ except ValueError as e:
50
+ print(f"FOUT: {e}")
51
+ sys.exit(1)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: raster_integrity
3
+ Version: 0.0.2
4
+ Summary: Technical methods for checking the integrity of raster data
5
+ Author-email: Dedulle Wim <wim.dedulle@proton.me>
6
+ License: MIT License
7
+ Project-URL: Homepage, https://github.com/wdedulle/raster_integrity
8
+ Keywords: raster_integrity
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Natural Language :: English
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ License-File: AUTHORS.rst
21
+ Requires-Dist: bagit
22
+ Requires-Dist: cryptography
23
+ Requires-Dist: numpy
24
+ Requires-Dist: pymerkle
25
+ Requires-Dist: rfc3161_client
26
+ Provides-Extra: all
27
+ Requires-Dist: raster_integrity[extra]; extra == "all"
28
+ Provides-Extra: extra
29
+ Requires-Dist: pandas; extra == "extra"
30
+ Dynamic: license-file
31
+
32
+ # raster_integrity
33
+
34
+
35
+ [![image](https://img.shields.io/pypi/v/raster_integrity.svg)](https://pypi.python.org/pypi/raster_integrity)
36
+ [![image](https://img.shields.io/conda/vn/conda-forge/raster_integrity.svg)](https://anaconda.org/conda-forge/raster_integrity)
37
+
38
+ [![image](https://pyup.io/repos/github/wdedulle/raster_integrity/shield.svg)](https://pyup.io/repos/github/wdedulle/raster_integrity)
39
+
40
+
41
+ **Technical methods for checking the integrity of raster data**
42
+
43
+
44
+ - Free software: MIT License
45
+ - Documentation: https://wdedulle.github.io/raster_integrity
46
+
47
+
48
+ ## Features
49
+
50
+ - Setting up metadata for raster files: it includes common and custom raster information.
51
+ ```shell
52
+ python .\raster_integrity\setup.py -f .\data\classified.xyz -m "used_for"="classification for AI"
53
+ ```
54
+
55
+ - Creating integrity hash
56
+ ```shell
57
+ python .\raster_integrity\integrity.py -i .\data\classified.jpg -t me
58
+ 8eb3bdddc021b045622f9811ff925d132ad6274f0bba6546dcbf6014dac19bdf
59
+ ```
60
+ - Archive all artefacts with its integrity hash
61
+ ```shell
62
+ python .\raster_integrity\integrity.py -i .\data\classified.jpg -t ba -a root_hash=8eb3bdddc021b045622f9811ff925d132ad6274f0bba6546dcbf6014dac19bdf
63
+ .\data\classified_bagit
64
+ ```
65
+ - Seal the tagmanifest with timestamping
66
+ ```shell
67
+ python .\raster_integrity\integrity.py -i .\data\classified_bagit\tagmanifest-sha256.txt -t ts -a tsr_name=.\data\tagmanifest_cassified_jpg
68
+ .\data\tagmanifest_cassified_jpg.tsr
69
+ ```
70
+
71
+ - Verification - To Be
72
+ ```shell
73
+ python .\raster_integrity\verify.py -a file=.\data\classified_bagit\tagmanifest-sha256.txt -t ts -s .\data\classified.jpg.tsr
74
+ python .\raster_integrity\verify.py -a root_hash=8eb3bdddc021b045622f9811ff925d132ad6274f0bba6546dcbf6014dac19bdf -t me -s .\data\classified.jpg
75
+ ```
76
+
77
+ ## Required package
78
+ GDAL: Geospatial Data Abstraction Library version 3.13.3
79
+ ### Installation for Python 3.15
80
+ Wheel packages can be found at https://github.com/cgohlke/geospatial-wheels/releases
81
+
82
+ This a specific GDAL version 3.13.3 installation for Windows AMD64 with Python version 3.15.*
83
+ ```
84
+ pip install https://github.com/cgohlke/geospatial-wheels/releases/download/v2026.8.20/gdal-3.13.3-cp315-cp315-win_amd64.whl
85
+ ```
@@ -0,0 +1,18 @@
1
+ raster_integrity/__init__.py,sha256=QvlVh4JTl3JL7jQAja76yKtT-IvF4631ASjWY1wS6AQ,22
2
+ raster_integrity/integrity.py,sha256=jARfjBikXtqtvDiKM-fdy4aS2nLckSjGiLhkhA-LUKs,1282
3
+ raster_integrity/setup.py,sha256=ZS68cjz3ptqr7LRpNFHUsUYwid_eVkQv3-Iom5ulp6o,3720
4
+ raster_integrity/technique_factory.py,sha256=QVySneWWYlSRpWaH74tefh9V7ZIInQrgNFlX63GgPfY,3137
5
+ raster_integrity/verify.py,sha256=u4nDoyhRK7s9wpQj0lGuE8ydLUwz8rAt7ZLXBPXnu_0,1461
6
+ raster_integrity/techniques/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ raster_integrity/techniques/archiving.py,sha256=V2WZ58zWuA53Iw5vIHBing4fAasgTsbmtDpHM78-XmY,3355
8
+ raster_integrity/techniques/file_hash.py,sha256=Z4fPRKBk933ZbYWufC1sOaU7WURsqdUYkDSdRpv7cTo,2506
9
+ raster_integrity/techniques/merkle_tree_hash.py,sha256=NRitljjMpxECYPJs2pxaSg31QqSNj8gxwvjJh_F9LUI,9405
10
+ raster_integrity/techniques/technique.py,sha256=qu761tiiPq-w_Fq8yXDRIIBrFo7mTbDqna_MjayFzu4,445
11
+ raster_integrity/techniques/timestamping.py,sha256=OWz2gA-u1XA8tXQuYeKjkiHfnauvdJ799fmBUwgFwW0,5522
12
+ raster_integrity-0.0.2.dist-info/licenses/AUTHORS.rst,sha256=GGH30hXv6Zfjg7QDp6TkeVbAnuCksKxBgb2kqXnfBtw,158
13
+ raster_integrity-0.0.2.dist-info/licenses/LICENSE,sha256=MRMIkJs8bKcYa_8_GHofsYEfcCVb_0I8y2kUQyeI2_E,1070
14
+ raster_integrity-0.0.2.dist-info/METADATA,sha256=o3HIQmZJkkCaEw5KIXedCdAaU1wxqQ8_K_lKWWXqgpk,3230
15
+ raster_integrity-0.0.2.dist-info/WHEEL,sha256=Uvlg2aNlEKdilsyFSJt1QX5me0eEbCjQzR3nZeFkvy4,109
16
+ raster_integrity-0.0.2.dist-info/entry_points.txt,sha256=fAMaL9chR--gr6a4LHdx2M-pPYUrGRbG7CyTPmj89as,63
17
+ raster_integrity-0.0.2.dist-info/top_level.txt,sha256=D9igPYFLAm8QOh9rTevgj95GiGzQq7BzS9EMqmAxSTA,17
18
+ raster_integrity-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
6
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ raster_integrity = raster_integrity.cli:main
@@ -0,0 +1,13 @@
1
+ =======
2
+ Credits
3
+ =======
4
+
5
+ Development Lead
6
+ ----------------
7
+
8
+ * Dedulle Wim <wim.dedulle@proton.me>
9
+
10
+ Contributors
11
+ ------------
12
+
13
+ None yet. Why not be the first?
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026, Dedulle Wim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1 @@
1
+ raster_integrity