Skip to main content

Generic, payload-agnostic IETF SCITT + COSE Receipts substrate for Python (receipts strictly RFC9162_SHA256).

Project description

scitt-cose

A generic, payload-agnostic IETF SCITT + COSE Receipts substrate for Python: build/verify COSE_Sign1 Signed Statements, verify Receipts and RFC 9162 inclusion / consistency proofs, with the Merkle + receipt-signing primitives.

It is NOT a transparency service (operating a log — a hosted registration endpoint — is a separate concern), and it carries NO application profile — bring your own statement semantics. The only third-party dependencies anywhere in the package are cbor2 and cryptography, plus the standard library. COSE_Sign1 (RFC 9052) is implemented here from scratch — it does not use python-cwt or any other COSE library.

Name note. scitt-cose is the finalized package name, claimed on PyPI and GitHub (action-state-group/scitt-cose) in the same pass.

What this does / does not do

Does:

  • Verify a SCITT Signed Statement (COSE_Sign1) signature against a supplied key — EdDSA and ES256 — and report its issuer / subject / content-type / alg.
  • Verify a COSE Receipt whose verifiable data structure is RFC9162_SHA256 (vds = 1, the tree algorithm registered by draft-ietf-cose-merkle-tree-proofs): the RFC 9162 SHA-256 inclusion proof and the log's signature over the reconstructed root — i.e. "this statement is provably in the log"without trusting the log operator. The vds value is read from the protected header only and anything other than RFC9162_SHA256 is rejected, not silently accepted.
  • Provide the RFC 9162 Merkle primitives (root, inclusion, consistency) and a build_receipt primitive.

Does NOT:

  • Operate a Transparency Service. It never registers statements, issues receipts, anchors, or stores anything. Running a log is a separate concern with its own operational trust obligations.
  • Validate any application profile's payload semantics. The statement payload is treated as opaque bytes. There is no application-profile awareness — SBOMs, agent actions, or anything else; that neutrality is deliberate, and is what makes this reusable by anyone in the SCITT ecosystem.
  • Depend on a COSE library for wire values. COSE_Sign1 is implemented from scratch over cbor2 + cryptography; code points are pinned to the IANA registries, not to a library's enum.

Why this exists

This is a standalone, profile-opaque, cross-validated generic SCITT/COSE verifier: it verifies anyone's SCITT Signed Statements and COSE Receipts, treats the payload as opaque bytes, and its conformance is checked against independent, external references (the published RFC 6962/9162 Merkle vectors, a third-party COSE library, and a separate Go implementation — see Correctness & cross-implementation evidence).

It is intended to be useful as neutral substrate: the parts that exist today don't fit that gap cleanly — python-cwt is COSE-only (no SCITT/Receipts), and a transparency-service emulator is a server, not a verification library you depend on. So this is the small building block — a second, independent implementation you can verify against, with no profile baked in. (No primacy is claimed; the value is neutrality + verifiable conformance, not being "first".)

An agent-action profile is one example consumer that builds its statement/claim semantics on top of this substrate — but nothing profile-specific lives here.

Provenance, neutrality & governance

Three things an adopter should know up front:

  1. Built by Action State Group. We built this for our own SCITT use and publish it as community substrate under Apache-2.0.
  2. Neutral by design. No application profile, no vendor coupling, payload-opaque. The package imports only cbor2, cryptography, and the standard library — it does not import any Action State code, and a test gate (tests/test_iana_codepoints.py) enforces that this stays true.
  3. Foundation intent. We intend to contribute this project to a neutral open-source foundation so its governance does not rest with any single vendor. Which foundation is deliberately not yet decided; until then it is governed in the open under DCO + Apache-2.0 (see CONTRIBUTING.md).
  4. Development. This project is built and maintained by Action State Group, with AI-assisted engineering (Claude Code) used as a development tool. Every contribution is human-reviewed and DCO-signed. Correctness does not rest on trusting any author — human or otherwise: conformance is enforced in CI by the published RFC 6962/9162 test vectors, an independent clean-room Go implementation, and cross-checks against a third-party COSE library, none of which can be skipped.

Scope

