Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support updated REST API #9

Merged
merged 11 commits into from
Feb 22, 2023
Merged
2 changes: 1 addition & 1 deletion run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ else
. ./venv/bin/activate
fi

pytest
pytest "$@"
104 changes: 81 additions & 23 deletions scitt_emulator/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,97 @@
from typing import Optional
from pathlib import Path
import json
import time

import httpx

import scitt_emulator.scitt as scitt
from scitt_emulator.tree_algs import TREE_ALGS

DEFAULT_URL = "http://127.0.0.1:8000"
CONNECT_RETRIES = 3
HTTP_RETRIES = 3
HTTP_DEFAULT_RETRY_DELAY = 1


def raise_for_status(response: httpx.Response):
if response.is_success:
return
try:
error = response.json()
except json.JSONDecodeError:
error = response.text
raise RuntimeError(f"HTTP error {response.status_code}: {error}")
raise RuntimeError(
f"HTTP error {response.status_code}: {error['error']['message']}"
)
raise RuntimeError(f"HTTP error {response.status_code}: {response.text}")


def raise_for_operation_status(operation: dict):
if operation["status"] != "failed":
return
raise RuntimeError(f"Operation error: {operation['error']}")


class HttpClient:
def __init__(self, cacert: Optional[Path] = None):
verify = True if cacert is None else str(cacert)
transport = httpx.HTTPTransport(retries=CONNECT_RETRIES, verify=verify)
self.client = httpx.Client(transport=transport)

def _request(self, *args, **kwargs):
response = self.client.request(*args, **kwargs)
retries = HTTP_RETRIES
while retries >= 0 and response.status_code == 503:
retries -= 1
retry_after = int(
response.headers.get("retry-after", HTTP_DEFAULT_RETRY_DELAY)
)
time.sleep(retry_after)
response = self.client.request(*args, **kwargs)
raise_for_status(response)
return response

def get(self, *args, **kwargs):
return self._request("GET", *args, **kwargs)

def post(self, *args, **kwargs):
return self._request("POST", *args, **kwargs)


def create_claim(issuer: str, content_type: str, payload: str, claim_path: Path):
scitt.create_claim(claim_path, issuer, content_type, payload)


def submit_claim(
url: str, claim_path: Path, receipt_path: Path, entry_id_path: Optional[Path]
url: str,
claim_path: Path,
receipt_path: Path,
entry_id_path: Optional[Path],
client: HttpClient,
):
with open(claim_path, "rb") as f:
claim = f.read()

# Submit claim
response = httpx.post(f"{url}/entries", content=claim)
raise_for_status(response)
entry_id = response.json()["entry_id"]
response = client.post(f"{url}/entries", content=claim)
if response.status_code == 201:
entry = response.json()
entry_id = entry["entryId"]

elif response.status_code == 202:
operation = response.json()

# Wait for registration to finish
while operation["status"] != "succeeded":
retry_after = int(
response.headers.get("retry-after", HTTP_DEFAULT_RETRY_DELAY)
)
time.sleep(retry_after)
response = client.get(f"{url}/operations/{operation['operationId']}")
operation = response.json()
raise_for_operation_status(operation)

entry_id = operation["entryId"]

else:
raise RuntimeError(f"Unexpected status code: {response.status_code}")

# Fetch receipt
response = httpx.get(f"{url}/entries/{entry_id}/receipt")
raise_for_status(response)
response = client.get(f"{url}/entries/{entry_id}/receipt")
receipt = response.content

print(f"Claim registered with entry ID {entry_id}")
Expand All @@ -62,9 +113,8 @@ def submit_claim(
print(f"Entry ID written to {entry_id_path}")


def retrieve_claim(url: str, entry_id: Path, claim_path: Path):
response = httpx.get(f"{url}/entries/{entry_id}")
raise_for_status(response)
def retrieve_claim(url: str, entry_id: Path, claim_path: Path, client: HttpClient):
response = client.get(f"{url}/entries/{entry_id}")
claim = response.content

with open(claim_path, "wb") as f:
Expand All @@ -73,9 +123,8 @@ def retrieve_claim(url: str, entry_id: Path, claim_path: Path):
print(f"Claim written to {claim_path}")


def retrieve_receipt(url: str, entry_id: Path, receipt_path: Path):
response = httpx.get(f"{url}/entries/{entry_id}/receipt")
raise_for_status(response)
def retrieve_receipt(url: str, entry_id: Path, receipt_path: Path, client: HttpClient):
response = client.get(f"{url}/entries/{entry_id}/receipt")
receipt = response.content

with open(receipt_path, "wb") as f:
Expand Down Expand Up @@ -123,26 +172,35 @@ def cli(fn):
help="Path to write the entry id to",
)
p.add_argument("--url", required=False, default=DEFAULT_URL)
p.add_argument("--cacert", type=Path, help="CA certificate to verify host against")
p.set_defaults(
func=lambda args: submit_claim(
args.url, args.claim, args.out, args.out_entry_id
args.url, args.claim, args.out, args.out_entry_id, HttpClient(args.cacert)
)
)

