Skip to content
Open
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
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,61 @@ The system is designed to scale horizontally. The conserver service can be scale
docker compose up --scale conserver=4 -d
```

## SCITT Lifecycle Registration

The `links.scitt` module registers vCon lifecycle events on a [SCRAPI](https://datatracker.ietf.org/doc/draft-ietf-scitt-scrapi/)-compatible transparency service, creating an immutable audit trail per [draft-howe-vcon-lifecycle](https://www.ietf.org/archive/id/draft-howe-vcon-lifecycle-00.html).

Each registration creates a COSE Sign1 signed statement from the vCon's SHA-256 hash and registers it via `POST /entries`. The receipt is stored as a `scitt_receipt` analysis entry on the vCon.

### Configuration

```yaml
links:
scitt_created:
module: links.scitt
options:
scrapi_url: http://scittles:8000 # SCRAPI service URL
signing_key_path: /etc/scitt/signing-key.pem # EC P-256 key
issuer: conserver # CWT issuer claim
key_id: conserver-key-1 # COSE key ID
vcon_operation: vcon_created # Lifecycle event type

scitt_enhanced:
module: links.scitt
options:
scrapi_url: http://scittles:8000
signing_key_path: /etc/scitt/signing-key.pem
issuer: conserver
key_id: conserver-key-1
vcon_operation: vcon_enhanced
```

Use two instances in a chain to capture the vCon hash before and after transcription:

```yaml
chains:
transcription_chain:
links:
- tag
- scitt_created # Hash before transcription
- wtf_transcribe
- keyword_tagger
- scitt_enhanced # Hash after transcription
- expire_vcon
```

### Signing Key

Generate an EC P-256 signing key:

```bash
openssl ecparam -name prime256v1 -genkey -noout -out scitt-signing-key.pem
```

### Transparency Service

The link is compatible with any SCRAPI service. [SCITTLEs](https://github.com/vcon-dev/scittles) is a lightweight, self-hosted option using SQLite.

## Storage Modules

### PostgreSQL Storage
Expand Down
167 changes: 84 additions & 83 deletions server/links/scitt/__init__.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
import os
import requests
from links.scitt import create_hashed_signed_statement, register_signed_statement
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException
from lib.vcon_redis import VconRedis
from lib.logging_utils import init_logger
from starlette.status import HTTP_404_NOT_FOUND, HTTP_501_NOT_IMPLEMENTED

import hashlib
import json
import requests
from starlette.status import HTTP_404_NOT_FOUND

logger = init_logger(__name__)

# Increment for any API/attribute changes
link_version = "0.1.0"
link_version = "0.3.0"

default_options = {
"client_id": "<set-in-config.yml>",
"client_secret": "<set-in-config.yml>",
"scrapi_url": "https://app.datatrails.ai/archivist/v2",
"auth_url": "https://app.datatrails.ai/archivist/iam/v1/appidp/token",
"signing_key_path": None,
"issuer": "ANONYMOUS CONSERVER"
"scrapi_url": "http://scittles:8000",
"signing_key_path": "/etc/scitt/signing-key.pem",
"issuer": "conserver",
"key_id": "conserver-key-1",
"vcon_operation": "vcon_created",
"store_receipt": True,
}

def run(
Expand All @@ -31,98 +25,105 @@ def run(
opts: dict = default_options
) -> str:
"""
Main function to run the SCITT link.
SCITT lifecycle registration link.

Creates a COSE Sign1 signed statement from the vCon hash and registers
it on a SCRAPI-compatible Transparency Service (SCITTLEs).

This function creates a SCITT Signed Statement based on the vCon data,
registering it on a SCITT Transparency Service.
The vcon_operation option controls the lifecycle event type:
- "vcon_created": registered before transcription
- "vcon_enhanced": registered after transcription

Args:
vcon_uuid (str): UUID of the vCon to process.
link_name (str): Name of the link (for logging purposes).
opts (dict): Options for the link, including API URLs and credentials.
vcon_uuid: UUID of the vCon to process.
link_name: Name of the link instance (for logging).
opts: Configuration options.

Returns:
str: The UUID of the processed vCon.

Raises:
ValueError: If client_id or client_secret is not provided in the options.
The UUID of the processed vCon.
"""
module_name = __name__.split(".")[-1]
logger.info(f"Starting {module_name}: {link_name} plugin for: {vcon_uuid}")
logger.info(f"Starting {module_name}: {link_name} for: {vcon_uuid}")
merged_opts = default_options.copy()
merged_opts.update(opts)
opts = merged_opts

if not opts["client_id"] or not opts["client_secret"]:
raise ValueError(f"{module_name} client ID and client secret must be provided")

# Get the vCon
# Get the vCon from Redis
vcon_redis = VconRedis()
vcon = vcon_redis.get_vcon(vcon_uuid)
if not vcon:
logger.info(f"{link_name}: vCon not found: {vcon_uuid}")
logger.info(f"{link_name}: vCon not found: {vcon_uuid}")
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"vCon not found: {vcon_uuid}"
)

###############################
# Create a Signed Statement
###############################

# Set the subject to the vcon identifier
subject = vcon.subject or f"vcon://{vcon_uuid}"

# SCITT metadata for the vCon
meta_map = {
"vcon_operation" : opts["vcon_operation"]
}
# Set the payload to the hash of the vCon consistent with
# cose-hash-envelope: https://datatracker.ietf.org/doc/draft-steele-cose-hash-envelope

# Build per-participant SCITT registrations
payload = vcon.hash
# TODO: pull hash_alg from the vcon
payload_hash_alg = "SHA-256"
# TODO: pull the payload_location from the vcon.url
payload_location = "" # vcon.url

key_id = opts["key_id"]
operation = opts["vcon_operation"]

signing_key_path = os.path.join(opts["signing_key_path"])
signing_key_path = opts["signing_key_path"]
signing_key = create_hashed_signed_statement.open_signing_key(signing_key_path)

signed_statement = create_hashed_signed_statement.create_hashed_signed_statement(
issuer=opts["issuer"],
signing_key=signing_key,
subject=subject,
kid=key_id.encode('utf-8'),
meta_map=meta_map,
payload=payload.encode('utf-8'),
payload_hash_alg=payload_hash_alg,
payload_location=payload_location,
pre_image_content_type="application/vcon+json"
)
logger.info(f"signed_statement: {signed_statement}")

###############################
# Register the Signed Statement
###############################

# Construct an OIDC Auth Object
oidc_flow = opts["OIDC_flow"]
if oidc_flow == "client-credentials":
auth = register_signed_statement.OIDC_Auth(opts)
else:
raise HTTPException(
status_code=HTTP_501_NOT_IMPLEMENTED,
detail=f"OIDC_flow not found or unsupported. OIDC_flow: {oidc_flow}"
# Collect tel URIs from parties (Party objects use attrs, dicts use keys)
party_tels = []
for party in (vcon.parties or []):
tel = party.get("tel") if isinstance(party, dict) else getattr(party, "tel", None)
if tel:
party_tels.append(tel)
else:
logger.warning(f"{link_name}: party without tel in {vcon_uuid}, skipping")

# Fall back to vcon:// subject if no parties have tel
if not party_tels:
party_tels = [None]

scrapi_url = opts["scrapi_url"]
receipts = []

for tel in party_tels:
if tel:
subject = f"tel:{tel}"
operation_payload = f"{payload}:{operation}:{tel}"
meta_map = {"vcon_operation": operation, "party_tel": tel}
else:
subject = f"vcon://{vcon_uuid}"
operation_payload = f"{payload}:{operation}"
meta_map = {"vcon_operation": operation}

signed_statement = create_hashed_signed_statement.create_hashed_signed_statement(
issuer=opts["issuer"],
signing_key=signing_key,
subject=subject,
kid=opts["key_id"].encode("utf-8"),
meta_map=meta_map,
payload=operation_payload.encode("utf-8"),
payload_hash_alg="SHA-256",
payload_location="",
pre_image_content_type="application/vcon+json",
)

operation_id = register_signed_statement.register_statement(
opts=opts,
auth=auth,
signed_statement=signed_statement
)
logger.info(f"operation_id: {operation_id}")
logger.info(f"{link_name}: Created signed statement for {vcon_uuid} subject={subject} ({operation})")

result = register_signed_statement.register_statement(scrapi_url, signed_statement)
logger.info(f"{link_name}: Registered entry_id={result['entry_id']} subject={subject} for {vcon_uuid}")

receipts.append({
"entry_id": result["entry_id"],
"vcon_operation": operation,
"subject": subject,
"vcon_hash": payload,
"scrapi_url": scrapi_url,
})

# Store receipts as analysis entry on the vCon
if opts.get("store_receipt", True):
vcon.add_analysis(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@howethomas Should it got to attachments instead of analysis?

type="scitt_receipt",
dialog=0,
vendor="scittles",
body=receipts if len(receipts) > 1 else receipts[0],
)
vcon_redis.store_vcon(vcon)
logger.info(f"{link_name}: Stored {len(receipts)} SCITT receipt(s) for {vcon_uuid}")

return vcon_uuid
Loading
Loading