cmem-plugin-graphsigning 0.5.0__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,835 @@
1
+ """Create Graph Signature Task"""
2
+
3
+ import base64
4
+ import re
5
+ import tempfile
6
+ from collections import OrderedDict
7
+ from collections.abc import Callable, Sequence
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ import rdflib
14
+ from cmem_client.client import Client
15
+ from cmem_client.repositories.protocols.import_item import ImportConflictPolicy
16
+ from cmem_plugin_base.dataintegration.client import get_client
17
+ from cmem_plugin_base.dataintegration.context import ExecutionContext, ExecutionReport
18
+ from cmem_plugin_base.dataintegration.description import (
19
+ Icon,
20
+ Plugin,
21
+ PluginAction,
22
+ PluginParameter,
23
+ )
24
+ from cmem_plugin_base.dataintegration.entity import (
25
+ Entities,
26
+ Entity,
27
+ EntitySchema,
28
+ )
29
+ from cmem_plugin_base.dataintegration.parameter.choice import ChoiceParameterType
30
+ from cmem_plugin_base.dataintegration.parameter.graph import GraphParameterType
31
+ from cmem_plugin_base.dataintegration.parameter.password import Password, PasswordParameterType
32
+ from cmem_plugin_base.dataintegration.plugins import WorkflowPlugin
33
+ from cmem_plugin_base.dataintegration.ports import (
34
+ FixedNumberOfInputs,
35
+ FixedSchemaPort,
36
+ )
37
+ from cmem_plugin_base.dataintegration.typed_entities.file import FileEntitySchema, LocalFile
38
+ from cryptography.hazmat.primitives import serialization
39
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
40
+ from cryptography.hazmat.primitives.asymmetric.rsa import generate_private_key
41
+ from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes, PublicKeyTypes
42
+ from rdflib import RDF, URIRef
43
+
44
+ from cmem_plugin_graphsigning.graph_proof_utils import (
45
+ ProofMetadata,
46
+ build_digest_record,
47
+ build_proof_rdf,
48
+ canonical_digest,
49
+ multibase_decode,
50
+ new_proof_uri,
51
+ new_rdf_graph,
52
+ proof_graph_iri,
53
+ proof_payload,
54
+ proofs_by_other_keys,
55
+ signature_options,
56
+ )
57
+ from cmem_plugin_graphsigning.graph_store import (
58
+ download_and_parse,
59
+ download_and_parse_proof_graph,
60
+ graph_iri_input_schema,
61
+ hash_parsed_graph,
62
+ )
63
+ from cmem_plugin_graphsigning.signing import (
64
+ MINIMUM_RSA_KEY_SIZE,
65
+ check_signing_key_strength,
66
+ cryptosuite_for,
67
+ did_key_controller,
68
+ is_multikey,
69
+ public_key_from_did_key,
70
+ public_key_multibase,
71
+ sign_digest,
72
+ verification_method_id,
73
+ verify_digest,
74
+ )
75
+ from cmem_plugin_graphsigning.vocabulary import SEC
76
+
77
+ OUTPUT_ONE_DOCUMENT = "one-document"
78
+ OUTPUT_DOCUMENT_PER_GRAPH = "document-per-graph"
79
+ OUTPUT_INTO_GRAPH = "into-graph"
80
+
81
+ OUTPUT_CHOICES = OrderedDict(
82
+ [
83
+ (
84
+ OUTPUT_ONE_DOCUMENT,
85
+ ("One JSON-LD document - every graph's signature in a single file"),
86
+ ),
87
+ (
88
+ OUTPUT_DOCUMENT_PER_GRAPH,
89
+ (
90
+ "One JSON-LD document per graph - each signature its own file, to keep "
91
+ "or send on separately"
92
+ ),
93
+ ),
94
+ (
95
+ OUTPUT_INTO_GRAPH,
96
+ (
97
+ "Into a graph of its own - a companion proof graph beside the source graph, "
98
+ "which is left untouched; the task hands nothing on"
99
+ ),
100
+ ),
101
+ ]
102
+ )
103
+
104
+ _PROOF_VALUE_PLACEHOLDER = "cGxhY2Vob2xkZXI="
105
+ """Stands in for the signature while the triples it will cover are assembled."""
106
+
107
+ GENERATED_RSA_KEY_SIZE = 3072
108
+ """Modulus, in bits, of the RSA keys the *Create RSA Key Pair* action issues.
109
+
110
+ The shortest widely implemented length at or above ``signing.MINIMUM_RSA_KEY_SIZE``,
111
+ so a key this action hands out is one the signing path accepts. The length is not
112
+ configurable: an action that could issue a key the task then refuses would be a worse
113
+ offer than one that issues a single good length.
114
+ """
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class ProofWrite:
119
+ """Everything one proof write needs about one graph."""
120
+
121
+ graph_iri: str
122
+ rdf_hash: str
123
+ private_key: PrivateKeyTypes
124
+ created: str
125
+
126
+
127
+ @Plugin(
128
+ label="Create Graph Signature",
129
+ plugin_id="cmem_plugin_graphsigning-CreateGraphSignature",
130
+ description=(
131
+ "Compute a canonical SHA-256 digest of Knowledge Graphs and optionally sign it "
132
+ "as a W3C Data Integrity proof."
133
+ ),
134
+ documentation="""
135
+ Compute a canonical SHA-256 digest (RDFC-1.0) of one or more Knowledge Graphs and, given a
136
+ private key, sign each digest as a W3C Data Integrity proof. Without a key the task only
137
+ hashes.
138
+
139
+ ## Ports
140
+
141
+ Graph IRIs arrive on the input port, as `graph_iri` values, and are processed in order. The
142
+ port is replaced by a parameter when a single graph is configured on the task itself. Keys
143
+ never arrive on a port.
144
+
145
+ What leaves depends on where the signature goes. Written to documents, it leaves the output
146
+ port as JSON-LD files, one for the whole run or one per graph, ready to be stored in a file
147
+ dataset beside the data. Written into a companion proof graph, it is already in the store, and
148
+ the task declares no output port at all. A run without a key hands on documents that carry the
149
+ digest alone, which still record what the content hashed to.
150
+
151
+ ## Where this task fits
152
+
153
+ Every shape this task produces is one **Verify Graph Signature** reads, and the two default to
154
+ the same one, so they wire together untouched. **Remove Graph Signature** takes the
155
+ signatures back off a graph, and its output feeds this task again to sign afresh.
156
+
157
+ ## What a proof says
158
+
159
+ A proof names the key that made it, states its purpose as `sec:assertionMethod`, and records
160
+ the algorithm in `sec:cryptosuite`. Its signature covers all of that together with the digest,
161
+ so no statement in a proof can be edited afterwards without invalidating it.
162
+
163
+ An Ed25519 key names itself through its `did:key`, derived from the key material, so it is
164
+ named identically in every graph it signs and nothing has to be configured; that identifier
165
+ contains the key, so an Ed25519 proof always discloses its public key.
166
+
167
+ Which key type to use is a choice between interoperability and a recommendation. Ed25519 is
168
+ recommended here on interoperability grounds: its keys and signatures are small, it has no
169
+ padding or hash settings to get wrong, and its proofs follow the registered
170
+ `eddsa-rdfc-2022` cryptosuite, so another conformant implementation checks exactly the bytes
171
+ this task signed. It is, however, recommended by no part of BSI TR-02102-1. RSA-PSS is, at a
172
+ modulus of 3000 bits or more, which is the floor this task enforces when it is configured -
173
+ but RSA proofs use this plugin's own `rsa-rdfc1-sha256-2026` cryptosuite, which only
174
+ **Verify Graph Signature** checks. So choose RSA where an organizational key has to be used,
175
+ or where TR-02102-1 has to be followed, and Ed25519 where a proof has to be verifiable by
176
+ tooling other than this plugin.
177
+
178
+ ## Caveats
179
+
180
+ - **The digest covers content, not the graph it came from.** The same triples hash the same
181
+ under any IRI, or loaded from a file. The link from a proof to its graph is not signed, so a
182
+ proof moved to another graph with identical content verifies there too.
183
+ - Writing into a companion proof graph replaces your own earlier signature and keeps those
184
+ made with other keys; taking those off is what **Remove Graph Signature** is for. A proof is
185
+ yours when it names your verification method or your key's `did:key`, or when your key
186
+ verifies it against the content as it is now. Re-signing unchanged content yields the same
187
+ digest and a new proof, since two signing acts are two proofs.
188
+ - A `sec:proof` resource in the companion proof graph that is not typed
189
+ `sec:DataIntegrityProof` is not recognized as a proof, and is dropped the next time any
190
+ signature is written into that graph.
191
+ - The companion proof graph is read, changed and replaced in one step per graph. Two runs
192
+ signing the same graph with different keys at the same time can lose one of the two
193
+ signatures.
194
+ - The source graph is never written to. Proof-shaped triples already sitting in it are not
195
+ recognized as a signature, and are hashed and signed as ordinary data.
196
+ - A proof naming its key through `did:key`, or embedding it, verifies on its own but does not
197
+ say who signed. Anyone able to write the companion proof graph can re-sign altered content
198
+ with their own key; a verifier has to check against a key it trusts.
199
+ - With one document for the whole run, a cancelled run writes no document at all rather than
200
+ one covering only the graphs done so far, which would read as a complete, passing set. With
201
+ one document per graph, the documents for the graphs done so far are handed on.
202
+ - A graph that does not exist fails the run. Canonicalization is capped at five seconds per
203
+ graph; a graph large or blank-node-heavy enough to exceed that fails the run with a timeout.
204
+ - A key that is neither Ed25519 nor RSA, an RSA key with a modulus under 3000 bits, a key the
205
+ passphrase does not open, and incomplete combinations - such as an RSA key with no
206
+ verification method - are refused when the task is configured, not when it runs.
207
+ - A signature document is this plugin's own format, not a W3C secured document: it holds the
208
+ proof apart from the data it covers. A standard verifier needs a secured document assembled
209
+ from the graph's data and this proof first.
210
+ """,
211
+ icon=Icon(file_name="create_graph_signature.svg", package=__package__),
212
+ actions=[
213
+ PluginAction(
214
+ name="create_key_pair",
215
+ label="Create Ed25519 Key Pair",
216
+ description=(
217
+ "Generate a new Ed25519 key pair and show it once, without storing it "
218
+ "anywhere. This is the recommended key type for interoperability, but it "
219
+ "is outside BSI TR-02102-1; where that recommendation has to be followed, "
220
+ "create an RSA key pair instead. Set Private Key Passphrase first to have "
221
+ "the generated private key encrypted with it."
222
+ ),
223
+ ),
224
+ PluginAction(
225
+ name="create_rsa_key_pair",
226
+ label="Create RSA Key Pair",
227
+ description=(
228
+ "Generate a new RSA key pair and show it once, without storing it anywhere. "
229
+ f"The key has a {GENERATED_RSA_KEY_SIZE} bit modulus, which is the shortest "
230
+ "this task signs with. Use this where an organizational RSA key is required, "
231
+ "or where BSI TR-02102-1 has to be followed. Set Private Key Passphrase "
232
+ "first to have the generated private key encrypted with it."
233
+ ),
234
+ ),
235
+ ],
236
+ parameters=[
237
+ PluginParameter(
238
+ name="input_graph",
239
+ label="Input Graph",
240
+ description=(
241
+ "The Knowledge Graph to hash. Leave empty to take graph IRIs from the input "
242
+ "port instead; setting this parameter removes that port."
243
+ ),
244
+ param_type=GraphParameterType(
245
+ show_di_graphs=False,
246
+ show_graphs_without_class=True,
247
+ show_system_graphs=True,
248
+ allow_only_autocompleted_values=False,
249
+ ),
250
+ default_value="",
251
+ ),
252
+ PluginParameter(
253
+ name="private_key",
254
+ label="Private Key",
255
+ description=(
256
+ "PEM-encoded Ed25519 or RSA private key that signs each digest. Leave "
257
+ "empty to hash without signing. The key type decides the algorithm, so "
258
+ "there is nothing else to choose. An RSA key needs a modulus of at least "
259
+ f"{MINIMUM_RSA_KEY_SIZE} bits."
260
+ ),
261
+ param_type=PasswordParameterType(),
262
+ default_value="",
263
+ ),
264
+ PluginParameter(
265
+ name="private_key_passphrase",
266
+ label="Private Key Passphrase",
267
+ description=(
268
+ "Passphrase of an encrypted private key. The key-pair actions encrypt the "
269
+ "private key they generate with it."
270
+ ),
271
+ param_type=PasswordParameterType(),
272
+ default_value="",
273
+ ),
274
+ PluginParameter(
275
+ name="output_shape",
276
+ label="Output",
277
+ description=(
278
+ "Where each signature goes. Writing into a graph of its own requires a "
279
+ "Private Key and removes the output port."
280
+ ),
281
+ param_type=ChoiceParameterType(OUTPUT_CHOICES),
282
+ default_value=OUTPUT_ONE_DOCUMENT,
283
+ ),
284
+ PluginParameter(
285
+ name="embed_public_key",
286
+ label="Embed Public Key",
287
+ description=(
288
+ "If enabled, embeds the signing key's public key in each proof as "
289
+ "sec:publicKeyMultibase, so the proof can be checked without a key supplied "
290
+ "separately. An Ed25519 proof needs no embedded key for that, since its "
291
+ "did:key already names the key. Requires a Private Key."
292
+ ),
293
+ default_value=False,
294
+ advanced=True,
295
+ ),
296
+ PluginParameter(
297
+ name="verification_method_iri",
298
+ label="Verification Method IRI",
299
+ description=(
300
+ "IRI naming the signing key, written as each proof's sec:verificationMethod. "
301
+ "Leave empty for an Ed25519 key, which is then named by its did:key; an RSA "
302
+ "key requires it."
303
+ ),
304
+ default_value="",
305
+ advanced=True,
306
+ ),
307
+ PluginParameter(
308
+ name="resolve_owl_imports",
309
+ label="Resolve owl:imports",
310
+ description=(
311
+ "If enabled, resolves owl:imports before hashing, so the digest and signature "
312
+ "cover every graph reachable through the source graph's owl:imports. Verify "
313
+ "Graph Signature has to use the same setting."
314
+ ),
315
+ default_value=False,
316
+ advanced=True,
317
+ ),
318
+ PluginParameter(
319
+ name="proof_validity_days",
320
+ label="Proof Validity (days)",
321
+ description=(
322
+ "Number of days after signing at which each proof expires, written as "
323
+ "sec:expiration. 0 writes proofs that never expire."
324
+ ),
325
+ default_value=0,
326
+ advanced=True,
327
+ ),
328
+ ],
329
+ )
330
+ class CreateGraphSignature(WorkflowPlugin):
331
+ """Compute a hash value from one or more Knowledge Graphs, with optional signing."""
332
+
333
+ # A plugin constructor takes one argument per PluginParameter, so its arity is
334
+ # fixed by the plugin's configuration surface, not by a style choice here.
335
+ def __init__( # noqa: PLR0913, PLR0917
336
+ self,
337
+ input_graph: str = "",
338
+ private_key: Password | str = "",
339
+ private_key_passphrase: Password | str = "",
340
+ output_shape: str = OUTPUT_ONE_DOCUMENT,
341
+ embed_public_key: bool = False,
342
+ verification_method_iri: str = "",
343
+ resolve_owl_imports: bool = False,
344
+ proof_validity_days: int = 0,
345
+ ) -> None:
346
+ # A Password parameter is always truthy - even when the field was left blank it
347
+ # arrives as Password(""), so whether a key was given can only be told from the
348
+ # decrypted value. Only the answer is kept; the key itself stays encrypted until
349
+ # execute() needs it.
350
+ self.signing = bool(self._resolve_secret(private_key))
351
+ if self.signing:
352
+ # Refuse a key this plugin will not sign with here, rather than at execute
353
+ # time: cryptosuite_for raises for anything but Ed25519 and RSA, and
354
+ # check_signing_key_strength for an RSA key below the policy minimum.
355
+ key = self._load_private_key(private_key, private_key_passphrase)
356
+ cryptosuite_for(key)
357
+ check_signing_key_strength(key)
358
+ if embed_public_key and not self.signing:
359
+ raise ValueError("'Embed Public Key' requires a private key to be provided.")
360
+ if output_shape == OUTPUT_INTO_GRAPH and not self.signing:
361
+ raise ValueError(
362
+ "Writing into a graph of its own requires a private key to be provided."
363
+ )
364
+ if proof_validity_days < 0:
365
+ raise ValueError("'Proof Validity (days)' must not be negative.")
366
+ self.proof_validity_days = proof_validity_days
367
+ self.input_graph = input_graph
368
+ self.private_key = private_key
369
+ self.embed_public_key = embed_public_key
370
+ if self.signing and not verification_method_iri:
371
+ # Every proof has to name its key. An Ed25519 key identifies itself: its
372
+ # did:key is derived from the key material, so it needs no IRI of its own.
373
+ # Nothing else can do that.
374
+ public_key = self._load_private_key(private_key, private_key_passphrase).public_key()
375
+ if not is_multikey(public_key):
376
+ raise ValueError(
377
+ "Signing with this key type requires a 'Verification Method IRI' to "
378
+ "be provided. Every proof names the key that made it, and only an "
379
+ "Ed25519 key can identify itself, through its did:key."
380
+ )
381
+ self.private_key_passphrase = private_key_passphrase
382
+ self.resolve_owl_imports = resolve_owl_imports
383
+ if output_shape not in OUTPUT_CHOICES:
384
+ raise ValueError(f"'Output' must be one of {', '.join(OUTPUT_CHOICES)}.")
385
+ self.output_shape = output_shape
386
+ self.verification_method_iri = verification_method_iri
387
+ if input_graph:
388
+ self.input_ports = FixedNumberOfInputs([])
389
+ else:
390
+ self.input_ports = FixedNumberOfInputs(
391
+ [FixedSchemaPort(schema=graph_iri_input_schema())]
392
+ )
393
+ self._documents_dir: Path | None = None
394
+ schema = self._output_entity_schema()
395
+ self.output_port = None if schema is None else FixedSchemaPort(schema=schema)
396
+
397
+ def _emit_for_graph(
398
+ self,
399
+ graph_iri: str,
400
+ rdf_hash: str,
401
+ proof_rdf: rdflib.Graph | None,
402
+ collected: rdflib.Graph,
403
+ ) -> Entity | None:
404
+ """Return the document one graph contributes to the output, or accumulate it.
405
+
406
+ Only the one-document-per-graph mode returns anything. The one-document-per-run
407
+ mode collects the graphs and writes them once the last of them has been signed,
408
+ and writing into a graph of its own hands nothing on.
409
+ """
410
+ if not self._writes_documents:
411
+ return None
412
+ signatures = proof_rdf if proof_rdf is not None else self._digest_only(graph_iri, rdf_hash)
413
+ if self.output_shape == OUTPUT_DOCUMENT_PER_GRAPH:
414
+ return FileEntitySchema().to_entity(self.signature_document(graph_iri, signatures))
415
+ for triple in signatures:
416
+ collected.add(triple)
417
+ return None
418
+
419
+ @property
420
+ def _writes_documents(self) -> bool:
421
+ """Whether this run hands signature documents to the next task."""
422
+ return self.output_shape in (OUTPUT_ONE_DOCUMENT, OUTPUT_DOCUMENT_PER_GRAPH)
423
+
424
+ @property
425
+ def _writes_into_graph(self) -> bool:
426
+ """Whether this run puts the signature into the source graph."""
427
+ return self.output_shape == OUTPUT_INTO_GRAPH
428
+
429
+ def operation_description(self) -> str:
430
+ """Describe what this run does to the graphs it processes, for the report."""
431
+ if not self.signing:
432
+ return "graphs hashed"
433
+ if self._writes_documents:
434
+ return "signature documents written"
435
+ return "graphs signed"
436
+
437
+ def _output_entity_schema(self) -> EntitySchema | None:
438
+ """Return the schema this task emits, or None where it emits nothing.
439
+
440
+ Both the declared port and the returned Entities read it from here. Declaring
441
+ one schema and returning another is accepted by the task itself and rejected by
442
+ whatever is wired downstream, which is a confusing place to find out.
443
+
444
+ Writing into a graph of its own produces no output at all: the signature is in
445
+ that graph, and a port would only invite something to be wired to a result that
446
+ says nothing the graph does not already say.
447
+ """
448
+ if self._writes_into_graph:
449
+ return None
450
+ return FileEntitySchema()
451
+
452
+ @staticmethod
453
+ def _resolve_secret(value: Password | str) -> str:
454
+ """Return the decrypted string for a Password parameter, decrypted at point of use."""
455
+ return value if isinstance(value, str) else value.decrypt()
456
+
457
+ @classmethod
458
+ def _load_private_key(
459
+ cls, private_key: Password | str, passphrase: Password | str
460
+ ) -> PrivateKeyTypes:
461
+ """Load the configured private key, decrypting both secrets at the point of use."""
462
+ passphrase_str = cls._resolve_secret(passphrase)
463
+ return serialization.load_pem_private_key(
464
+ data=cls._resolve_secret(private_key).encode("utf-8"),
465
+ password=passphrase_str.encode("utf-8") if passphrase_str else None,
466
+ )
467
+
468
+ @staticmethod
469
+ def _public_key_is_multikey(private_key: PrivateKeyTypes) -> bool:
470
+ """Whether the key of *private_key* may be typed sec:Multikey."""
471
+ return is_multikey(private_key.public_key())
472
+
473
+ @staticmethod
474
+ def _public_key_multibase(private_key: PrivateKeyTypes) -> str:
475
+ """Return the multibase public key of *private_key*, for embedding in a proof.
476
+
477
+ An RSA private key carries its public key, so the key embedded in a proof is
478
+ always derived here rather than supplied separately - a key that does not
479
+ belong to the signature could only ever produce a proof that fails to verify.
480
+ """
481
+ return public_key_multibase(private_key.public_key())
482
+
483
+ def create_key_pair(self) -> str:
484
+ """Generate an Ed25519 key pair and return it for display, storing nothing.
485
+
486
+ Backs the *Create Ed25519 Key Pair* action.
487
+ """
488
+ return self._key_pair_output(Ed25519PrivateKey.generate())
489
+
490
+ def create_rsa_key_pair(self) -> str:
491
+ """Generate an RSA key pair and return it for display, storing nothing.
492
+
493
+ Backs the *Create RSA Key Pair* action.
494
+ """
495
+ return self._key_pair_output(
496
+ generate_private_key(public_exponent=65537, key_size=GENERATED_RSA_KEY_SIZE)
497
+ )
498
+
499
+ def _key_pair_output(self, private_key: PrivateKeyTypes) -> str:
500
+ """Render a freshly generated key pair as Markdown, storing nothing.
501
+
502
+ The keys are held in memory for the length of this call and returned as
503
+ text: nothing is written to disk, not even a temporary file, because a
504
+ private key belongs in a password store and in this task's encrypted
505
+ parameter, not in a file someone has to remember to delete.
506
+ """
507
+ public_key = private_key.public_key()
508
+ passphrase = self._resolve_secret(self.private_key_passphrase)
509
+ encryption = (
510
+ serialization.BestAvailableEncryption(passphrase.encode("utf-8"))
511
+ if passphrase
512
+ else serialization.NoEncryption()
513
+ )
514
+ private_pem = private_key.private_bytes(
515
+ encoding=serialization.Encoding.PEM,
516
+ format=serialization.PrivateFormat.PKCS8,
517
+ encryption_algorithm=encryption,
518
+ ).decode("utf-8")
519
+ public_pem = public_key.public_bytes(
520
+ encoding=serialization.Encoding.PEM,
521
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
522
+ ).decode("utf-8")
523
+ protection = (
524
+ "encrypted with the passphrase configured on this task"
525
+ if passphrase
526
+ else "**not** encrypted - set a *Private Key Passphrase* first to encrypt it"
527
+ )
528
+ return (
529
+ "This key pair exists only in this message. It is stored nowhere, and it "
530
+ "cannot be shown again - copy both keys to your password store now.\n\n"
531
+ f"**Private key** ({protection}) - paste into *Private Key*:\n\n"
532
+ f"```\n{private_pem}```\n\n"
533
+ "**Public key** - paste into *Verify Graph Signature*'s *External Public "
534
+ f"Key*, where a verifier needs a key to trust:\n\n"
535
+ f"```\n{public_pem}```\n"
536
+ )
537
+
538
+ def _verification_method_iri(self, private_key: PrivateKeyTypes) -> str:
539
+ """Return the verification method every proof of this run names.
540
+
541
+ The configured IRI where there is one, and otherwise the key's own did:key -
542
+ which ``__init__`` has already ensured exists. The specification requires a
543
+ proof to name its verification method, since that is how anyone else finds
544
+ the key to check it with.
545
+ """
546
+ return self.verification_method_iri or verification_method_id(private_key.public_key())
547
+
548
+ def _own_signature(
549
+ self, graph: rdflib.Graph, private_key: PrivateKeyTypes, rdf_hash: str
550
+ ) -> Callable[[rdflib.term.Node, rdflib.term.Node | None], bool]:
551
+ """Return the test that tells this signer's own proofs from everybody else's.
552
+
553
+ A proof naming the verification method this run writes settles it outright:
554
+ that is the signer saying which key is theirs. So does a proof naming this
555
+ key's did:key, even where this run is configured to name the key another
556
+ way. Failing both, a proof this key made over the content as it is now is
557
+ one this key verifies.
558
+ """
559
+ own_method = self._verification_method_iri(private_key)
560
+ public_key = private_key.public_key()
561
+
562
+ def is_own(proof_uri: rdflib.term.Node, vm_uri: rdflib.term.Node | None) -> bool:
563
+ if vm_uri is not None and (
564
+ str(vm_uri) == own_method or self._names_key(str(vm_uri), public_key)
565
+ ):
566
+ return True
567
+ return self._made_by(graph, proof_uri, public_key, rdf_hash)
568
+
569
+ return is_own
570
+
571
+ @staticmethod
572
+ def _names_key(verification_method: str, public_key: PublicKeyTypes) -> bool:
573
+ """Whether *verification_method* is a did:key encoding *public_key*."""
574
+ try:
575
+ named = public_key_from_did_key(verification_method)
576
+ except ValueError:
577
+ return False
578
+ return named is not None and named == public_key
579
+
580
+ @staticmethod
581
+ def _made_by(
582
+ graph: rdflib.Graph,
583
+ proof_uri: rdflib.term.Node,
584
+ public_key: PublicKeyTypes,
585
+ rdf_hash: str,
586
+ ) -> bool:
587
+ """Whether *public_key* made the signature stored on *graph* at *proof_uri*.
588
+
589
+ A proof states no digest of its own - the specification's proof has no such
590
+ property - so this can only be checked against the content as it is now. A
591
+ proof this key made over content that has since changed is still recognized
592
+ whenever it names the key, which every proof this task writes does.
593
+ """
594
+ proof_value = str(next(graph.objects(proof_uri, SEC.proofValue), ""))
595
+ if not proof_value:
596
+ return False
597
+ try:
598
+ signature = multibase_decode(proof_value)
599
+ except (ValueError, TypeError):
600
+ # Proof material that will not decode is no signature this key can claim.
601
+ return False
602
+ options = signature_options(graph, proof_uri)
603
+ payload = proof_payload(canonical_digest(options), rdf_hash)
604
+ return verify_digest(public_key, payload, signature)
605
+
606
+ def _expires(self, created: str) -> str:
607
+ """Return this run's proof expiry, formatted like *created*, or empty for none.
608
+
609
+ Computed from *created* rather than from now, so every proof in a run whose
610
+ signing spans more than an instant still expires a fixed number of days after
611
+ its own creation, not after the run's.
612
+ """
613
+ if not self.proof_validity_days:
614
+ return ""
615
+ created_at = datetime.strptime(created, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
616
+ expires_at = created_at + timedelta(days=self.proof_validity_days)
617
+ return expires_at.strftime("%Y-%m-%dT%H:%M:%SZ")
618
+
619
+ def signed_proof(self, write: ProofWrite) -> rdflib.Graph:
620
+ """Build the signed proof for one graph, as triples.
621
+
622
+ The same proof either goes into a graph of its own or leaves as a signature
623
+ document, so both are produced here and cannot drift apart.
624
+ """
625
+ graph_iri, rdf_hash, private_key = write.graph_iri, write.rdf_hash, write.private_key
626
+ graph_uri_ref = URIRef(graph_iri)
627
+ embedded_key = self._public_key_multibase(private_key) if self.embed_public_key else ""
628
+ verification_method_iri = self._verification_method_iri(private_key)
629
+ metadata = ProofMetadata(
630
+ created=write.created,
631
+ verification_method_iri=verification_method_iri,
632
+ public_key_multibase=embedded_key,
633
+ cryptosuite=cryptosuite_for(private_key),
634
+ public_key_is_multikey=bool(embedded_key) and self._public_key_is_multikey(private_key),
635
+ expires=self._expires(write.created),
636
+ controller=did_key_controller(verification_method_iri),
637
+ )
638
+ # The proof is built twice: once with a placeholder to obtain its configuration -
639
+ # everything it asserts about itself, such as when it was created, its purpose
640
+ # and the verification method it names - and once for real, with a signature
641
+ # covering that configuration together with the document digest. Signing the
642
+ # digest alone would leave created, proofPurpose and verificationMethod
643
+ # editable after the fact.
644
+ proof_uri = new_proof_uri()
645
+ placeholder_proof = build_proof_rdf(
646
+ graph_uri=graph_uri_ref,
647
+ proof_uri=proof_uri,
648
+ signature_b64=_PROOF_VALUE_PLACEHOLDER,
649
+ metadata=metadata,
650
+ )
651
+ options = signature_options(placeholder_proof, proof_uri)
652
+ bound_signature = base64.b64encode(
653
+ sign_digest(private_key, proof_payload(canonical_digest(options), rdf_hash))
654
+ ).decode("ascii")
655
+ return build_proof_rdf(
656
+ graph_uri=graph_uri_ref,
657
+ proof_uri=proof_uri,
658
+ signature_b64=bound_signature,
659
+ metadata=metadata,
660
+ )
661
+
662
+ def _write_proof(self, client: Client, write: ProofWrite, proof_rdf: rdflib.Graph) -> None:
663
+ """Write this signer's proof into the data graph's companion proof graph.
664
+
665
+ The data graph itself plays no part in this write and is never downloaded,
666
+ reserialized or reimported here - only the companion proof graph is read and
667
+ written, holding every other signer's proof kept as it was, plus this one.
668
+ """
669
+ graph_iri = write.graph_iri
670
+ graph_uri = URIRef(graph_iri)
671
+ existing = download_and_parse_proof_graph(client, graph_iri)
672
+ if existing is None:
673
+ existing = new_rdf_graph()
674
+ kept, replaced = proofs_by_other_keys(
675
+ existing,
676
+ graph_uri,
677
+ self._own_signature(existing, write.private_key, write.rdf_hash),
678
+ )
679
+ self.log.info(
680
+ f"{graph_iri}: {len(replaced)} own signature(s) replaced, "
681
+ f"{len(list(kept.subjects(RDF.type, SEC.DataIntegrityProof)))} kept"
682
+ )
683
+ combined = new_rdf_graph()
684
+ for triple in kept:
685
+ combined.add(triple)
686
+ for triple in proof_rdf:
687
+ combined.add(triple)
688
+ turtle_bytes = combined.serialize(format="turtle").encode("utf-8")
689
+ with tempfile.NamedTemporaryFile(suffix=".ttl", delete=False) as tmp:
690
+ tmp.write(turtle_bytes)
691
+ tmp_path = Path(tmp.name)
692
+ proof_graph_key = str(proof_graph_iri(graph_iri))
693
+ try:
694
+ client.graphs.import_item(
695
+ path=tmp_path,
696
+ key=proof_graph_key,
697
+ on_conflict=ImportConflictPolicy.REPLACE,
698
+ )
699
+ self.log.info(f"Proof written to proof graph: {proof_graph_key}")
700
+ finally:
701
+ Path(tmp_path).unlink(missing_ok=True)
702
+
703
+ def _entities(self, output_entities: list[Entity]) -> Entities | None:
704
+ """Wrap the results, or return nothing where this task declares no port."""
705
+ schema = self._output_entity_schema()
706
+ if schema is None:
707
+ return None
708
+ return Entities(schema=schema, entities=iter(output_entities))
709
+
710
+ def _run_document(self, collected: rdflib.Graph, *, cancelled: bool) -> Entity | None:
711
+ """Return the one document covering a whole run, if this run writes one.
712
+
713
+ A cancelled run writes none. A document covering the graphs signed before
714
+ the cancellation is indistinguishable from one covering every graph asked
715
+ for, and anyone verifying against it would see only passes.
716
+ """
717
+ if self.output_shape != OUTPUT_ONE_DOCUMENT or len(collected) == 0:
718
+ return None
719
+ if cancelled:
720
+ self.log.warning("Run cancelled; no signature document written.")
721
+ return None
722
+ return FileEntitySchema().to_entity(self.signature_document("graph-signatures", collected))
723
+
724
+ def _document_directory(self) -> Path:
725
+ """Return the directory this run writes its signature documents into.
726
+
727
+ One directory per run rather than one per document, registered for cleanup
728
+ so a long-lived worker does not accumulate a directory per graph signed.
729
+ """
730
+ if self._documents_dir is None:
731
+ self._documents_dir = Path(tempfile.mkdtemp(prefix="graph-signatures-"))
732
+ return self._documents_dir
733
+
734
+ @staticmethod
735
+ def _digest_only(graph_iri: str, rdf_hash: str) -> rdflib.Graph:
736
+ """Return the digest record for a graph that was hashed but not signed."""
737
+ return build_digest_record(URIRef(graph_iri), rdf_hash)
738
+
739
+ def signature_document(self, graph_iri: str, proof_rdf: rdflib.Graph) -> LocalFile:
740
+ """Write one graph's signature to a JSON-LD document and return it as a file.
741
+
742
+ The document holds exactly the triples a proof written into a graph of its own
743
+ would, so the two say the same thing in two places.
744
+ """
745
+ # The context is pinned to the two namespaces the document actually uses.
746
+ # rdflib would otherwise emit its entire default prefix table, and naming the
747
+ # W3C Data Integrity context here would claim a conformance this does not have.
748
+ jsonld = proof_rdf.serialize(
749
+ format="json-ld",
750
+ auto_compact=True,
751
+ context={"sec": str(SEC)},
752
+ )
753
+ slug = re.sub(r"^https?-+", "", re.sub(r"[^A-Za-z0-9]+", "-", graph_iri).strip("-"))
754
+ slug = slug[:100] or "graph"
755
+ path = self._document_directory() / f"{slug}.signature.json"
756
+ path.write_text(jsonld, encoding="utf-8")
757
+ return LocalFile(path=str(path), mime="application/ld+json")
758
+
759
+ def execute(
760
+ self,
761
+ inputs: Sequence[Entities],
762
+ context: ExecutionContext,
763
+ ) -> Entities | None:
764
+ """Run the workflow operator."""
765
+ self.log.info("Start creating graph hash.")
766
+ client = get_client(context)
767
+
768
+ # Collect graph IRIs from port 0; fall back to the input_graph parameter
769
+ graph_iris: list[str] = []
770
+ if inputs:
771
+ graph_iris.extend(
772
+ entity.values[0][0]
773
+ for entity in inputs[0].entities
774
+ if entity.values and entity.values[0]
775
+ )
776
+ if not graph_iris and self.input_graph:
777
+ graph_iris = [self.input_graph]
778
+
779
+ # Load the private key when one was supplied; that is what turns on signing
780
+ private_key = (
781
+ self._load_private_key(self.private_key, self.private_key_passphrase)
782
+ if self.signing
783
+ else None
784
+ )
785
+
786
+ # Process each graph
787
+ output_entities: list[Entity] = []
788
+ collected = new_rdf_graph()
789
+ created = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
790
+
791
+ cancelled = False
792
+ for processed, graph_iri in enumerate(graph_iris, start=1):
793
+ with suppress(AttributeError):
794
+ if context.workflow.status() == "Canceling":
795
+ cancelled = True
796
+ break
797
+
798
+ self.log.info(f"Processing graph: {graph_iri}")
799
+ full_graph = download_and_parse(
800
+ client=client, graph_iri=graph_iri, resolve_owl_imports=self.resolve_owl_imports
801
+ )
802
+
803
+ self.log.info(f"Graph size: {len(full_graph)}")
804
+ rdf_hash = hash_parsed_graph(full_graph)
805
+ self.log.info(rdf_hash)
806
+
807
+ proof_rdf = None
808
+ if private_key is not None:
809
+ write = ProofWrite(
810
+ graph_iri=graph_iri,
811
+ rdf_hash=rdf_hash,
812
+ private_key=private_key,
813
+ created=created,
814
+ )
815
+ proof_rdf = self.signed_proof(write)
816
+ if self._writes_into_graph:
817
+ self._write_proof(client, write, proof_rdf)
818
+
819
+ emitted = self._emit_for_graph(graph_iri, rdf_hash, proof_rdf, collected)
820
+ if emitted is not None:
821
+ output_entities.append(emitted)
822
+
823
+ context.report.update(
824
+ ExecutionReport(
825
+ entity_count=processed,
826
+ operation="write" if self._writes_into_graph else "read",
827
+ operation_desc=self.operation_description(),
828
+ )
829
+ )
830
+
831
+ run_document = self._run_document(collected, cancelled=cancelled)
832
+ if run_document is not None:
833
+ output_entities.append(run_document)
834
+
835
+ return self._entities(output_entities)