Skip to content

Commit f663f91

Browse files
authored
Merge pull request #151 from vcon-dev/feature/scitt-v0.3.0
SCITT v0.3.0: cose_receipt storage, inclusion proof verification, signing key injection
2 parents 9fb177d + f716f68 commit f663f91

5 files changed

Lines changed: 612 additions & 365 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ tmp
2323
traefik/
2424
redis_data/
2525
litellm_config.yaml
26+
litellm_config.yml

conserver/links/scitt/__init__.py

Lines changed: 174 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,128 +1,218 @@
1-
import os
1+
import base64
2+
import hashlib
3+
import cbor2
24
import requests
5+
from ecdsa import SigningKey
6+
from pycose.messages import Sign1Message
7+
from pycose.keys.ec2 import EC2Key
8+
from pycose.keys.curves import P256
39
from links.scitt import create_hashed_signed_statement, register_signed_statement
4-
from datetime import datetime, timedelta, timezone
510
from fastapi import HTTPException
611
from lib.vcon_redis import VconRedis
712
from lib.logging_utils import init_logger
8-
from starlette.status import HTTP_404_NOT_FOUND, HTTP_501_NOT_IMPLEMENTED
9-
10-
import hashlib
11-
import json
12-
import requests
13+
from starlette.status import HTTP_404_NOT_FOUND
1314

1415
logger = init_logger(__name__)
1516

1617
# Increment for any API/attribute changes
17-
link_version = "0.1.0"
18+
link_version = "0.3.0"
1819

1920
default_options = {
20-
"client_id": "<set-in-config.yml>",
21-
"client_secret": "<set-in-config.yml>",
22-
"scrapi_url": "https://app.datatrails.ai/archivist/v2",
23-
"auth_url": "https://app.datatrails.ai/archivist/iam/v1/appidp/token",
24-
"signing_key_path": None,
25-
"issuer": "ANONYMOUS CONSERVER"
21+
"scrapi_url": "http://scittles:8000",
22+
"signing_key_pem": None, # Base64-encoded PEM (preferred for containers/k8s)
23+
"signing_key_path": "/etc/scitt/signing-key.pem", # Fallback for local dev
24+
"issuer": "conserver",
25+
"key_id": "conserver-key-1",
26+
"vcon_operation": "vcon_created",
27+
"store_receipt": True,
2628
}
2729