p = sub.add_parser("retrieve-claim", description="Retrieve a SCITT claim")
p.add_argument("--entry-id", required=True, type=str)
p.add_argument("--out", required=True, type=Path, help="Path to write the claim to")
p.add_argument("--url", required=False, default=DEFAULT_URL)
p.set_defaults(func=lambda args: retrieve_claim(args.url, args.entry_id, args.out))
p.add_argument("--cacert", type=Path, help="CA certificate to verify host against")
p.set_defaults(
func=lambda args: retrieve_claim(
args.url, args.entry_id, args.out, HttpClient(args.cacert)
)
)

p = sub.add_parser("retrieve-receipt", description="Retrieve a SCITT receipt")
p.add_argument("--entry-id", required=True, type=str)
p.add_argument(
"--out", required=True, type=Path, help="Path to write the receipt to"
)
p.add_argument("--url", required=False, default=DEFAULT_URL)
p.add_argument("--cacert", type=Path, help="CA certificate to verify host against")
p.set_defaults(
func=lambda args: retrieve_receipt(args.url, args.entry_id, args.out)
func=lambda args: retrieve_receipt(
args.url, args.entry_id, args.out, HttpClient(args.cacert)
)
)

p = sub.add_parser("verify-receipt", description="Verify a SCITT receipt")
Expand Down
87 changes: 81 additions & 6 deletions scitt_emulator/scitt.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path
import time
import json
import uuid

import cbor2
from pycose.messages import CoseMessage, Sign1Message
Expand All @@ -30,13 +31,21 @@ class EntryNotFoundError(Exception):
pass


class OperationNotFoundError(Exception):
pass


class SCITTServiceEmulator(ABC):
def __init__(
self, service_parameters_path: Path, storage_path: Optional[Path] = None
):
self.storage_path = storage_path
self.service_parameters_path = service_parameters_path

if storage_path is not None:
self.operations_path = storage_path / "operations"
self.operations_path.mkdir(exist_ok=True)

if self.service_parameters_path.exists():
with open(self.service_parameters_path) as f:
self.service_parameters = json.load(f)
Expand All @@ -53,6 +62,28 @@ def create_receipt_contents(self, countersign_tbi: bytes, entry_id: str):
def verify_receipt_contents(receipt_contents: list, countersign_tbi: bytes):
raise NotImplementedError

def get_operation(self, operation_id: str) -> dict:
operation_path = self.operations_path / f"{operation_id}.json"
try:
with open(operation_path, "r") as f:
operation = json.load(f)
except FileNotFoundError:
raise EntryNotFoundError(f"Operation {operation_id} not found")

if operation["status"] == "running":
# Pretend that the service finishes the operation after
# the client having checked the operation status once.
operation = self._finish_operation(operation)
return operation

def get_entry(self, entry_id: str) -> dict:
try:
self.get_claim(entry_id)
except EntryNotFoundError:
raise
# More metadata to follow in the future.
return { "entryId": entry_id }

def get_claim(self, entry_id: str) -> bytes:
claim_path = self.storage_path / f"{entry_id}.cose"
try:
Expand All @@ -62,29 +93,73 @@ def get_claim(self, entry_id: str) -> bytes:
raise EntryNotFoundError(f"Entry {entry_id} not found")
return claim

def submit_claim(self, claim: bytes):
def submit_claim(self, claim: bytes, long_running=True) -> dict:
if long_running:
return self._create_operation(claim)
else:
return self._create_entry(claim)

def _create_entry(self, claim: bytes) -> dict:
last_entry_path = self.storage_path / "last_entry_id.txt"
if last_entry_path.exists():
with open(last_entry_path, "r") as f:
last_entry_id = int(f.read())
else:
last_entry_id = 0

entry_id = last_entry_id + 1
entry_id = str(last_entry_id + 1)

self._create_receipt(claim, entry_id)

last_entry_path.write_text(entry_id)

claim_path = self.storage_path / f"{entry_id}.cose"
claim_path.write_bytes(claim)

print(f"Claim written to {claim_path}")

entry = {"entryId": entry_id}
return entry

def _create_operation(self, claim: bytes):
operation_id = str(uuid.uuid4())
operation_path = self.operations_path / f"{operation_id}.json"
claim_path = self.operations_path / f"{operation_id}.cose"

operation = {
"operationId": operation_id,
"status": "running"
}

with open(operation_path, "w") as f:
json.dump(operation, f)

with open(claim_path, "wb") as f:
f.write(claim)

print(f"Operation {operation_id} created")
print(f"Claim written to {claim_path}")

with open(last_entry_path, "w") as f:
f.write(str(entry_id))
return operation

def _finish_operation(self, operation: dict):
operation_id = operation["operationId"]
operation_path = self.operations_path / f"{operation_id}.json"
claim_src_path = self.operations_path / f"{operation_id}.cose"

claim = claim_src_path.read_bytes()
entry = self._create_entry(claim)
claim_src_path.unlink()

operation["status"] = "succeeded"
operation["entryId"] = entry["entryId"]

with open(operation_path, "w") as f:
json.dump(operation, f)

return entry_id
return operation

def _create_receipt(self, claim: Path, entry_id: str):
def _create_receipt(self, claim: bytes, entry_id: str):
# Validate claim
# Note: This emulator does not verify the claim signature and does not apply
# registration policies.
Expand Down
Loading