Skip to content

Commit

Permalink
new cli argument allowing to specify receipt type to get after submis…
Browse files Browse the repository at this point in the history
…sion
  • Loading branch information
ivarprudnikov committed Aug 23, 2024
1 parent dd96110 commit 81ffcd6
Show file tree
Hide file tree
Showing 20 changed files with 291 additions and 121 deletions.
29 changes: 24 additions & 5 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,18 +132,37 @@ Root CAs are used to validate COSE envelopes being submitted to the `/entries` e

scitt-ccf-ledger has unit tests, covering individual components of the source code, and functional tests, covering end-to-end use cases of scitt-ccf-ledger.

The unit tests can be run with:
### Unit tests

The unit tests can be run with `run_unit_tests.sh` script.

```sh
PLATFORM="virtual" ./docker/build.sh
./run_unit_tests.sh
```

All functional tests can be run with:
### Functional (e2e) tests

```sh
./run_functional_tests.sh
```
To start the tests you need to use the script `run_functional_tests.sh`.

Specific functional test can also be run by passing additional `pytest` arguments, e.g. `./run_functional_tests.sh -k test_use_cacert_submit_verify_x509_signature`

Note: the functional tests will launch their own CCF network on a randomly assigned port. You do not need to start an instance beforehand.

**Using Docker**

The script will launch the built Docker image and will execute tests against it:

```sh
PLATFORM="virtual" ./docker/build.sh
DOCKER=1 PLATFORM=virtual ./run_functional_tests.sh
```

**Using your host environment**

```sh
PLATFORM=virtual ./build.sh
PLATFORM=virtual ./run_functional_tests.sh
```


2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ See the `demo/` folder on how to interact with the application.

### Development setup

See [DEVELOPMENT.md](DEVELOPMENT.md) for instructions on building, running, and testing scitt-ccf-ledger without Docker.
See [DEVELOPMENT.md](DEVELOPMENT.md) for instructions on building, running, and testing scitt-ccf-ledger.

### Using the CLI

Expand Down
2 changes: 0 additions & 2 deletions demo/github/4-submit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@
set -ex

SCITT_URL=${SCITT_URL:-"https://127.0.0.1:8000"}
SCITT_TRUST_STORE=tmp/trust_store

TMP_DIR=tmp/github

scitt submit $TMP_DIR/claims.cose \
--receipt $TMP_DIR/claims.receipt.cbor \
--url "$SCITT_URL" \
--service-trust-store $SCITT_TRUST_STORE \
--development
38 changes: 30 additions & 8 deletions pyscitt/pyscitt/cli/submit_signed_claims.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pathlib import Path
from typing import Optional

from ..client import Client
from ..client import Client, ReceiptType
from ..verify import StaticTrustStore, verify_receipt
from .client_arguments import add_client_arguments, create_client

Expand All @@ -14,27 +14,39 @@ def submit_signed_claimset(
client: Client,
path: Path,
receipt_path: Optional[Path],
receipt_type: str,
service_trust_store_path: Optional[Path],
skip_confirmation: bool,
):
if path.suffix != ".cose":
raise ValueError("unsupported file extension")
raise ValueError("unsupported file extension, must end with .cose")

if receipt_type == "raw":
r_type = ReceiptType.RAW
elif receipt_type == "embedded":
r_type = ReceiptType.EMBEDDED
else:
raise ValueError(f"unsupported receipt type {receipt_type}")

with open(path, "rb") as f:
signed_claimset = f.read()

if skip_confirmation:
pending = client.submit_claim(signed_claimset, skip_confirmation=True)
pending = client.submit_claim(signed_claimset)
print(f"Submitted {path} as operation {pending.operation_tx}")
print("Confirmation of submission was skipped! Claim may not be registered.")
print(
"""Confirmation of submission was skipped!
There is a small chance the claim may not be registered.
Receipt will not be downloaded and saved."""
)
return

submission = client.submit_claim(signed_claimset)
submission = client.submit_claim_and_confirm(signed_claimset, receipt_type=r_type)
print(f"Submitted {path} as transaction {submission.tx}")

if receipt_path:
with open(receipt_path, "wb") as f:
f.write(submission.raw_receipt)
f.write(submission.receipt_bytes)
print(f"Received {receipt_path}")

