diff --git a/README.md b/README.md index 5d2e931..d48b056 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,20 @@ It uses the `Click` library to define commands and options. Here's a summary of - `performance`: Manages performances in the TRO. It has a subcommand `add` that adds a performance to the TRO. - - `sign`: Signs the TRO. + - `sign`: Signs the TRO. This records the public half of the signing key as `trov:publicKey` in the declaration, saves the declaration, and then produces the signature and the RFC 3161 timestamp over it. - `report`: Generates a report of the TRO. 3. **TRO Interaction**: The script interacts with the TRO using the `TRO` class from the `tro_utils` module. It uses this class to create a new TRO, add arrangements and performances to the TRO, verify the TRO, and generate a report of the TRO. +4. **GPG is only used for signing**: `--gpg-fingerprint` and `--gpg-passphrase` are recorded but never resolved against a keyring until `sign` runs. Building, inspecting, reporting on and verifying a TRO therefore need no GPG key — and no `gpg` binary at all. As a consequence, `trov:publicKey` appears in the declaration only from `sign` onwards, and is by construction the public half of the key that produced the signature (a value supplied by a TRS profile acts as a default until then). + ## Installation ### Pre-requisites Before you begin, you need to have the following installed on your system: -- GPG +- GPG (only needed to `sign` a TRO) - OpenSSL - Python 3.8+ @@ -46,12 +48,15 @@ $ sudo apt-get install gnupg openssl python3 python3-pip # on Debian/Ubuntu $ brew install gnupg openssl python3 # on macOS with Homebrew ``` +If you only consume TROs — building, inspecting, reporting or `verify-timestamp` — +GPG is not required; OpenSSL still is. + ## Example Usage Assumes that: * this package is installed -* your GPG key is present +* your GPG key is present (needed for the `sign` step only) * `trs.jsonld` is available and defines TRS capabilities (see below for an example) Example workflow: diff --git a/docs/installation.md b/docs/installation.md index f967a36..2969655 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -3,7 +3,7 @@ ## Pre-requisites Before you begin, you need to have the following installed on your system: -- GPG +- GPG (only needed to `sign` a TRO) - OpenSSL - Python 3.8+ @@ -14,6 +14,9 @@ $ sudo apt-get install gnupg openssl python3 python3-pip # on Debian/Ubuntu $ brew install gnupg openssl python3 # on macOS with Homebrew ``` +If you only consume TROs — building, inspecting, reporting or `verify-timestamp` — +GPG is not required; OpenSSL still is. + ## Stable release To install Transparent Research Object utils, run this command in your terminal: diff --git a/docs/usage.md b/docs/usage.md index e4b147f..43257d4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -60,7 +60,9 @@ tro-utils --declaration my.jsonld performance add \ ``` tro-utils sign ``` -GPG-signs the TRO declaration, writing a `.sig` file. +Records the signing key's public half as `trov:publicKey` in the declaration, saves the +declaration, GPG-signs it into a `.sig` file, and timestamps both into a `.tsr` file. +This is the only command that needs a GPG key. ``` tro-utils report -t TEMPLATE -o OUTPUT @@ -247,11 +249,12 @@ tro.add_performance( modified_arrangement="arrangement/1", # str | (id, path) tuple | list | None ) -# Save, sign, and timestamp +# Save, sign, and timestamp. request_timestamp() attaches trov:publicKey and +# re-saves the declaration before signing, so the .sig and .tsr always cover the +# declaration as it exists on disk. tro.save() -tro.trs_signature() tro.request_timestamp() -tro.verify_timestamp() +tro.verify_timestamp() # needs neither a key nor the gpg binary # Verify a replication package result = tro.verify_replication_package( diff --git a/tests/helpers.py b/tests/helpers.py index 9acb71e..85da101 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,28 +2,19 @@ import os +from tro_utils import tro_utils from tro_utils.tro_utils import TRO def create_tro_with_gpg(filepath, gpg_setup, **kwargs): - """Helper to create TRO with proper GPG configuration.""" - # Set GPG_HOME environment variable - os.environ["GPG_HOME"] = gpg_setup["gpg_home"] - - # Temporarily remove gpg_fingerprint from kwargs to avoid key_map lookup error - gpg_fingerprint = kwargs.pop("gpg_fingerprint", None) + """Create a TRO pointed at the test GPG keyring. - # Create TRO instance without fingerprint first - tro = TRO(filepath=filepath, **kwargs) - - # Now manually set up the GPG key if fingerprint was provided - # This works around the key_map issue in the gnupg library - if gpg_fingerprint: - tro.gpg = gpg_setup["gpg"] - tro.gpg_key_id = gpg_setup["keyid"] - tro.data["@graph"][0]["trov:wasAssembledBy"]["trov:publicKey"] = ( - tro.gpg.export_keys(tro.gpg_key_id) - ) - tro.gpg_passphrase = kwargs.get("gpg_passphrase") + ``tro_utils.tro_utils.GPG_HOME`` is read at import time, so it is redirected + here to the keyring created by the ``gpg_setup`` fixture. GPG itself is only + contacted when a key is needed, so a TRO created without a fingerprint never + touches it. + """ + os.environ["GPG_HOME"] = gpg_setup["gpg_home"] + tro_utils.GPG_HOME = gpg_setup["gpg_home"] - return tro + return TRO(filepath=filepath, **kwargs) diff --git a/tests/test_tro_creation.py b/tests/test_tro_creation.py index 57b2a0c..67d5641 100644 --- a/tests/test_tro_creation.py +++ b/tests/test_tro_creation.py @@ -46,7 +46,10 @@ def test_create_tro_without_file(self, tmp_path, gpg_setup): ) assert tro.basename == "new_tro" - assert tro.gpg_key_id == gpg_setup["keyid"] + # The fingerprint is recorded but not resolved against the keyring yet + assert tro.gpg_fingerprint == gpg_setup["fingerprint"] + assert tro.gpg_key_id is None + assert "trov:publicKey" not in tro.data["@graph"][0]["trov:wasAssembledBy"] assert "TransparentResearchObject" in str(tro.data) assert tro.data["@graph"][0]["schema:creator"] == "Test Creator" assert tro.data["@graph"][0]["schema:name"] == "Test TRO" diff --git a/tests/test_tro_signing.py b/tests/test_tro_signing.py index 4cc2776..3ce3688 100644 --- a/tests/test_tro_signing.py +++ b/tests/test_tro_signing.py @@ -1,6 +1,8 @@ """Tests for TRO signing and verification.""" +import json import os +from unittest.mock import patch import pytest @@ -8,6 +10,14 @@ from tests.helpers import create_tro_with_gpg +PUBLIC_KEY_ARMOR = "-----BEGIN PGP PUBLIC KEY BLOCK-----" + + +def _trs(declaration): + """Return the ``trov:wasAssembledBy`` block of a saved declaration.""" + with open(declaration) as fp: + return json.load(fp)["@graph"][0]["trov:wasAssembledBy"] + class TestTROSigning: """Test TRO signing and verification.""" @@ -57,3 +67,83 @@ def test_sign_without_passphrase(self, tmp_path, gpg_setup): with pytest.raises(RuntimeError, match="GPG passphrase was not provided"): tro.trs_signature() + + +class TestPublicKeyAttachment: + """Test that trov:publicKey is only injected at signing time.""" + + def test_public_key_absent_until_attached( + self, temp_workspace, tmp_path, gpg_setup, trs_profile + ): + """Saving before signing must not record a public key.""" + declaration = str(tmp_path / "test_tro.jsonld") + tro = create_tro_with_gpg( + filepath=declaration, + gpg_setup=gpg_setup, + profile=trs_profile, + gpg_fingerprint=gpg_setup["fingerprint"], + gpg_passphrase=gpg_setup["passphrase"], + ) + tro.add_arrangement(str(temp_workspace), comment="Test") + tro.save() + + assert "trov:publicKey" not in _trs(declaration) + + tro.attach_public_key() + tro.save() + + public_key = _trs(declaration)["trov:publicKey"] + assert public_key.startswith(PUBLIC_KEY_ARMOR) + assert public_key == gpg_setup["gpg"].export_keys(gpg_setup["keyid"]) + assert tro.gpg_key_id == gpg_setup["keyid"] + + def test_attach_public_key_without_fingerprint(self, tmp_path): + """Attaching a key without a configured fingerprint raises.""" + tro = TRO(filepath=str(tmp_path / "test_tro.jsonld")) + + with pytest.raises(RuntimeError, match="GPG fingerprint was not provided"): + tro.attach_public_key() + + def test_attach_public_key_without_keyring(self, tmp_path, monkeypatch): + """A fingerprint missing from the keyring is reported as such.""" + gpg_home = tmp_path / "empty_keyring" + gpg_home.mkdir(mode=0o700) + monkeypatch.setattr("tro_utils.tro_utils.GPG_HOME", str(gpg_home)) + + tro = TRO( + filepath=str(tmp_path / "test_tro.jsonld"), + gpg_fingerprint="0" * 40, + ) + + with pytest.raises(RuntimeError, match="was not found in the keyring"): + tro.attach_public_key() + + def test_save_with_fingerprint_but_no_keyring( + self, temp_workspace, tmp_path, monkeypatch + ): + """Building and saving a TRO must not require the configured key.""" + gpg_home = tmp_path / "empty_keyring" + gpg_home.mkdir(mode=0o700) + monkeypatch.setattr("tro_utils.tro_utils.GPG_HOME", str(gpg_home)) + declaration = str(tmp_path / "test_tro.jsonld") + + tro = TRO(filepath=declaration, gpg_fingerprint="0" * 40) + tro.add_arrangement(str(temp_workspace), comment="Test") + tro.save() + + assert "trov:publicKey" not in _trs(declaration) + + def test_read_write_never_touches_gpg(self, temp_workspace, tmp_path): + """No GPG binary is needed to create, mutate, save or reload a TRO.""" + declaration = str(tmp_path / "test_tro.jsonld") + + def no_gpg(*args, **kwargs): + raise AssertionError("GPG must not be used outside of signing") + + with patch.object(TRO, "_gpg", no_gpg): + tro = TRO(filepath=declaration, gpg_fingerprint="0" * 40) + tro.add_arrangement(str(temp_workspace), comment="Test") + tro.save() + reloaded = TRO(filepath=declaration, gpg_fingerprint="0" * 40) + + assert reloaded.list_arrangements() diff --git a/tests/test_tro_timestamping.py b/tests/test_tro_timestamping.py index 7c3b527..26848e1 100644 --- a/tests/test_tro_timestamping.py +++ b/tests/test_tro_timestamping.py @@ -1,8 +1,11 @@ """Tests for TRO timestamping operations.""" +import json import os from unittest.mock import MagicMock, patch +from tro_utils.tro_utils import TRO + from tests.helpers import create_tro_with_gpg @@ -102,3 +105,63 @@ def test_verify_timestamp( assert call_args[0] == "openssl" assert call_args[1] == "ts" assert call_args[2] == "-verify" + + @patch("subprocess.check_call") + @patch("requests.get") + @patch("tro_utils.tro_utils.encoder.encode") + @patch("tro_utils.tro_utils.rfc3161ng.RemoteTimestamper") + def test_timestamped_payload_matches_saved_declaration( + self, + mock_timestamper, + mock_encode, + mock_get, + mock_check_call, + temp_workspace, + tmp_path, + gpg_setup, + trs_profile, + ): + """The timestamped payload must match what a verifier recomputes. + + Regression test for the ordering of ``attach_public_key()``: the public + key has to land in the declaration before it is hashed *and* be written + to disk, otherwise a signed TRO fails its own ``verify_timestamp()``. + """ + mock_encode.return_value = b"encoded_tsr_data" + mock_tsr = MagicMock() + mock_ts_instance = MagicMock(return_value=mock_tsr) + mock_timestamper.return_value = mock_ts_instance + mock_get.return_value = MagicMock(content=b"fake cert content") + + declaration = str(tmp_path / "test_tro.jsonld") + tro = create_tro_with_gpg( + filepath=declaration, + gpg_setup=gpg_setup, + profile=trs_profile, + gpg_fingerprint=gpg_setup["fingerprint"], + gpg_passphrase=gpg_setup["passphrase"], + ) + tro.add_arrangement(str(temp_workspace), comment="Test") + tro.save() + + tro.request_timestamp() + signed_payload = mock_ts_instance.call_args.kwargs["data"] + + # Signing must persist the declaration it attested to + with open(declaration) as fp: + saved = json.load(fp) + public_key = saved["@graph"][0]["trov:wasAssembledBy"]["trov:publicKey"] + assert public_key.startswith("-----BEGIN PGP PUBLIC KEY BLOCK-----") + + # A third party verifies from the files alone: no fingerprint, no + # passphrase, no keyring. + verified_payload = {} + + def capture_payload(args): + with open(args[args.index("-data") + 1], "rb") as fp: + verified_payload["data"] = fp.read() + + mock_check_call.side_effect = capture_payload + TRO(filepath=declaration).verify_timestamp() + + assert verified_payload["data"] == signed_payload diff --git a/tro_utils/tro_utils.py b/tro_utils/tro_utils.py index 4068de7..f675cf9 100644 --- a/tro_utils/tro_utils.py +++ b/tro_utils/tro_utils.py @@ -15,7 +15,6 @@ import subprocess import tempfile -import gnupg from jinja2 import Template import requests import rfc3161ng @@ -30,10 +29,12 @@ class TRO: + gpg_fingerprint = None gpg_key_id = None gpg_passphrase = None basename = None profile = None + _gpg_obj = None def __init__( self, @@ -84,10 +85,11 @@ def __init__( if extra_context: self._model.extra_context.update(extra_context) - self.gpg = gnupg.GPG(gnupghome=GPG_HOME, verbose=False) + # GPG configuration is only recorded here; nothing touches the keyring + # until a signature is actually produced. See _gpg() and + # attach_public_key(). if gpg_fingerprint: - self.gpg_key_id = self.gpg.list_keys().key_map[gpg_fingerprint]["keyid"] - self._model.trs.public_key = self.gpg.export_keys(self.gpg_key_id) + self.gpg_fingerprint = gpg_fingerprint if gpg_passphrase: self.gpg_passphrase = gpg_passphrase @@ -182,14 +184,53 @@ def sha256_for_file(filepath, resolve_symlinks=True): # Signing and timestamping # ------------------------------------------------------------------ - def trs_signature(self): + def _gpg(self): + """Return the GPG interface, constructing it on first use. + + Instantiating :class:`gnupg.GPG` spawns the ``gpg`` binary, so it is + deferred until a key is actually needed. Creating, mutating, saving, + reporting on and verifying a TRO therefore require neither the binary + nor a keyring. + """ + if self._gpg_obj is None: + import gnupg + + self._gpg_obj = gnupg.GPG(gnupghome=GPG_HOME, verbose=False) + return self._gpg_obj + + def _resolve_key_id(self): + """Resolve the configured fingerprint against the local keyring.""" + if self.gpg_fingerprint is None: + raise RuntimeError("GPG fingerprint was not provided") if self.gpg_key_id is None: + try: + self.gpg_key_id = ( + self._gpg().list_keys().key_map[self.gpg_fingerprint]["keyid"] + ) + except KeyError: + raise RuntimeError( + f"GPG key {self.gpg_fingerprint} was not found in the keyring" + ) from None + return self.gpg_key_id + + def attach_public_key(self): + """Record the public half of the signing key in the declaration. + + Called by :meth:`request_timestamp` before the declaration is hashed or + signed, so that ``trov:publicKey`` is by construction the public half of + the key that produced the signature. A value supplied by a TRS profile + acts as a default until it is replaced here. + """ + self._model.trs.public_key = self._gpg().export_keys(self._resolve_key_id()) + + def trs_signature(self): + if self.gpg_fingerprint is None: raise RuntimeError("GPG fingerprint was not provided") if self.gpg_passphrase is None: raise RuntimeError("GPG passphrase was not provided") - signature = self.gpg.sign( + signature = self._gpg().sign( json.dumps(self.data, indent=2, sort_keys=True), - keyid=self.gpg_key_id, + keyid=self._resolve_key_id(), passphrase=self.gpg_passphrase, detach=True, ) @@ -198,7 +239,16 @@ def trs_signature(self): return signature def request_timestamp(self): - """Request a timestamp from a remote TSA and store the result in a file.""" + """Request a timestamp from a remote TSA and store the result in a file. + + The public key is attached and the declaration written out *before* + anything is hashed, so that the signature and the timestamp both cover + the declaration exactly as it ends up on disk. Injecting the key any + later — inside :meth:`trs_signature`, say — would make every TRO fail + its own :meth:`verify_timestamp`. + """ + self.attach_public_key() + self.save() rt = rfc3161ng.RemoteTimestamper("https://freetsa.org/tsr", hashname="sha512") ts_data = { "tro_declaration": hashlib.sha512(