diff --git a/.github/linters/.eslintrc.yml b/.github/linters/.eslintrc.yml index 7d9bcb4..b4ebfa1 100644 --- a/.github/linters/.eslintrc.yml +++ b/.github/linters/.eslintrc.yml @@ -1,5 +1,10 @@ plugins: - jsonc +# event.canonical.json is the RFC 8785 (JCS) canonical serialization of the +# sample event: its exact bytes are the committed artifact, so it cannot be +# reformatted to satisfy style rules. +ignorePatterns: + - "examples/event-provenance/event.canonical.json" overrides: - files: "*.json" parser: jsonc-eslint-parser diff --git a/.spellcheck-en-custom.txt b/.spellcheck-en-custom.txt index 4bb487d..5b9afe3 100644 --- a/.spellcheck-en-custom.txt +++ b/.spellcheck-en-custom.txt @@ -1,18 +1,26 @@ CDF CVE +DSSE +Ed Gerrit Github Gitlab +JCS JSON +Merkle NIST Notational +PAE README +RFC SBOM SBOMs SCM SDLC SDK +SHA SIG +SLSA SRE Tekton URI @@ -21,6 +29,7 @@ YAML american assignees br +canonicalization cardpane cdevent cdevents @@ -32,6 +41,7 @@ customdatacontenttype datacontenttype dataschema deterministically +dsse emmitted english eventdata @@ -42,6 +52,7 @@ img interoperable json jsonschema +keypair lifecycle markdownlint md @@ -53,6 +64,7 @@ pipelinerun png pre rolledback +runnable runtime sbom schemaUri @@ -69,6 +81,7 @@ testsuite testsuiterun ticketURI toc +toto typesystem unparsed uri diff --git a/README.md b/README.md index 0ec7d41..b91173e 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,11 @@ Handling Events associated with Test execution performed independently or as par Defining how CDEvents are mapped to CloudEvents for transportation and delivery +### [Event Provenance and Tamper-Evidence](./event-provenance.md) + +Optional guidance on signing CDEvents with DSSE, and on committing an event's canonical +hash to an external timeline for existence-over-time. Additive only: it changes no schema. + ### [Schemas](./schemas/) and [Conformance](./conformance/) The [schemas](./schemas/) folder contains `jsonschemas` for all events in the spec. The [conformance](./conformance/) folder contains simple `JSON` examples for all events. The content of the conformance folder is used for testing purposes: the structure of the files in there is sound, the values have correct types but are not particularly meaningful. @@ -123,4 +128,4 @@ guidelines. ### Governance The project has been started by the CDF [SIG Events](https://github.com/cdfoundation/sig-events). -Its governance is [documented in the community repository](https://github.com/cdevents/community/blob/main/governance.md). +Its governance is [documented in the community repository](https://github.com/cdevents/community/blob/main/governance.md). \ No newline at end of file diff --git a/event-provenance.md b/event-provenance.md new file mode 100644 index 0000000..43f6171 --- /dev/null +++ b/event-provenance.md @@ -0,0 +1,204 @@ +# Event provenance and tamper-evidence for CDEvents + +Status: informational companion document. This guidance is optional and additive; it +does not change the core CDEvents specification or any schema. + +## Introduction + +A CDEvent describes something that happened in a delivery pipeline: an artifact was +published, a build finished, a change was merged. Downstream systems increasingly +make decisions on these events (promotion gates, policy checks, audit trails), so two +separate questions arise: + +1. Authenticity and integrity. Who emitted this event, and has its payload been + altered in transit or at rest? +2. Existence over time. Did this event actually exist by some point in time, and can + that be checked later without trusting the emitter's clock, or its continued + custody of a signing key? + +These are different properties and they need different mechanisms. A signature answers +question 1. It does not answer question 2: a signature proves who and whether-tampered, +but not when the event existed, and a holder of the signing key can re-sign a +back-dated or quietly reissued event at any time. Question 2 needs a commitment to the +event that is fixed against an external timeline. + +This document describes both, as optional, additive guidance. Nothing here changes the +core CDEvents schema. Part 1 covers signing a CDEvent with a +[DSSE](https://github.com/secure-systems-lab/dsse) envelope. Part 2 covers an optional +commitment to the event's canonical hash, anchored to an external timeline. Part 2 is +strictly opt-in and deliberately anchor-agnostic: the commitment is just the event +digest, and where it is anchored is an implementation choice. + +## Background: how a CDEvent is carried + +Per the CDEvents [CloudEvents binding](https://github.com/cdevents/spec/blob/main/cloudevents-binding.md), +a CDEvent is transported as the `data` of a +[CloudEvent](https://github.com/cloudevents/spec) with `datacontenttype` +`application/json`, and the CloudEvents `id`, `source`, `type`, `subject` and `time` +attributes are mapped from the CDEvent's context. The guidance below can be applied at +either layer. Signing and hashing the CDEvent document (its `context` plus `subject`, +the object defined by the CDEvents schema) keeps provenance tied to the event itself and +independent of the transport, which is the approach used in the worked example. + +## Part 1: authenticity and integrity with DSSE + +[DSSE](https://github.com/secure-systems-lab/dsse) (Dead Simple Signing Envelope) is the +signing envelope used by in-toto and SLSA attestations. It wraps an arbitrary payload +plus a payload-type string and one or more signatures, and it signs over a +Pre-Authentication Encoding (PAE) rather than the raw bytes, which removes +canonicalization ambiguity and cross-protocol confusion from the signature. + +### The envelope + +The DSSE JSON envelope ([envelope.md](https://github.com/secure-systems-lab/dsse/blob/master/envelope.md)) +is: + +```json +{ + "payload": "", + "payloadType": "", + "signatures": [{ "keyid": "", "sig": "" }] +} +``` + +- `payload` is the Base64 (RFC 4648) encoding of the serialized CDEvent. +- `payloadType` is an opaque, case-sensitive string identifying how to interpret the + payload. This guide uses `application/cdevents+json`. `payloadType` is authenticated, + so a signature over a CDEvent cannot be replayed as a signature over a different + document type. +- Each `signatures[].sig` is the Base64 signature. `keyid` is an optional, + unauthenticated hint for key selection. + +### What is signed: the PAE + +DSSE signs the Pre-Authentication Encoding of the payload, defined in +[protocol.md](https://github.com/secure-systems-lab/dsse/blob/master/protocol.md): + +``` +PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body +SP = ASCII space (0x20) +LEN(s) = ASCII decimal byte length of s, no leading zeros +"DSSEv1" = ASCII bytes 44 53 53 45 76 31 +``` + +A verifier reconstructs `PAE(payloadType, Base64Decode(payload))` and checks the +signature against a trusted public key. Per DSSE guidance, the verified bytes are used +as-is; they are not re-parsed or re-canonicalized before use. + +### What DSSE proves, and what it does not + +Proves: +- Authenticity. The event was signed by a holder of the private key for `keyid`. +- Integrity. Any change to the CDEvent payload, or to `payloadType`, invalidates the + signature. + +Does not prove: +- When the event existed. A signature carries no trustworthy time. Any `timestamp` + inside the event is asserted by the emitter and can be set to any value. +- That the event was not reissued. A party holding the signing key can produce a fresh, + validly-signed event with different content at any later time. + +Note on CloudEvents: CloudEvents has merged a DSSE-based verifiability extension +([cloudevents/extensions/verifiability.md](https://github.com/cloudevents/spec/blob/main/cloudevents/extensions/verifiability.md), +merged June 2026), covering authenticity and integrity of individual events. When a +CDEvent travels as a CloudEvent, that extension is the recommended way to sign it — it +is DSSE in both cases, so Part 1 of this guidance and the extension align rather than +compete. The extension's stated non-goals (stream completeness, ordering, existence +over time) are what the Part 2 commitment adds; the two compose. + +## Part 2: existence over time with an optional hash commitment + +To answer "did this event exist by time T", commit to the event and fix that commitment +against a timeline that the emitter does not control. This is independent of, and +complementary to, the DSSE signature. + +### The commitment + +1. Canonicalize the CDEvent with [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) + (JSON Canonicalization Scheme, JCS). JCS gives a single, deterministic byte + serialization for a JSON value, so any party recomputes the same bytes from the same + event. +2. Hash the canonical bytes with SHA-256. This digest is the commitment. In the worked + example it is labeled `RFC8785-JCS+SHA-256`. +3. Record the digest on an external timeline so that its existence-by-time-T can be + checked later. + +Step 3 is where the anchor lives, and this guide is deliberately anchor-agnostic. A +digest can be fixed against a timeline in several ways, for example: + +- an [RFC 3161](https://www.rfc-editor.org/rfc/rfc3161) trusted timestamp from a Time + Stamping Authority, +- an append-only transparency log (for example a Merkle-tree log in the style of + RFC 9162), +- inclusion in a public blockchain transaction. + +Which anchor to use is an operational and threat-model choice. The only thing CDEvents +tooling needs to agree on is the commitment itself: the canonicalization +(RFC 8785) and the hash (SHA-256). Everything about the anchor is out of scope for this +document and MUST NOT be assumed by consumers. + +### What the commitment adds beyond DSSE + +- Existence proof. Given the event, anyone can recompute the digest and check it against + the anchor's record, establishing that the event existed by the time the anchor was + written. +- Independence from key custody. The check does not require the emitter's signing key or + its clock. It survives key loss, key rotation, and emitter compromise after the anchor + time. +- Reissue detection. Because the digest is bound to a point on the timeline, a later + re-signed variant of the event produces a different digest with no earlier anchor. + +The commitment does not prove authorship. Only the DSSE signature does. The two are +designed to be used together. + +## When to use which + +| Requirement | DSSE signature (Part 1) | Hash commitment + anchor (Part 2) | +| --- | --- | --- | +| Prove who emitted the event | Yes | No | +| Detect payload tampering | Yes | Yes (digest mismatch) | +| Prove the event existed by time T | No | Yes | +| Verifiable without the emitter's key | No | Yes | +| Verifiable without trusting the emitter's clock | No | Yes | +| Detect a later re-signed / back-dated reissue | No | Yes | +| Extra infrastructure required | A key and key distribution | An external timeline / anchor | +| Status in this guidance | Recommended baseline | Optional, opt-in | + +Baseline: sign CDEvents with DSSE. Add a Part 2 commitment when downstream decisions +need existence-over-time or must survive emitter key compromise, for example regulated +audit trails or long-lived provenance. + +## Worked example + +A runnable end-to-end example accompanies this guide in +[`examples/event-provenance/`](./examples/event-provenance/). + +The example uses real libraries rather than stubs: `rfc8785` for JCS canonicalization, +`cryptography` for Ed25519, and `jsonschema` to validate the sample event against the +published CDEvents artifact-published schema. It contains a valid +`dev.cdevents.artifact.published.0.3.0` event (CDEvents v0.5.1), its JCS canonical +serialization, the Part 2 commitment, a DSSE envelope over the canonical bytes, and a +tampered variant of each. Its verifier shows the genuine artifacts passing both checks +(the signature verifies, and SHA-256 of the signed payload equals the committed hash) +and the tampered artifacts failing both. + +The example is illustrative. It uses one possible anchor mechanism; as stated in Part 2, +the choice of anchor is out of scope for this guidance. + +## References + +- DSSE envelope format: secure-systems-lab/dsse, `envelope.md`. + https://github.com/secure-systems-lab/dsse/blob/master/envelope.md +- DSSE protocol and PAE: secure-systems-lab/dsse, `protocol.md`. + https://github.com/secure-systems-lab/dsse/blob/master/protocol.md +- RFC 8785, JSON Canonicalization Scheme (JCS). https://www.rfc-editor.org/rfc/rfc8785 +- RFC 4648, Base64 (used by DSSE `payload`/`sig`). https://www.rfc-editor.org/rfc/rfc4648 +- RFC 3161, Time-Stamp Protocol (one possible anchor for Part 2). + https://www.rfc-editor.org/rfc/rfc3161 +- RFC 9162, Certificate Transparency v2 (transparency-log style anchor for Part 2). + https://www.rfc-editor.org/rfc/rfc9162 +- CDEvents CloudEvents binding. + https://github.com/cdevents/spec/blob/main/cloudevents-binding.md +- CloudEvents specification. https://github.com/cloudevents/spec +- CloudEvents message-level signature discussion (open). + https://github.com/cloudevents/spec/issues/565 \ No newline at end of file diff --git a/examples/event-provenance/README.md b/examples/event-provenance/README.md new file mode 100644 index 0000000..05ef452 --- /dev/null +++ b/examples/event-provenance/README.md @@ -0,0 +1,26 @@ +# Event provenance worked example + +A runnable example for the [event provenance guidance](../../event-provenance.md). +It contains a valid `dev.cdevents.artifact.published` event, its canonical +serialization, a DSSE envelope over the canonical bytes, the commitment to the +event digest, and a tampered variant of each. + +Requires Python 3 with `rfc8785`, `cryptography`, and `jsonschema`: + +```shell +pip install rfc8785 cryptography jsonschema +``` + +Committed artifacts are already present. To regenerate and verify: + +```shell +python3 build_example.py +python3 verify_example.py +``` + +The verifier shows the genuine event passing both checks (the DSSE signature +verifies against the demo public key, and the SHA-256 digest of the canonical +payload equals the committed hash) and the tampered event failing both. The +demo keypair is generated by `build_example.py`; only the public key is +committed. `build_example.py` also validates the sample event against the +published CDEvents schema. diff --git a/examples/event-provenance/build_example.py b/examples/event-provenance/build_example.py new file mode 100644 index 0000000..72dfe87 --- /dev/null +++ b/examples/event-provenance/build_example.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +Build the worked example for the CDEvents event-provenance guide. + +Generates, in the current directory: + - keys/ed25519_private.pem, keys/ed25519_public.pem (throwaway DEMO key) + - event.json a valid dev.cdevents.artifact.published CDEvent + - event.canonical.json RFC 8785 (JCS) canonical serialization of event.json + - commitment.json {alg, hash} = SHA-256 over the canonical bytes + - envelope.json DSSE envelope signing the canonical bytes + - event.tampered.json the same event with one byte-of-meaning changed + - envelope.tampered.json original signature, payload swapped to the tampered bytes + +Nothing here is vendor-specific. The commitment hash is just the event digest; +WHERE it is anchored (a transparency log, an RFC 3161 TSA, a blockchain, ...) is an +implementation choice and is intentionally out of scope for this example. + +Requires: rfc8785, cryptography, jsonschema +""" +import base64 +import hashlib +import json +import os +import urllib.request + +import rfc8785 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization +from jsonschema import Draft202012Validator + +PAYLOAD_TYPE = "application/cdevents+json" +CDEVENTS_TAG = "v0.5.1" +SCHEMA_URL = f"https://raw.githubusercontent.com/cdevents/spec/{CDEVENTS_TAG}/schemas/artifactpublished.json" + +HERE = os.path.dirname(os.path.abspath(__file__)) +KEYDIR = os.path.join(HERE, "keys") +os.makedirs(KEYDIR, exist_ok=True) + + +def jcs(obj) -> bytes: + """RFC 8785 JSON Canonicalization Scheme -> canonical UTF-8 bytes.""" + return rfc8785.dumps(obj) + + +def pae(payload_type: str, body: bytes) -> bytes: + """DSSE Pre-Authentication Encoding (secure-systems-lab/dsse protocol.md). + + PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body + LEN(s) = ASCII decimal byte length, no leading zeros; SP = 0x20. + """ + t = payload_type.encode("utf-8") + return b"DSSEv1 " + str(len(t)).encode() + b" " + t + b" " + str(len(body)).encode() + b" " + body + + +def keyid_for(pub_raw: bytes) -> str: + return "sha256:" + hashlib.sha256(pub_raw).hexdigest() + + +# ---- 1. the CDEvent (a real dev.cdevents.artifact.published event) ---------- +event = { + "context": { + "specversion": "0.5.1", + "id": "271069a8-fc18-44f1-b38f-9d70a1695819", + "source": "/pipelines/release/build-and-publish", + "type": "dev.cdevents.artifact.published.0.3.0", + "timestamp": "2026-07-03T14:27:05.315384Z", + }, + "subject": { + "id": "pkg:oci/myapp@sha256:0f7e1b2c3d4a5b6c7d8e9f00112233445566778899aabbccddeeff0011223344", + "source": "/pipelines/release/build-and-publish", + "content": { + "sbom": {"uri": "https://sbom.example.com/myorg/myapp/0f7e1b2c.spdx.json"}, + "user": "release-bot", + }, + }, +} + +# ---- 2. validate it against the REAL CDEvents schema ----------------------- +with urllib.request.urlopen(SCHEMA_URL, timeout=30) as r: + schema = json.load(r) +Draft202012Validator(schema).validate(event) +print(f"[ok] event validates against CDEvents {CDEVENTS_TAG} artifact-published schema") + +# ---- 3. canonicalize + digest (the "commitment") --------------------------- +canonical = jcs(event) +digest = hashlib.sha256(canonical).hexdigest() +commitment = { + "alg": "RFC8785-JCS+SHA-256", + "hash": f"sha256:{digest}", + "note": ( + "SHA-256 over the RFC 8785 (JCS) canonical serialization of the CDEvent. " + "This is the value an emitter would commit to an external timeline. " + "The anchor (transparency log, RFC 3161 TSA, blockchain, ...) is out of scope." + ), +} + +# ---- 4. DSSE sign the canonical bytes -------------------------------------- +priv = Ed25519PrivateKey.generate() +pub = priv.public_key() +pub_raw = pub.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw) +kid = keyid_for(pub_raw) + +signature = priv.sign(pae(PAYLOAD_TYPE, canonical)) +envelope = { + "payload": base64.standard_b64encode(canonical).decode(), + "payloadType": PAYLOAD_TYPE, + "signatures": [{"keyid": kid, "sig": base64.standard_b64encode(signature).decode()}], +} + +# persist the DEMO keypair so the example is reproducible + verifiable +with open(os.path.join(KEYDIR, "ed25519_private.pem"), "wb") as f: + f.write(priv.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) +with open(os.path.join(KEYDIR, "ed25519_public.pem"), "wb") as f: + f.write(pub.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)) + +# ---- 5. a tampered event + a tampered envelope ----------------------------- +tampered_event = json.loads(json.dumps(event)) +tampered_event["subject"]["content"]["user"] = "attacker-bot" # one field of meaning +tampered_canonical = jcs(tampered_event) +# attacker swaps the payload but cannot re-sign (no private key): keep original sig +tampered_envelope = json.loads(json.dumps(envelope)) +tampered_envelope["payload"] = base64.standard_b64encode(tampered_canonical).decode() + +# ---- 6. write everything ---------------------------------------------------- +def write(name, data, raw=False): + p = os.path.join(HERE, name) + with open(p, "wb" if raw else "w") as f: + if raw: + f.write(data) + else: + json.dump(data, f, indent=2) + f.write("\n") + print(f"[wrote] {name}") + +write("event.json", event) +write("event.canonical.json", canonical, raw=True) +write("commitment.json", commitment) +write("envelope.json", envelope) +write("event.tampered.json", tampered_event) +write("envelope.tampered.json", tampered_envelope) + +print(f"\nkeyid = {kid}") +print(f"digest = sha256:{digest}") +print("done.") diff --git a/examples/event-provenance/commitment.json b/examples/event-provenance/commitment.json new file mode 100644 index 0000000..6df8e50 --- /dev/null +++ b/examples/event-provenance/commitment.json @@ -0,0 +1,5 @@ +{ + "alg": "RFC8785-JCS+SHA-256", + "hash": "sha256:2654df9ccd165ead6390d70ca533976aff38da017b53e83439407993c4d6c9ed", + "note": "SHA-256 over the RFC 8785 (JCS) canonical serialization of the CDEvent. This is the value an emitter would commit to an external timeline. The anchor (transparency log, RFC 3161 TSA, blockchain, ...) is out of scope." +} diff --git a/examples/event-provenance/envelope.json b/examples/event-provenance/envelope.json new file mode 100644 index 0000000..904fee9 --- /dev/null +++ b/examples/event-provenance/envelope.json @@ -0,0 +1,10 @@ +{ + "payload": "eyJjb250ZXh0Ijp7ImlkIjoiMjcxMDY5YTgtZmMxOC00NGYxLWIzOGYtOWQ3MGExNjk1ODE5Iiwic291cmNlIjoiL3BpcGVsaW5lcy9yZWxlYXNlL2J1aWxkLWFuZC1wdWJsaXNoIiwic3BlY3ZlcnNpb24iOiIwLjUuMSIsInRpbWVzdGFtcCI6IjIwMjYtMDctMDNUMTQ6Mjc6MDUuMzE1Mzg0WiIsInR5cGUiOiJkZXYuY2RldmVudHMuYXJ0aWZhY3QucHVibGlzaGVkLjAuMy4wIn0sInN1YmplY3QiOnsiY29udGVudCI6eyJzYm9tIjp7InVyaSI6Imh0dHBzOi8vc2JvbS5leGFtcGxlLmNvbS9teW9yZy9teWFwcC8wZjdlMWIyYy5zcGR4Lmpzb24ifSwidXNlciI6InJlbGVhc2UtYm90In0sImlkIjoicGtnOm9jaS9teWFwcEBzaGEyNTY6MGY3ZTFiMmMzZDRhNWI2YzdkOGU5ZjAwMTEyMjMzNDQ1NTY2Nzc4ODk5YWFiYmNjZGRlZWZmMDAxMTIyMzM0NCIsInNvdXJjZSI6Ii9waXBlbGluZXMvcmVsZWFzZS9idWlsZC1hbmQtcHVibGlzaCJ9fQ==", + "payloadType": "application/cdevents+json", + "signatures": [ + { + "keyid": "sha256:86d9432605a93aebcb37e18597b06712af996be6684f6c9f2531940df2df7a89", + "sig": "QMv4jWHi9zDz8Dflo3U8yzK7Z+FBRrpIwjHr2kLabjMwoly9+qUFP8cuYI23/XCycjL972bWgaZn8Gz5ka80AA==" + } + ] +} diff --git a/examples/event-provenance/envelope.tampered.json b/examples/event-provenance/envelope.tampered.json new file mode 100644 index 0000000..5d2250e --- /dev/null +++ b/examples/event-provenance/envelope.tampered.json @@ -0,0 +1,10 @@ +{ + "payload": "eyJjb250ZXh0Ijp7ImlkIjoiMjcxMDY5YTgtZmMxOC00NGYxLWIzOGYtOWQ3MGExNjk1ODE5Iiwic291cmNlIjoiL3BpcGVsaW5lcy9yZWxlYXNlL2J1aWxkLWFuZC1wdWJsaXNoIiwic3BlY3ZlcnNpb24iOiIwLjUuMSIsInRpbWVzdGFtcCI6IjIwMjYtMDctMDNUMTQ6Mjc6MDUuMzE1Mzg0WiIsInR5cGUiOiJkZXYuY2RldmVudHMuYXJ0aWZhY3QucHVibGlzaGVkLjAuMy4wIn0sInN1YmplY3QiOnsiY29udGVudCI6eyJzYm9tIjp7InVyaSI6Imh0dHBzOi8vc2JvbS5leGFtcGxlLmNvbS9teW9yZy9teWFwcC8wZjdlMWIyYy5zcGR4Lmpzb24ifSwidXNlciI6ImF0dGFja2VyLWJvdCJ9LCJpZCI6InBrZzpvY2kvbXlhcHBAc2hhMjU2OjBmN2UxYjJjM2Q0YTViNmM3ZDhlOWYwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZjAwMTEyMjMzNDQiLCJzb3VyY2UiOiIvcGlwZWxpbmVzL3JlbGVhc2UvYnVpbGQtYW5kLXB1Ymxpc2gifX0=", + "payloadType": "application/cdevents+json", + "signatures": [ + { + "keyid": "sha256:86d9432605a93aebcb37e18597b06712af996be6684f6c9f2531940df2df7a89", + "sig": "QMv4jWHi9zDz8Dflo3U8yzK7Z+FBRrpIwjHr2kLabjMwoly9+qUFP8cuYI23/XCycjL972bWgaZn8Gz5ka80AA==" + } + ] +} diff --git a/examples/event-provenance/event.canonical.json b/examples/event-provenance/event.canonical.json new file mode 100644 index 0000000..a5677e2 --- /dev/null +++ b/examples/event-provenance/event.canonical.json @@ -0,0 +1 @@ +{"context":{"id":"271069a8-fc18-44f1-b38f-9d70a1695819","source":"/pipelines/release/build-and-publish","specversion":"0.5.1","timestamp":"2026-07-03T14:27:05.315384Z","type":"dev.cdevents.artifact.published.0.3.0"},"subject":{"content":{"sbom":{"uri":"https://sbom.example.com/myorg/myapp/0f7e1b2c.spdx.json"},"user":"release-bot"},"id":"pkg:oci/myapp@sha256:0f7e1b2c3d4a5b6c7d8e9f00112233445566778899aabbccddeeff0011223344","source":"/pipelines/release/build-and-publish"}} \ No newline at end of file diff --git a/examples/event-provenance/event.json b/examples/event-provenance/event.json new file mode 100644 index 0000000..bbfb9f6 --- /dev/null +++ b/examples/event-provenance/event.json @@ -0,0 +1,19 @@ +{ + "context": { + "specversion": "0.5.1", + "id": "271069a8-fc18-44f1-b38f-9d70a1695819", + "source": "/pipelines/release/build-and-publish", + "type": "dev.cdevents.artifact.published.0.3.0", + "timestamp": "2026-07-03T14:27:05.315384Z" + }, + "subject": { + "id": "pkg:oci/myapp@sha256:0f7e1b2c3d4a5b6c7d8e9f00112233445566778899aabbccddeeff0011223344", + "source": "/pipelines/release/build-and-publish", + "content": { + "sbom": { + "uri": "https://sbom.example.com/myorg/myapp/0f7e1b2c.spdx.json" + }, + "user": "release-bot" + } + } +} diff --git a/examples/event-provenance/event.tampered.json b/examples/event-provenance/event.tampered.json new file mode 100644 index 0000000..67bc928 --- /dev/null +++ b/examples/event-provenance/event.tampered.json @@ -0,0 +1,19 @@ +{ + "context": { + "specversion": "0.5.1", + "id": "271069a8-fc18-44f1-b38f-9d70a1695819", + "source": "/pipelines/release/build-and-publish", + "type": "dev.cdevents.artifact.published.0.3.0", + "timestamp": "2026-07-03T14:27:05.315384Z" + }, + "subject": { + "id": "pkg:oci/myapp@sha256:0f7e1b2c3d4a5b6c7d8e9f00112233445566778899aabbccddeeff0011223344", + "source": "/pipelines/release/build-and-publish", + "content": { + "sbom": { + "uri": "https://sbom.example.com/myorg/myapp/0f7e1b2c.spdx.json" + }, + "user": "attacker-bot" + } + } +} diff --git a/examples/event-provenance/keys/ed25519_public.pem b/examples/event-provenance/keys/ed25519_public.pem new file mode 100644 index 0000000..b3b851e --- /dev/null +++ b/examples/event-provenance/keys/ed25519_public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAAAgNDDBSbFphXFoQu3fC/dG/ksXVq+MEFTg3Qct4CK4= +-----END PUBLIC KEY----- diff --git a/examples/event-provenance/verify_example.py b/examples/event-provenance/verify_example.py new file mode 100644 index 0000000..7c3ae69 --- /dev/null +++ b/examples/event-provenance/verify_example.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Verify the worked example end to end. + +For the genuine artifacts (envelope.json + commitment.json) this shows: + (a) the DSSE signature verifies against the public key, and + (b) the SHA-256 of the (canonical) signed payload equals the committed hash. + +For the tampered artifacts (envelope.tampered.json) it shows the SAME checks +fail: the DSSE signature no longer verifies AND the recomputed hash no longer +matches the commitment. An attacker who alters the event cannot re-sign it +(no private key) and cannot make the digest match the pre-existing commitment. + +Standards used: + - DSSE : secure-systems-lab/dsse, protocol.md (PAE + envelope shape) + - JCS : RFC 8785 (JSON Canonicalization Scheme) + - CDEvents cloudevents-binding.md (transport), artifact-published schema (shape) + +Requires: rfc8785, cryptography +Exit code 0 iff genuine passes both checks AND tampered fails both. +""" +import base64 +import hashlib +import json +import os +import sys + +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def pae(payload_type: str, body: bytes) -> bytes: + """DSSE Pre-Authentication Encoding: DSSEv1 SP LEN(t) SP t SP LEN(b) SP b.""" + t = payload_type.encode("utf-8") + return b"DSSEv1 " + str(len(t)).encode() + b" " + t + b" " + str(len(body)).encode() + b" " + body + + +def load(name): + with open(os.path.join(HERE, name), "rb") as f: + return f.read() + + +def load_pubkey(): + return serialization.load_pem_public_key(load("keys/ed25519_public.pem")) + + +def dsse_verify(envelope: dict, pub: Ed25519PublicKey) -> bool: + """Verify at least one signature in the envelope (DSSE protocol).""" + body = base64.standard_b64decode(envelope["payload"]) + msg = pae(envelope["payloadType"], body) + for s in envelope["signatures"]: + try: + pub.verify(base64.standard_b64decode(s["sig"]), msg) + return True + except InvalidSignature: + continue + return False + + +def hash_matches(envelope: dict, commitment: dict) -> (bool, str): + """Recompute SHA-256 over the signed payload bytes; compare to commitment. + + Per DSSE guidance we hash the exact verified bytes; we do NOT re-canonicalize. + Those bytes already ARE the RFC 8785 canonical form the emitter committed to. + """ + body = base64.standard_b64decode(envelope["payload"]) + got = "sha256:" + hashlib.sha256(body).hexdigest() + return (got == commitment["hash"], got) + + +def show(title, envelope, commitment, pub): + print(f"--- {title} ---") + sig_ok = dsse_verify(envelope, pub) + h_ok, got = hash_matches(envelope, commitment) + print(f" DSSE signature : {'VALID ' if sig_ok else 'INVALID'} (payloadType={envelope['payloadType']})") + print(f" committed hash : {commitment['hash']}") + print(f" recomputed hash: {got}") + print(f" hash match : {'YES' if h_ok else 'NO'}") + return sig_ok, h_ok + + +def main(): + pub = load_pubkey() + commitment = json.loads(load("commitment.json")) + + genuine = json.loads(load("envelope.json")) + tampered = json.loads(load("envelope.tampered.json")) + + print("CDEvents event-provenance worked example -- verification\n") + g_sig, g_hash = show("GENUINE event (envelope.json)", genuine, commitment, pub) + print() + t_sig, t_hash = show("TAMPERED event (envelope.tampered.json)", tampered, commitment, pub) + + print("\nExpected: genuine passes BOTH checks; tampered fails BOTH.") + ok = g_sig and g_hash and (not t_sig) and (not t_hash) + print("RESULT:", "PASS" if ok else "FAIL") + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main()