In scope (library plumbing):

  • Build / parse / verify Signed Statements and Transparent Statements.
  • Verify Receipts + RFC 9162 inclusion and consistency proofs.
  • RFC 9162 SHA-256 Merkle primitives and the receipt-signing primitive (build_receipt) — so you can mint receipts as a building block.

Out of scope (documented, deliberately not built):

  • Operating a transparency log / service. A hosted registration endpoint, log storage, monitoring, witnessing, gossip — none of that is here. The primitives needed to build one (Merkle tree, receipt signing) are included.

Install

pip install cbor2 cryptography          # runtime deps
# from a checkout (package root):
pip install -e .

Quick start

from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.hazmat.primitives import serialization
from scitt_cose import (
    build_signed_statement, parse_signed_statement,
    merkle_root, inclusion_proof, verify_inclusion,
    consistency_proof, verify_consistency,
    build_receipt, verify_receipt,
    attach_receipts, extract_receipts,
)

# --- a key (caller supplies everything) ---
sk = ed25519.Ed25519PrivateKey.generate()
priv = sk.private_bytes(serialization.Encoding.PEM,
                        serialization.PrivateFormat.PKCS8,
                        serialization.NoEncryption())
pub = sk.public_key().public_bytes(serialization.Encoding.PEM,
                                    serialization.PublicFormat.SubjectPublicKeyInfo)

# --- Signed Statement (generic; no profile) ---
stmt = build_signed_statement(
    b'{"hello":"world"}',
    alg="EdDSA", private_key_pem=priv,
    issuer="https://issuer.example",
    subject="my-artifact",
    content_type="application/json",
    extra_cwt_claims={"my_claim": "x", 99: 1},  # str- or int-keyed
)
parsed = parse_signed_statement(stmt, public_key_pem=pub)
assert parsed["signature_verified"] is True
assert parsed["issuer"] == "https://issuer.example"

# --- Merkle (RFC 9162 SHA-256; hex in/out) ---
entries = [b"a".hex(), b"b".hex(), b"c".hex(), b"d".hex()]
root = merkle_root(entries)
path = inclusion_proof(entries, 2)
assert verify_inclusion(entries[2], 2, len(entries), path, root)

cproof = consistency_proof(entries, 2, 4)
assert verify_consistency(merkle_root(entries[:2]), root, 2, 4, cproof)

# --- Receipt (primitive) + verify ---
receipt = build_receipt(
    leaf_entry_hex=entries[2], leaf_index=2, tree_entries_hex=entries,
    alg="EdDSA", log_private_key_pem=priv,     # here the "log" key is our key
)
res = verify_receipt(receipt, leaf_entry_hex=entries[2], log_public_key_pem=pub)
assert res.ok and res.root == root

# --- Transparent Statement = Signed Statement + Receipts ---
transparent = attach_receipts(stmt, [receipt])
assert extract_receipts(transparent) == [receipt]

CLI

scitt-cose --statement stmt.cose --statement-pubkey issuer.pem
scitt-cose --receipt receipt.cose --receipt-log-pubkey log.pem --leaf-entry-hex 61
scitt-cose --statement stmt.cose --receipt receipt.cose \
           --receipt-log-pubkey log.pem --leaf-entry-hex 61 --json

Public API

Area Functions / types
COSE_Sign1 sign_sign1, verify_sign1, Sign1, CoseError
Statements build_signed_statement, parse_signed_statement, attach_receipts, extract_receipts
Merkle leaf_hash, merkle_root, inclusion_proof, verify_inclusion, consistency_proof, verify_consistency
Receipts build_receipt, verify_receipt, ReceiptResult
Status DRAFT_TRACKING_NOTICE, DRAFT_SCITT_ARCHITECTURE, DRAFT_COSE_MERKLE_TREE_PROOFS, SUBSTRATE_RFCS

Failure contract