30+
_LEAF_PREFIX = b"\x00"
31+
_NODE_PREFIX = b"\x01"
32+
33+
34+
def _compute_root(leaf_hash: bytes, leaf_index: int, tree_size: int, siblings: list) -> bytes:
35+
"""Walk RFC 9162 inclusion proof and return the recomputed Merkle root."""
36+
current = leaf_hash
37+
current_index = leaf_index
38+
current_size = tree_size
39+
proof_idx = 0
40+
while current_size > 1:
41+
if current_index == current_size - 1 and current_size % 2 == 1:
42+
current_index //= 2
43+
current_size = (current_size + 1) // 2
44+
continue
45+
sibling = siblings[proof_idx]
46+
proof_idx += 1
47+
if current_index % 2 == 0:
48+
current = hashlib.sha256(_NODE_PREFIX + current + sibling).digest()
49+
else:
50+
current = hashlib.sha256(_NODE_PREFIX + sibling + current).digest()
51+
current_index //= 2
52+
current_size = (current_size + 1) // 2
53+
return current
54+
55+
56+
def _verify_cose_receipt(receipt_bytes: bytes, statement_hash: bytes, scrapi_url: str) -> None:
57+
"""
58+
Verify a COSE receipt before storing it.
59+
60+
1. Parse COSE Sign1 → extract inclusion proof (leaf_index, tree_size, siblings)
61+
2. Compute leaf_hash = SHA-256(0x00 || statement_hash) per RFC 9162
62+
3. Walk proof → recompute root_hash
63+
4. Fetch transparency service public key from JWKS
64+
5. Verify COSE Sign1 signature with recomputed root_hash as detached payload
65+
66+
Raises ValueError if any step fails.
67+
"""
68+
# Step 1: parse receipt and extract inclusion proof
69+
msg = Sign1Message.decode(receipt_bytes)
70+
proofs_map = msg.uhdr.get(396, {})
71+
proofs_raw = proofs_map.get(-1, [])
72+
if not proofs_raw:
73+
raise ValueError("cose_receipt is missing inclusion proof (uhdr label 396/-1)")
74+
tree_size, leaf_index, siblings = cbor2.loads(proofs_raw[0])
75+
76+
# Step 2: leaf hash per RFC 9162 (0x00 || statement_hash)
77+
leaf_hash = hashlib.sha256(_LEAF_PREFIX + statement_hash).digest()
78+
79+
# Step 3: recompute Merkle root from inclusion proof
80+
root_hash = _compute_root(leaf_hash, leaf_index, tree_size, siblings)
81+
82+
# Step 4: fetch JWKS — discover jwks_uri from transparency-configuration first
83+
config_resp = requests.get(
84+
f"{scrapi_url}/.well-known/transparency-configuration", timeout=10
85+
)
86+
config_resp.raise_for_status()
87+
config = cbor2.loads(config_resp.content)
88+
jwks_uri = config.get("jwks_uri") or f"{scrapi_url}/jwks"
89+
jwks_resp = requests.get(jwks_uri, timeout=10)
90+
jwks_resp.raise_for_status()
91+
jwk = jwks_resp.json()["keys"][0]
92+
93+
x_bytes = base64.urlsafe_b64decode(jwk["x"] + "==")
94+
y_bytes = base64.urlsafe_b64decode(jwk["y"] + "==")
95+
cose_key = EC2Key(crv=P256, x=x_bytes, y=y_bytes)
96+
97+
# Step 5: verify COSE Sign1 signature with recomputed root as detached payload
98+
msg.key = cose_key
99+
if not msg.verify_signature(detached_payload=root_hash):
100+
raise ValueError("cose_receipt signature verification failed — receipt is not authentic")
101+
102+
28103
def run(
29104
vcon_uuid: str,
30105
link_name: str,
31106
opts: dict = default_options
32107
) -> str:
33108
"""
34-
Main function to run the SCITT link.
109+
SCITT lifecycle registration link.
35110
36-
This function creates a SCITT Signed Statement based on the vCon data,
37-
registering it on a SCITT Transparency Service.
111+
Creates a COSE Sign1 signed statement from the vCon hash and registers
112+
it on a SCRAPI-compatible Transparency Service (SCITTLEs).
113+
114+
The vcon_operation option controls the lifecycle event type:
115+
- "vcon_created": registered before transcription
116+
- "vcon_enhanced": registered after transcription
38117
39118
Args:
40-
vcon_uuid (str): UUID of the vCon to process.
41-
link_name (str): Name of the link (for logging purposes).
42-
opts (dict): Options for the link, including API URLs and credentials.
119+
vcon_uuid: UUID of the vCon to process.
120+
link_name: Name of the link instance (for logging).
121+
opts: Configuration options.
43122
44123
Returns:
45-
str: The UUID of the processed vCon.
46-
47-
Raises:
48-
ValueError: If client_id or client_secret is not provided in the options.
124+
The UUID of the processed vCon.
49125
"""
50126
module_name = __name__.split(".")[-1]
51-
logger.info(f"Starting {module_name}: {link_name} plugin for: {vcon_uuid}")
127+
logger.info(f"Starting {module_name}: {link_name} for: {vcon_uuid}")
52128
merged_opts = default_options.copy()
53129
merged_opts.update(opts)
54130
opts = merged_opts
55131

56-
if not opts["client_id"] or not opts["client_secret"]:
57-
raise ValueError(f"{module_name} client ID and client secret must be provided")
58-
59-
# Get the vCon
132+
# Get the vCon from Redis
60133
vcon_redis = VconRedis()
61134
vcon = vcon_redis.get_vcon(vcon_uuid)
62135
if not vcon:
63-
logger.info(f"{link_name}: vCon not found: {vcon_uuid}")
136+
logger.info(f"{link_name}: vCon not found: {vcon_uuid}")
64137
raise HTTPException(
65138
status_code=HTTP_404_NOT_FOUND,
66139
detail=f"vCon not found: {vcon_uuid}"
67140
)
68141

69-
###############################
70-
# Create a Signed Statement
71-
###############################
72-
73-
# Set the subject to the vcon identifier
74-
subject = vcon.subject or f"vcon://{vcon_uuid}"
75-
76-
# SCITT metadata for the vCon
77-
meta_map = {
78-
"vcon_operation" : opts["vcon_operation"]
79-
}
80-
# Set the payload to the hash of the vCon consistent with
81-
# cose-hash-envelope: https://datatracker.ietf.org/doc/draft-steele-cose-hash-envelope
82-
142+
# Build per-participant SCITT registrations
83143
payload = vcon.hash
84-
# TODO: pull hash_alg from the vcon
85-
payload_hash_alg = "SHA-256"
86-
# TODO: pull the payload_location from the vcon.url
87-
payload_location = "" # vcon.url
88-
89-
key_id = opts["key_id"]
90-
91-
signing_key_path = os.path.join(opts["signing_key_path"])
92-
signing_key = create_hashed_signed_statement.open_signing_key(signing_key_path)
93-
94-
signed_statement = create_hashed_signed_statement.create_hashed_signed_statement(
95-
issuer=opts["issuer"],
96-
signing_key=signing_key,
97-
subject=subject,
98-
kid=key_id.encode('utf-8'),
99-
meta_map=meta_map,
100-
payload=payload.encode('utf-8'),
101-
payload_hash_alg=payload_hash_alg,
102-
payload_location=payload_location,
103-
pre_image_content_type="application/vcon+json"
104-
)
105-
logger.info(f"signed_statement: {signed_statement}")
106-
107-
###############################
108-
# Register the Signed Statement
109-
###############################
144+
operation = opts["vcon_operation"]
110145

