Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+

Expand All @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+

Expand All @@ -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:
Expand Down
11 changes: 7 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
29 changes: 10 additions & 19 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
5 changes: 4 additions & 1 deletion tests/test_tro_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
90 changes: 90 additions & 0 deletions tests/test_tro_signing.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
"""Tests for TRO signing and verification."""

import json
import os
from unittest.mock import patch

import pytest

from tro_utils.tro_utils import TRO

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."""
Expand Down Expand Up @@ -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()
63 changes: 63 additions & 0 deletions tests/test_tro_timestamping.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Loading
Loading