A verifier's behaviour on hostile and malformed input is part of its API, so it is spelled out here and enforced by tests (tests/test_hardening.py) and by continuous differential fuzzing (see below).

  • parse_signed_statement and verify_receipt never raise on input. Every outcome — bad signature, malformed CBOR, truncation, a hostile length or tree size — is reported in the returned value (signature_verified / ReceiptResult.ok), never as a leaked cbor2 / RecursionError / other exception. A CoseError is reserved for the low-level primitive verify_sign1 (a building block), which raises on failure by design.
  • Identity is authenticated-only. parse_signed_statement populates issuer / subject / content_type / alg / claims only when signature_verified is True. When the signature did not verify (wrong key, bad signature) or no key was supplied, those top-level fields are None, and the structurally-decoded values are fenced under an explicit unverified sub-dict — so an integrator can never mistake an attacker-chosen issuer for a signed one. verified ⇒ trust the fields; not verified ⇒ they are under unverified`, clearly labelled.
  • Strict, non-malleable decoding. Both verify paths reject trailing bytes after the COSE_Sign1, indefinite-length / non-deterministic encodings, and duplicate protected-header keys — so one signature cannot be presented in many byte encodings, and the Python and Go verifiers agree on accept and reject.
  • Bounded cost. Messages are size-capped; tree sizes and inclusion-proof lengths are bounded and length-checked before any hashing or allocation, and no decoded integer is ever coerced to bytes before validation. A tiny hostile receipt cannot trigger a multi-gigabyte allocation or an unbounded loop on either runtime.

See docs/hardening-review.md for the review, the finding-by-finding resolutions, and the consciously-accepted items.

Algorithms

sign_sign1 / statements support "EdDSA" (code point −8) and "ES256" (−7). For ES256, COSE carries the signature as raw r || s (64 bytes), not DER; the conversion happens at the cryptography boundary. ML-DSA code points (RFC 9964) are recognized in the status notice but signing is not implemented here.

ES256 signatures are accepted in their canonical form as verified by cryptography; s-value malleability (high-s) is not rejected — see the Security Considerations in docs/hardening-review.md for why that is a documented, accepted property rather than a bug.

Why CWT Claims at label 15 (not 13)

Signed-Statement CWT Claims are placed in the protected header at label 15, the "CWT Claims" header parameter registered by RFC 9597 §2. Label 13 is a different parameter (kcwt, RFC 9528) and is sometimes mistakenly used for the claims map; this library always reads and writes the claims at 15.

Receipt vdp encoding (honest caveat)

The Receipt's verifiable-data-proof shape tracks draft-ietf-cose-merkle-tree-proofs-18:

  • protected 1 = alg, protected 395 = vds (1 = RFC9162_SHA256);
  • unprotected 396 = vdp map with key -1 → array of inclusion-proof bstrs;
  • each inclusion-proof bstr = cbor([tree_size, leaf_index, [audit_path bstrs]]);
  • payload = the Merkle root (detached by default).

verify_receipt reads vds from the protected header only (it is security-relevant and must be integrity-protected), reconstructs the root from the proof, and verifies the COSE_Sign1 over that root with the log key. Because the underlying documents are drafts, not RFCs, this exact CBOR shape is validated by round-trip in this library's own tests, not against a frozen RFC. Treat the wire shape as draft-tracking.

Draft-tracking / standards honesty

This library tracks two IETF documents that are Active Internet-Drafts (Work in Progress) — they have been approved and are in the RFC Editor Queue, but are NOT yet published RFCs (status audited against the IETF Datatracker at ship date; re-verify at publish time, since RFC-Ed-Queue documents can be published at any point):

  • draft-ietf-scitt-architecture-22SCITT Architecture (Datatracker: Active Internet-Draft, RFC Ed Queue)
  • draft-ietf-cose-merkle-tree-proofs-18COSE Receipts / COSE Merkle Tree Proofs (Datatracker: Active Internet-Draft, RFC Ed Queue)

No unassigned RFC number is claimed anywhere (enforced by a test that scans shipped source + docs).

Published RFCs whose mechanisms this library implements / relies on (titles verified against the RFC Editor / IANA registries):

  • RFC 9052CBOR Object Signing and Encryption (COSE): Structures and Process (COSE_Sign1, Sig_structure).
  • RFC 9053COSE: Initial Algorithms (EdDSA −8, ES256 −7).
  • RFC 9162Certificate Transparency Version 2 (SHA-256 Merkle tree; inclusion and consistency proofs; reuses the RFC 6962 tree hash).
  • RFC 9597CBOR Web Token (CWT) Claims in COSE Headers — the CWT Claims header parameter at label 15 (IANA COSE Header Parameters registry). Label 13 is kcwt and 14 is kccs, both from RFC 9528 (EDHOC) — not the CWT Claims map; using 13 for claims is the python-cwt bug this library avoids.
  • RFC 9964ML-DSA for JOSE and COSE (code points recognized; ML-DSA signing not implemented here).

Independence

scitt_cose/ imports only cbor2, cryptography, and the standard library. It does not import cwt / python-cwt, pycose, or any consuming product/profile package. Verify:

grep -rnE 'import (cwt|pycose)\b' scitt_cose/   # → nothing

Two tests keep this true: the COSE-library import guard and the no-downstream-code neutrality gate (both in tests/test_iana_codepoints.py).

Correctness & cross-implementation evidence

For a community verifier, "trust me, it's conformant" is worthless. The correctness story rests on agreement with things outside this library, not on self-consistency:

  • Cross-language agreement (in CI). An independent Go verifier (the scitt-cose-go-verify directory, built on veraison/go-cose + fxamacker/cbor, with a clean-room Merkle fold) verifies the statements and receipts this library emits, agrees on the reconstructed Merkle root, and rejects tampered inputs. CI runs it with SCITT_REQUIRE_GO=1, so the cross-check can never silently skip. (tests/test_crosslang_go.py)
  • The standard's own test vectors. The Merkle code is checked against the published RFC 6962 / RFC 9162 reference vectors (8-leaf root 5dc9da79…, the canonical inclusion/consistency proofs) — external values, not ours. (tests/test_rfc9162_vectors.py)
  • A third-party COSE library. pycose (independent of us, and not python-cwt) both verifies our statements and emits statements we verify — so the round-trip isn't self-referential. (tests/test_thirdparty_cose.py)
  • The COSE standard's own reference-signed vector. We verify the canonical COSE_Sign1 the COSE WG published (RFC 9052 §C.2.1, ES256, from the cose-wg/Examples corpus) — anchoring the signature layer to the spec's reference output, just as the RFC 6962 vectors anchor the Merkle layer. (tests/test_cose_wg_vectors.py)
  • A real Transparency-Service verifier. A downstream Transparency Service's own verifier — written independently of this package, on a different COSE stack (python-cwt, vs this package's from-scratch cbor2/cryptography) — cross-verifies the same Signed Statements and the same TS-issued Receipts and agrees on the reconstructed Merkle root, in both directions. This exercises the real register→issue→verify shape; the integration test lives in the consuming repo (so this package stays standalone). This is not the hosted verify endpoint: that endpoint runs this package unchanged (a convenience deployment, deliberately the same library), so it is not an independent oracle — the python-cwt verifier is.
  • IANA values, not library enums. Every wire code point is asserted against its registry/RFC number (CWT Claims 15, not python-cwt's 13). (tests/test_iana_codepoints.py)
  • Spec-driven negative suite. The MUST-reject conditions — alg confusion, critical-header (crit) handling, vds downgrade, wrong-leaf/wrong-log receipts, Merkle edge cases, malformed CBOR — each have a test. (tests/test_negative_conformance.py)

Evaluated and not used: the SCITT API emulator. The scitt-community/ scitt-api-emulator was considered as an ecosystem oracle and rejected: it is archived/unmaintained (final "pre-archive" tag, Nov 2024) and emits the obsolete pre-standard receipt format (draft-birkholz-scitt-receipts — string labels service_id/tree_alg, not a COSE_Sign1 receipt, not the vds=395 / vdp=396 RFC 9162 structure this library verifies); its statements use pycose, already covered above. Pinning a non-conformant, drift-prone implementation would be a misleading oracle, so the COSE WG spec vector is used in its place.

Hosted verification — a standalone SCITT-only verifier

scitt_cose.hosted is a stateless, read-only wrapper over the same verify functions, so you can offer verification without anyone installing anything — and without the submitter having to trust the operator with their data (nothing stored, nothing logged, payload-opaque; the receipt path needs only the leaf digest, never the payload). tests/test_hosted_parity.py asserts the hosted verdict equals the local verdict on a fixture set, including the ASGI path.

This is a SCITT-only verifier, and it is NOT a Transparency Service. It verifies statements and receipts; it never registers, issues receipts, or anchors. Running a Transparency Service is a separate offering with its own operational trust obligations — deliberately out of scope here.

Run it standalone (no other service involved):

# Zero extra deps — stdlib HTTP server:
scitt-cose-serve                                  # 127.0.0.1:8080

# Or under a production ASGI server (pip install "scitt-cose[serve]"):
uvicorn scitt_cose.hosted:make_asgi_app --factory --host 0.0.0.0 --port 8080

# Or containerized (Dockerfile ships with the package):
docker build -t scitt-verifier . && docker run -p 8080:8080 scitt-verifier
GET  /         -> capabilities (what it does / does not do)
POST /verify   -> {valid, statement, receipt, reasons}   # JSON body, see below

GET / is content-negotiated: browsers (Accept: text/html) get a static landing page that renders the verifier-vs-Transparency-Service boundary table on the page itself — not buried in docs — while API clients get the same data as JSON (including a boundary field). Both are generated from the same constants (scitt_cose.hosted.BOUNDARY_TABLE), so page and API can't drift; tests/test_hosted_page.py pins the table's presence on both.

It can also ride along inside an existing ASGI app via app.mount("/scitt-verify", scitt_cose.hosted.make_asgi_app()) — same logic, shared deployment, still standalone code. The full design, submitter-safety constraints, and proposed deployment shape are in docs/hosted-verifier-design.md. A hosted instance is live at https://verify.actionstate.ai — this package unchanged (the parity-tested wrappers, deployed); its landing page renders the verifier-vs-Transparency-Service boundary table and states the privacy posture. You don't need it: the verifier runs anywhere.

Test vectors (cross-implementation, stable)

test-vectors/ is a frozen, append-only vector set for other SCITT/COSE implementations to check themselves against: two happy-path vectors (EdDSA, ES256) and three negative vectors (tampered inclusion path, unsupported vds, bad statement signature), each with committed bytes, TEST-ONLY keys, and an expected.json documenting every protected-header field a verifier needs. Verify from a clean checkout:

git clone https://github.com/action-state-group/scitt-cose
cd scitt-cose && pip install -e . && python -m scitt_cose.vectors

or from the published package (the runner ships in scitt-cose ≥ 0.1.0): pip install "scitt-cose>=0.1.0", download test-vectors/, then python -m scitt_cose.vectors path/to/test-vectors (add --json for a machine-readable report). The Go clean-room implementation runs the same manifest (go test ./... in scitt-cose-go-verify/). The append-only promise is enforced, not just stated: test-vectors/SHA256SUMS pins every published byte and CI fails on any mutation. A mismatch against your implementation is exactly the report we want — please open an issue. Details and the stability promise: test-vectors/README.md.

Tests

python3 -m pytest -q                     # unit + conformance suite (package root)
SCITT_REQUIRE_GO=1 python3 -m pytest -q  # also forces the Go cross-check
python3 -m ruff check .

License & contributing

Apache-2.0 — see LICENSE and NOTICE. Contributions are accepted under the Developer Certificate of Origin (sign your commits with git commit -s); scope rules and the standards-honesty gates are in CONTRIBUTING.md.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

scitt_cose-0.1.1.tar.gz (82.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

scitt_cose-0.1.1-py3-none-any.whl (50.8 kB view details)

Uploaded Python 3

File details

Details for the file scitt_cose-0.1.1.tar.gz.

File metadata

  • Download URL: scitt_cose-0.1.1.tar.gz
  • Upload date:
  • Size: 82.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.7

File hashes

Hashes for scitt_cose-0.1.1.tar.gz
Algorithm Hash digest
SHA256 419cf26b1d56a5c092f4b4b0044f3f926fd34cff209c6042302f0013aec050b8
MD5 14bcaa3cb66db9f636aced07c3c5e255
BLAKE2b-256 9a675c61c9f2fac9abe1cd5c7770db93794bb85559d98d2d79848e597e0369dd

See more details on using hashes here.

File details

Details for the file scitt_cose-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: scitt_cose-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 50.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.7

File hashes

Hashes for scitt_cose-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 310df036e0bf6f99a72d6ec48d63180b04d98c4fff437d7eb5a51980c92ad621
MD5 6e083e1f3cea41ce56aa5be9802b3f5f
BLAKE2b-256 de3084b16a22849a7abcf877219b2631f1abf14eea0d5f8ec734164daceb69c0

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page