111-
# Construct an OIDC Auth Object
112-
oidc_flow = opts["OIDC_flow"]
113-
if oidc_flow == "client-credentials":
114-
auth = register_signed_statement.OIDC_Auth(opts)
146+
if opts.get("signing_key_pem"):
147+
pem = base64.b64decode(opts["signing_key_pem"]).decode("utf-8")
148+
signing_key = SigningKey.from_pem(pem, hashlib.sha256)
115149
else:
116-
raise HTTPException(
117-
status_code=HTTP_501_NOT_IMPLEMENTED,
118-
detail=f"OIDC_flow not found or unsupported. OIDC_flow: {oidc_flow}"
150+
signing_key = create_hashed_signed_statement.open_signing_key(opts["signing_key_path"])
151+
152+
# Collect tel URIs from parties (Party objects use attrs, dicts use keys)
153+
party_tels = []
154+
for party in (vcon.parties or []):
155+
tel = party.get("tel") if isinstance(party, dict) else getattr(party, "tel", None)
156+
if tel:
157+
party_tels.append(tel)
158+
else:
159+
logger.warning(f"{link_name}: party without tel in {vcon_uuid}, skipping")
160+
161+
# Fall back to vcon:// subject if no parties have tel
162+
if not party_tels:
163+
party_tels = [None]
164+
165+
scrapi_url = opts["scrapi_url"]
166+
receipts = []
167+
168+
for tel in party_tels:
169+
if tel:
170+
subject = f"tel:{tel}"
171+
operation_payload = f"{payload}:{operation}:{tel}"
172+
meta_map = {"vcon_operation": operation, "party_tel": tel}
173+
else:
174+
subject = f"vcon://{vcon_uuid}"
175+
operation_payload = f"{payload}:{operation}"
176+
meta_map = {"vcon_operation": operation}
177+
178+
signed_statement = create_hashed_signed_statement.create_hashed_signed_statement(
179+
issuer=opts["issuer"],
180+
signing_key=signing_key,
181+
subject=subject,
182+
kid=opts["key_id"].encode("utf-8"),
183+
meta_map=meta_map,
184+
payload=operation_payload.encode("utf-8"),
185+
payload_hash_alg="SHA-256",
186+
payload_location="",
187+
pre_image_content_type="application/vcon+json",
119188
)
120-
121-
operation_id = register_signed_statement.register_statement(
122-
opts=opts,
123-
auth=auth,
124-
signed_statement=signed_statement
125-
)
126-
logger.info(f"operation_id: {operation_id}")
189+
logger.info(f"{link_name}: Created signed statement for {vcon_uuid} subject={subject} ({operation})")
190+
191+
result = register_signed_statement.register_statement(scrapi_url, signed_statement)
192+
logger.info(f"{link_name}: Registered entry_id={result['entry_id']} subject={subject} for {vcon_uuid}")
193+
194+
statement_hash = hashlib.sha256(operation_payload.encode("utf-8")).digest()
195+
_verify_cose_receipt(result["receipt"], statement_hash, scrapi_url)
196+
logger.info(f"{link_name}: Receipt verified for entry_id={result['entry_id']}")
197+
198+
receipts.append({
199+
"entry_id": result["entry_id"],
200+
"cose_receipt": base64.b64encode(result["receipt"]).decode(),
201+
"vcon_operation": operation,
202+
"subject": subject,
203+
"vcon_hash": payload,
204+
"scrapi_url": scrapi_url,
205+
})
206+
207+
# Store receipts as analysis entry on the vCon
208+
if opts.get("store_receipt", True):
209+
vcon.add_analysis(
210+
type="scitt_receipt",
211+
dialog=0,
212+
vendor="scittles",
213+
body=receipts if len(receipts) > 1 else receipts[0],
214+
)
215+
vcon_redis.store_vcon(vcon)
216+
logger.info(f"{link_name}: Stored {len(receipts)} SCITT receipt(s) for {vcon_uuid}")
127217

128218
return vcon_uuid

0 commit comments

Comments
 (0)