ib1-provenance 0.1__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.
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Icebreaker One
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
13
+ all 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
21
+ THE SOFTWARE.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.1
2
+ Name: ib1-provenance
3
+ Version: 0.1
4
+ Summary: Tools to encode, decode, and sign provenance data
5
+ Author: Ben Summers
6
+ Author-email: ben.summers@icebreakerone.org
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Dist: asn1crypto (>=1.5.1,<2.0.0)
13
+ Requires-Dist: cryptography (>=43.0.1,<44.0.0)
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Provenance
17
+
18
+ ```
19
+ python3 -m venv .venv
20
+ . .venv/bin/activate
21
+ pip install -r requirements.txt
22
+
23
+ pipx install poetry
24
+ poetry install
25
+
26
+ mkdir certs
27
+ cd certs
28
+ sh ../scripts/certmaker.sh
29
+
30
+ python3 main.py
31
+ ```
32
+
33
+ ## Provenance record decoder
34
+
35
+ ```
36
+ python3 decode-self-contained-provenance.py path/to/signing-CA-root.pem < record.json
37
+ ```
38
+
@@ -0,0 +1,22 @@
1
+ # Provenance
2
+
3
+ ```
4
+ python3 -m venv .venv
5
+ . .venv/bin/activate
6
+ pip install -r requirements.txt
7
+
8
+ pipx install poetry
9
+ poetry install
10
+
11
+ mkdir certs
12
+ cd certs
13
+ sh ../scripts/certmaker.sh
14
+
15
+ python3 main.py
16
+ ```
17
+
18
+ ## Provenance record decoder
19
+
20
+ ```
21
+ python3 decode-self-contained-provenance.py path/to/signing-CA-root.pem < record.json
22
+ ```
@@ -0,0 +1,25 @@
1
+ [tool.poetry]
2
+ name = "ib1-provenance"
3
+ version = "0.1"
4
+ description = "Tools to encode, decode, and sign provenance data"
5
+ authors = ["Ben Summers <ben.summers@icebreakerone.org>"]
6
+ readme = "README.md"
7
+ packages = [{include = "ib1", from = "src"}]
8
+
9
+
10
+ [tool.poetry.dependencies]
11
+ python = "^3.10"
12
+ cryptography = "^43.0.1"
13
+ asn1crypto = "^1.5.1"
14
+
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ pytest = "^8.3.3"
18
+ mypy = "^1.12.0"
19
+ ruff = "^0.6.9"
20
+ pytest-cov = "^5.0.0"
21
+ tox = "^4.23.0"
22
+
23
+ [build-system]
24
+ requires = ["poetry-core"]
25
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,2 @@
1
+
2
+ from .record import Record
@@ -0,0 +1,107 @@
1
+
2
+ import json
3
+ import base64
4
+ import datetime
5
+
6
+ from cryptography import x509
7
+ from cryptography.x509.oid import NameOID, ExtensionOID
8
+ from cryptography.x509.verification import PolicyBuilder, Store
9
+ from cryptography.hazmat.primitives import hashes
10
+ from cryptography.hazmat.primitives.asymmetric import ec
11
+ import asn1crypto.core as asn1
12
+
13
+ # ---------------------------------------------------------------------------
14
+
15
+ OID_IB1_ROLES = x509.ObjectIdentifier("1.3.6.1.4.1.62329.1.1")
16
+ OID_IB1_APPLICATION = x509.ObjectIdentifier("1.3.6.1.4.1.62329.1.2")
17
+
18
+ class CertExtUTF8Sequence(asn1.SequenceOf):
19
+ _child_spec = asn1.UTF8String
20
+
21
+ class SigningCertificate:
22
+ def __init__(self, x509_certificate: x509.Certificate):
23
+ self._x509_certificate = x509_certificate
24
+
25
+ def subject(self):
26
+ san_extension = self._x509_certificate.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
27
+ uri = san_extension.value.get_values_for_type(x509.UniformResourceIdentifier)
28
+ if len(uri) != 1:
29
+ raise Exception("Certificate doesn't contain exactly one URI subject alternative name")
30
+ return uri[0]
31
+
32
+ def organisation_name(self):
33
+ return self._x509_certificate.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value
34
+
35
+ def application(self):
36
+ value = self._x509_certificate.extensions.get_extension_for_oid(OID_IB1_APPLICATION).value.value # type: ignore [attr-defined]
37
+ return str(asn1.UTF8String.load(value))
38
+
39
+ def roles(self):
40
+ value = self._x509_certificate.extensions.get_extension_for_oid(OID_IB1_ROLES).value.value # type: ignore [attr-defined]
41
+ return list(map(str, CertExtUTF8Sequence.load(value)))
42
+
43
+ # ---------------------------------------------------------------------------
44
+
45
+ class CertificateProviderBase:
46
+ def __init__(self, root_ca_certificate):
47
+ with open(root_ca_certificate, "rb") as f:
48
+ self._ca_store = Store(x509.load_pem_x509_certificates(f.read()))
49
+
50
+ def policy_include_certificates_in_record(self):
51
+ return False
52
+
53
+ def _verify(self, certificates_from_record, serial, sign_timestamp, data, signature):
54
+ certs = self.certificates_for_serial(certificates_from_record, serial)
55
+ # first certificate in file is signing certificate
56
+ signing_cert, *issuer_chain = certs
57
+ # 1) check certificate chain validity at the time of signature
58
+ verification_time = datetime.datetime.fromisoformat(sign_timestamp)
59
+ verifier = (PolicyBuilder().
60
+ store(self._ca_store).
61
+ time(verification_time).
62
+ build_client_verifier())
63
+ verifier.verify(signing_cert, issuer_chain)
64
+ # 2) check signature on data
65
+ pubkey = signing_cert.public_key()
66
+ pubkey.verify(signature, data, ec.ECDSA(hashes.SHA256()))
67
+ # Return information about the signer
68
+ cert_info = SigningCertificate(signing_cert)
69
+ return {
70
+ "member": cert_info.subject(),
71
+ "name": cert_info.organisation_name(),
72
+ "application": cert_info.application(),
73
+ "roles": cert_info.roles()
74
+ }
75
+
76
+ # ---------------------------------------------------------------------------
77
+
78
+ class CertificatesProviderLocal(CertificateProviderBase):
79
+ def __init__(self, root_ca_certificate, directory):
80
+ CertificateProviderBase.__init__(self, root_ca_certificate)
81
+ self._directory = directory
82
+
83
+ def certificates_for_serial(self, certificates_from_record, serial):
84
+ certificate_filename = self._directory + '/' + str(int(serial)) + '-bundle.pem'
85
+ with open(certificate_filename, "rb") as f:
86
+ return x509.load_pem_x509_certificates(f.read())
87
+
88
+ # ---------------------------------------------------------------------------
89
+
90
+ class CertificatesProviderSelfContainedRecord(CertificateProviderBase):
91
+ def policy_include_certificates_in_record(self):
92
+ return True
93
+
94
+ def certificates_for_serial(self, certificates_from_record, serial):
95
+ certs = certificates_from_record.get(serial)
96
+ if certs is None:
97
+ raise Exception("Certificate serial "+serial+" is not present in record")
98
+ signing_cert, *path_serials = certs
99
+ cert_chain = [signing_cert]
100
+ cert_chain.extend(list(map(
101
+ lambda s: certificates_from_record[s][0],
102
+ path_serials
103
+ )))
104
+ return list(map(
105
+ lambda c: x509.load_pem_x509_certificate(c.encode("utf-8")),
106
+ cert_chain
107
+ ))
@@ -0,0 +1,5 @@
1
+
2
+ import secrets
3
+
4
+ def globally_unique_step_identifier():
5
+ return secrets.token_urlsafe(15)
@@ -0,0 +1,290 @@
1
+ import base64
2
+ import json
3
+ import copy
4
+ import datetime
5
+
6
+ from cryptography.hazmat.primitives import serialization
7
+
8
+ from .identifier import globally_unique_step_identifier
9
+
10
+
11
+ CURRENT_CONTAINER_FORMAT_VERSION = 0
12
+
13
+
14
+ class Record:
15
+
16
+ # data attributes:
17
+ # _record -- signed record as output from JSON decoding
18
+ # _additional_records -- array of signed records to add to the record
19
+ # _additional_steps -- array of steps to add to the record
20
+ # _signed -- whether the record is completely signed
21
+ # _verified -- whether the record has had signatures verified
22
+
23
+ def __init__(self, trust_framework, record=None):
24
+ if record is not None:
25
+ if not isinstance(record.get("steps"), list):
26
+ raise Exception("Not an encoded Provenance record")
27
+ if trust_framework != record["ib1:provenance"]:
28
+ raise Exception("Unexpected trust framework when creating Record from encoded form")
29
+ self.trust_framework = trust_framework
30
+ self._record = record
31
+ self._additional_records = []
32
+ self._additional_steps = []
33
+ self._signed = True
34
+ self._verified = None
35
+
36
+ def verify(self, certificate_provider):
37
+ self._require_signed()
38
+ # Recursively verify record
39
+ steps = []
40
+ origins = []
41
+ signer_stack = []
42
+ certificates_from_record = self._record.get("certificates")
43
+ if certificates_from_record is None:
44
+ certificates_from_record = {}
45
+ self._verify_record_container(self._record["steps"], certificates_from_record, certificate_provider, steps, origins, signer_stack)
46
+ if self._record["origins"] != origins:
47
+ raise Exception("origins property does not match origin steps in record")
48
+ self._verified = steps
49
+
50
+ def _verify_record_container(self, container, certificates_from_record, certificate_provider, steps, origins, signer_stack):
51
+ *data, sig_block = container
52
+ container_format_version, serial, sign_timestamp, signature = sig_block
53
+ # Check it's an understood format (multiple versions of formats may be included in a single record)
54
+ if container_format_version != CURRENT_CONTAINER_FORMAT_VERSION:
55
+ raise Exception("Cannot decode container format version: "+str(container_format_version))
56
+ # Serial number must only be a number
57
+ if str(int(serial)) != serial:
58
+ raise Exception("Bad certificate serial number in record: "+serial)
59
+ # Check signatures at this level and get signer information
60
+ data_for_signing = self._data_for_signing(data, [str(container_format_version), serial, sign_timestamp])
61
+ signer_info = certificate_provider._verify(certificates_from_record, serial, sign_timestamp, data_for_signing.encode("utf-8"), base64.urlsafe_b64decode(signature))
62
+ # Recurse into signed data, collecting decoded steps and adding signer info
63
+ for e in data:
64
+ if not isinstance(e, str):
65
+ signer_stack.append(signer_info)
66
+ self._verify_record_container(e, certificates_from_record, certificate_provider, steps, origins, signer_stack)
67
+ del signer_stack[-1]
68
+ else:
69
+ decoded_step = json.loads(base64.urlsafe_b64decode(e))
70
+ if decoded_step["type"] == "origin":
71
+ origins.append(decoded_step["id"])
72
+ decoded_step["_signature"] = {
73
+ "signed": signer_info,
74
+ "includedBy": copy.copy(signer_stack)
75
+ }
76
+ steps.append(decoded_step)
77
+
78
+ def add_record(self, record):
79
+ self._signed = False
80
+ self._verified = None
81
+ if not isinstance(record, Record):
82
+ raise Exception("Not a Record object")
83
+ if self.trust_framework != record.trust_framework:
84
+ raise Exception("Incompatible trust frameworks in added Record")
85
+ self._additional_records.append(record.encoded())
86
+
87
+ def add_step(self, step_in):
88
+ self._signed = False
89
+ self._verified = None
90
+ step = copy.deepcopy(step_in)
91
+ # Step identifier
92
+ if "id" in step:
93
+ raise Exception("Step may not contain an id key. Identifiers are allocated automatically and returned by this function.")
94
+ id = globally_unique_step_identifier()
95
+ # Step timestamp
96
+ # TODO: Verify timestamp is in the right format (but signing cert does not need to be valid at that time?)
97
+ timestamp = step.pop("timestamp", None)
98
+ if not timestamp:
99
+ timestamp = self._timestamp_now_iso8601()
100
+ # Step type
101
+ step_type = step.pop("type")
102
+ # No keys with _ prefix allowed
103
+ prohibited_keys_present = list(filter(
104
+ lambda k: k.startswith('_'),
105
+ step.keys()
106
+ ))
107
+ if prohibited_keys_present:
108
+ raise Exception("Step may not contain keys beginning with an underscore. Prohibited keys present: "+(", ".join(prohibited_keys_present)))
109
+ # Add to list of steps pending addition
110
+ self._additional_steps.append({
111
+ "id": id,
112
+ "timestamp": timestamp,
113
+ "type": step_type,
114
+ **step
115
+ })
116
+ return id
117
+
118
+ def find_step(self, required_values):
119
+ steps = self.filter_steps(required_values)
120
+ if len(steps) == 0:
121
+ raise Exception("No step matches required values")
122
+ if len(steps) > 1:
123
+ raise Exception("More than one step matches required values")
124
+ return steps[0]
125
+
126
+ def filter_steps(self, required_values):
127
+ self._require_verified()
128
+ return list(filter(
129
+ lambda s: self._filter_step_contains(s, required_values),
130
+ self._verified
131
+ ))
132
+
133
+ def _filter_step_contains(self, step, required_values):
134
+ if isinstance(required_values, dict):
135
+ # If required_values is a dict, ensure step is a dict and contains all keys/values of required_values
136
+ if not isinstance(step, dict):
137
+ return False
138
+ for key, value in required_values.items():
139
+ if key not in step or not self._filter_step_contains(step[key], value):
140
+ return False
141
+ return True
142
+ elif isinstance(required_values, list):
143
+ # If required_values is a list, ensure step is a list and contains all elements of required_values
144
+ if not isinstance(step, list):
145
+ return False
146
+ for item in required_values:
147
+ if not any(self._filter_step_contains(x, item) for x in step):
148
+ return False
149
+ return True
150
+ else:
151
+ # If required_values is a primitive, ensure it matches the value in step
152
+ return step == required_values
153
+
154
+ def sign(self, signer):
155
+ output = []
156
+ certificates = {}
157
+ if self._record is not None:
158
+ if "certificates" in self._record:
159
+ certificates.update(self._record["certificates"])
160
+ output.append(self._record["steps"]) # signed and encoded
161
+ for r in self._additional_records:
162
+ if "certificates" in r:
163
+ certificates.update(r["certificates"])
164
+ output.append(r["steps"]) # signed and encoded
165
+ for s in self._additional_steps:
166
+ output.append(self._encode_step(s)) # unencoded, not signed
167
+ serial = signer.serial()
168
+ sign_timestamp = self._timestamp_now_iso8601()
169
+ data_for_signing = self._data_for_signing(output, [str(CURRENT_CONTAINER_FORMAT_VERSION), serial, sign_timestamp])
170
+ signature = signer.sign(data_for_signing.encode("utf-8"))
171
+ output.append([
172
+ CURRENT_CONTAINER_FORMAT_VERSION,
173
+ serial,
174
+ sign_timestamp,
175
+ base64.urlsafe_b64encode(signature).decode('utf-8')
176
+ ])
177
+ if not serial in certificates:
178
+ certs_for_record = signer.certificates_for_record()
179
+ if certs_for_record is not None:
180
+ # Represent path as [pem encoded cert, serials of issuer chain ...]
181
+ first_cert, *other_certs = certs_for_record
182
+ cert_path = [first_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")]
183
+ cert_path.extend(list(map(
184
+ lambda c: str(c.serial_number), other_certs
185
+ )))
186
+ certificates[serial] = cert_path
187
+ for c in other_certs:
188
+ certificates[str(c.serial_number)] = [c.public_bytes(serialization.Encoding.PEM).decode("utf-8")]
189
+ origins = []
190
+ self._gather_origins(output, origins)
191
+ encoded = {
192
+ "ib1:provenance": self.trust_framework,
193
+ "origins": origins,
194
+ "steps": copy.deepcopy(output)
195
+ }
196
+ if certificates:
197
+ encoded["certificates"] = certificates
198
+ return Record(self.trust_framework, encoded)
199
+
200
+ def _gather_origins(self, container, origins):
201
+ *data, sig_block = container
202
+ for e in data:
203
+ if not isinstance(e, str):
204
+ self._gather_origins(e, origins)
205
+ else:
206
+ decoded_step = json.loads(base64.urlsafe_b64decode(e))
207
+ if decoded_step["type"] == "origin":
208
+ origins.append(decoded_step["id"])
209
+
210
+ def _encode_step(self, step):
211
+ return base64.urlsafe_b64encode(json.dumps(step, separators=(",", ":")).encode("utf-8")).decode('utf-8')
212
+
213
+ def _data_for_signing(self, data, additional=None, is_root=True):
214
+ gather = []
215
+ if is_root:
216
+ gather.append(self.trust_framework)
217
+ for e in data:
218
+ if isinstance(e, str):
219
+ gather.append(e)
220
+ elif isinstance(e, int):
221
+ gather.append(str(e))
222
+ else:
223
+ gather.append("%")
224
+ gather.append(self._data_for_signing(e, None, False))
225
+ gather.append("&")
226
+ if additional is not None:
227
+ gather.extend(additional)
228
+ return ".".join(gather)
229
+
230
+ def encoded(self): # TODO name
231
+ self._require_signed()
232
+ return self._record
233
+
234
+ def decoded(self): # TODO name
235
+ self._require_verified()
236
+ return copy.deepcopy(self._verified)
237
+
238
+ def _require_signed(self):
239
+ if not self._signed:
240
+ raise Exception("Record is not signed, call sign() and use returned object")
241
+
242
+ def _require_verified(self):
243
+ if not (self._signed and (self._verified != None)):
244
+ raise Exception("Record is not verified, call verify() first")
245
+
246
+ def _timestamp_now_iso8601(self):
247
+ return (datetime.datetime.now(datetime.UTC).
248
+ replace(microsecond=0).
249
+ isoformat().
250
+ replace("+00:00", "Z")
251
+ )
252
+
253
+ def to_graphviz(self):
254
+ self._require_verified()
255
+ signers = {}
256
+ dot = ["digraph ProvenanceRecord {"]
257
+ for s in self._verified:
258
+ id = s["id"]
259
+ shape = "box"
260
+ match s["type"]:
261
+ case "permission":
262
+ shape = "invhouse"
263
+ case "origin":
264
+ shape = "diamond"
265
+ case "transfer":
266
+ dot.append(" \""+s["of"]+"\" -> \""+id+"\" [label=of];")
267
+ case "receipt":
268
+ shape = "box3d"
269
+ dot.append(" \""+s["transfer"]+"\" -> \""+id+"\" [label=transfer];")
270
+ case "process":
271
+ shape = "parallelogram"
272
+ for key in ["inputs", "permissions"]:
273
+ for i in s.get(key, []):
274
+ dot.append(" \""+i+"\" -> \""+id+"\" [label="+key+"];")
275
+ dot.append(" \""+id+"\" [shape="+shape+",label=\""+s["type"]+"\\n"+id+"\"];")
276
+ signer = s["_signature"]["signed"]["name"]
277
+ if signer not in signers:
278
+ signers[signer] = []
279
+ signers[signer].append(id)
280
+ index = 0
281
+ for signer in signers:
282
+ dot.append(" subgraph cluster_"+str(index)+" {")
283
+ index += 1
284
+ dot.append(" graph[style=dashed];")
285
+ dot.append(" label = \""+signer+"\";")
286
+ for i in signers[signer]:
287
+ dot.append(" \""+i+"\";")
288
+ dot.append(" }")
289
+ dot.append("}")
290
+ return "\n".join(dot)
@@ -0,0 +1,32 @@
1
+
2
+ from cryptography import x509
3
+ from cryptography.hazmat.primitives import serialization
4
+ from cryptography.hazmat.primitives import hashes
5
+ from cryptography.hazmat.primitives.asymmetric import ec
6
+
7
+
8
+ class SignerInMemory:
9
+ def __init__(self, certificate_provider, certificates, private_key):
10
+ self._certificate_provider = certificate_provider
11
+ self._certificates = certificates
12
+ self._private_key = private_key
13
+
14
+ def serial(self):
15
+ return str(self._certificates[0].serial_number) # String, as JSON rounds large integers
16
+
17
+ def certificates_for_record(self):
18
+ if not self._certificate_provider.policy_include_certificates_in_record():
19
+ return None
20
+ return self._certificates.copy();
21
+
22
+ def sign(self, data):
23
+ # TODO: Use correct algorithm for type of key in certificate, assuming EC crypto
24
+ return self._private_key.sign(data, ec.ECDSA(hashes.SHA256()))
25
+
26
+ class SignerFiles(SignerInMemory):
27
+ def __init__(self, certificate_provider, certificate_file, key_file):
28
+ with open(certificate_file, "rb") as certs:
29
+ certificates = x509.load_pem_x509_certificates(certs.read())
30
+ with open(key_file, "rb") as key:
31
+ private_key = serialization.load_pem_private_key(key.read(), password=None)
32
+ super().__init__(certificate_provider, certificates, private_key)