if service_trust_store_path:
Expand All @@ -48,17 +60,26 @@ def submit_signed_claimset(

def cli(fn):
parser = fn(
description="Submit signed claimset to a SCITT CCF Ledger and retrieve receipt"
description="Submit signed claimset (COSE) to a SCITT CCF Ledger and retrieve receipt"
)
add_client_arguments(parser, with_auth_token=True)
parser.add_argument("path", type=Path, help="Path to signed claimset file")
parser.add_argument("path", type=Path, help="Path to signed claimset file (COSE)")
group = parser.add_mutually_exclusive_group()
group.add_argument("--receipt", type=Path, help="Output path to receipt file")
group.add_argument(
"--skip-confirmation",
action="store_true",
help="Don't wait for confirmation or a receipt",
)
parser.add_argument(
"--receipt-type",
choices=["embedded", "raw"],
default="raw", # default to raw for backwards compatibility
help="""
Downloads the receipt of a given type where raw means a countersignature (CBOR) binary
and embedded means the original claimset (COSE) with the raw receipt added to the unprotected header
""",
)
parser.add_argument(
"--service-trust-store",
type=Path,
Expand All @@ -71,6 +92,7 @@ def cmd(args):
client,
args.path,
args.receipt,
args.receipt_type,
args.service_trust_store,
args.skip_confirmation,
)
Expand Down
69 changes: 48 additions & 21 deletions pyscitt/pyscitt/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ class SigningType(Enum):
HTTP = "HTTP"


class ReceiptType(Enum):
"""Receipt types supported by the ledger."""

EMBEDDED = "EMBEDDED"
RAW = "RAW"


class MemberAuthenticationMethod(ABC):
cert: str

Expand Down Expand Up @@ -482,25 +489,39 @@ def get_historical(self, *args, retry_on=[], **kwargs):
@dataclass
class PendingSubmission:
"""
The result of submitting a claim to the service.
The pending result of submitting a claim to the service.
"""

operation_tx: str


@dataclass
class Submission(PendingSubmission):
"""
The result of submitting a claim to the service.
"""

tx: str
raw_receipt: bytes
receipt_bytes: bytes
is_receipt_embedded: bool

@property
def seqno(self) -> int:
"""Extract the sequence number from the transaction ID."""
view, seqno = self.tx.split(".")
return int(seqno)

@property
def receipt(self) -> Receipt:
return Receipt.decode(self.raw_receipt)
"""Parse the receipt bytes and return a Receipt object."""
if self.is_receipt_embedded:
embedded_receipt = crypto.get_last_embedded_receipt_from_cose(
self.receipt_bytes
)
if embedded_receipt:
return Receipt.decode(embedded_receipt)
raise ValueError("No embedded receipt found in COSE message header")
return Receipt.decode(self.receipt_bytes)


class Client(BaseClient):
Expand All @@ -523,33 +544,39 @@ def get_did_document(self, did: str) -> dict:
# Note: This endpoint only returns data for did:web DIDs.
return self.get(f"/did/{did}").json()["did_document"]

@overload
def submit_claim(
self, claim: bytes, *, skip_confirmation: Literal[False] = False
) -> Submission: ...

@overload
def submit_claim(
self, claim: bytes, *, skip_confirmation: Literal[True]
) -> PendingSubmission: ...

def submit_claim(
self, claim: bytes, *, skip_confirmation=False
) -> Union[Submission, PendingSubmission]:
self,
claim: bytes,
) -> PendingSubmission:
headers = {"Content-Type": "application/cose"}
response = self.post(
"/entries",
headers=headers,
content=claim,
).json()
operation_id = response["operationId"]
return PendingSubmission(operation_id)

def submit_claim_and_confirm(
self,
claim: bytes,
*,
receipt_type: ReceiptType = ReceiptType.RAW,
) -> Submission:
headers = {"Content-Type": "application/cose"}
response = self.post(
"/entries",
headers=headers,
content=claim,
).json()
operation_id = response["operationId"]
if skip_confirmation:
return PendingSubmission(operation_id)
else:
tx = self.wait_for_operation(operation_id)
receipt = self.get_receipt(tx, decode=False)
return Submission(operation_id, tx, receipt)
tx = self.wait_for_operation(operation_id)
if receipt_type == ReceiptType.EMBEDDED:
receipt = self.get_claim(tx, embed_receipt=True)
return Submission(operation_id, tx, receipt, True)

receipt = self.get_receipt(tx, decode=False)
return Submission(operation_id, tx, receipt, False)

def wait_for_operation(self, operation: str) -> str:
response = self.get(
Expand Down
18 changes: 18 additions & 0 deletions pyscitt/pyscitt/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ def sha256_file(path: Path) -> str:


def embed_receipt_in_cose(buf: bytes, receipt: bytes) -> bytes:
"""Append the receipt to an unprotected header in a COSE_Sign1 message."""
# Need to parse the receipt to avoid wrapping it in a bstr.
parsed_receipt = cbor2.loads(receipt)

Expand All @@ -591,6 +592,23 @@ def embed_receipt_in_cose(buf: bytes, receipt: bytes) -> bytes:
return cbor2.dumps(outer)


def get_last_embedded_receipt_from_cose(buf: bytes) -> Union[bytes, None]:
"""Extract the last receipt from the unprotected header of a COSE_Sign1 message."""
outer = cbor2.loads(buf)
if hasattr(outer, "tag"):
assert outer.tag == 18 # COSE_Sign1
val = outer.value # type: ignore[attr-defined]
else:
val = outer
[_, uhdr, _, _] = val
key = COSE_HEADER_PARAM_SCITT_RECEIPTS
if key in uhdr:
parsed_receipts = uhdr[key]
if isinstance(parsed_receipts, list) and parsed_receipts:
return cbor2.dumps(parsed_receipts[-1])
return None


def load_private_key(key_path: Path) -> Pem:
with open(key_path) as f:
key_priv_pem = f.read()
Expand Down
8 changes: 4 additions & 4 deletions pyscitt/pyscitt/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,10 @@ def verify_receipt(
decoded_receipt = receipt
else:
if receipt is None:
parsed_receipts = msg.uhdr[COSE_HEADER_PARAM_SCITT_RECEIPTS]
# For now, assume there is only one receipt
assert len(parsed_receipts) == 1
parsed_receipt = parsed_receipts[0]
embedded_receipt = crypto.get_last_embedded_receipt_from_cose(buf)
if embedded_receipt is None:
raise ValueError("No embedded receipt found in COSE message")
parsed_receipt = cbor2.loads(embedded_receipt)
else:
parsed_receipt = cbor2.loads(receipt)

Expand Down
6 changes: 4 additions & 2 deletions test/load_test/locustfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ def submit_claim(self):
claim = self._claims[random.randrange(len(self._claims))]
self.trace(
"submit_claim",
lambda: self.client.submit_claim(
claim, skip_confirmation=self.skip_confirmation
lambda: (
self.client.submit_claim(claim)
if self.skip_confirmation
else self.client.submit_claim_and_confirm(claim)
),
)
2 changes: 1 addition & 1 deletion test/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def f(*, allow_unauthenticated: bool, required_claims=None):
@pytest.fixture
def submit(self, client: Client, claim: bytes):
def f(**kwargs):
client.replace(**kwargs).submit_claim(claim)
client.replace(**kwargs).submit_claim_and_confirm(claim)

return f

Expand Down
10 changes: 6 additions & 4 deletions test/test_ccf.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_submit_claim(client: Client, did_web, trust_store, params):

# Sign and submit a dummy claim using our new identity
claims = crypto.sign_json_claimset(identity, {"foo": "bar"})
receipt = client.submit_claim(claims).raw_receipt
receipt = client.submit_claim_and_confirm(claims).receipt_bytes
verify_receipt(claims, trust_store, receipt)

embedded = crypto.embed_receipt_in_cose(claims, receipt)
Expand Down Expand Up @@ -72,14 +72,14 @@ def test_default_did_port(client: Client, trust_store, tmp_path):

# Sign and submit a dummy claim using our new identity
claims = crypto.sign_json_claimset(identity, {"foo": "bar"})
receipt = client.submit_claim(claims).receipt
receipt = client.submit_claim_and_confirm(claims).receipt
verify_receipt(claims, trust_store, receipt)


@pytest.mark.isolated_test
def test_recovery(client, did_web, restart_service):
identity = did_web.create_identity()
client.submit_claim(crypto.sign_json_claimset(identity, {"foo": "bar"}))
client.submit_claim_and_confirm(crypto.sign_json_claimset(identity, {"foo": "bar"}))

old_network = client.get("/node/network").json()
assert old_network["recovery_count"] == 0
Expand All @@ -91,4 +91,6 @@ def test_recovery(client, did_web, restart_service):
assert new_network["service_certificate"] != old_network["service_certificate"]

# Check that the service is still operating correctly
client.submit_claim(crypto.sign_json_claimset(identity, {"foo": "hello"}))
client.submit_claim_and_confirm(
crypto.sign_json_claimset(identity, {"foo": "hello"})
)
Loading

0 comments on commit 81ffcd6

Please sign in to comment.