| @@ -310,26 +310,38 @@ def upload_dsse( | ||
| ) -> RekorUploadResult: | ||
| """POST a DSSE envelope to Rekor v2. | ||
| - | ``issuer_ed25519_pub_pem`` is the issuer's verification key in PEM. | |
| - | Rekor v2 self-managed-key submissions require a verifier key alongside | |
| - | the envelope so the log can sanity-check that the envelope is verifiable | |
| - | before accepting it. | |
| + | ``issuer_ed25519_pub_pem`` is the issuer's verification key in PEM. The | |
| + | upload payload converts it to the DER (SubjectPublicKeyInfo) bytes that | |
| + | the Rekor v2 ``Verifier.PublicKey.raw_bytes`` field actually requires. | |
| + | ||
| + | Wire shape per | |
| + | https://github.com/sigstore/rekor-tiles/blob/main/api/proto/rekor/v2/dsse.proto | |
| + | (verified 2026-04-19): ``verifiers`` is a repeated field; each verifier | |
| + | carries ``publicKey.rawBytes`` (DER) and a sibling ``keyDetails`` enum | |
| + | string (e.g. ``PKIX_ED25519``). | |
| Network errors raise; callers decide whether to retry or fall back to | ||
| the local tlog (only acceptable for development, not production). | ||
| """ | ||
| + | # Rekor's PublicKey.raw_bytes wants DER (SubjectPublicKeyInfo), not PEM. | |
| + | from cryptography.hazmat.primitives import serialization as _ser | |
| + | pub_obj = _ser.load_pem_public_key(issuer_ed25519_pub_pem.encode("utf-8")) | |
| + | pub_der = pub_obj.public_bytes( | |
| + | encoding=_ser.Encoding.DER, | |
| + | format=_ser.PublicFormat.SubjectPublicKeyInfo, | |
| + | ) | |
| body = json.dumps( | ||
| { | ||
| "dsseRequestV002": { | ||
| "envelope": json.loads(envelope.to_json()), | ||
| - | "verifier": { | |
| - | "publicKey": { | |
| - | "rawBytes": base64.b64encode( | |
| - | issuer_ed25519_pub_pem.encode("utf-8") | |
| - | ).decode("ascii"), | |
| + | "verifiers": [ | |
| + | { | |
| + | "publicKey": { | |
| + | "rawBytes": base64.b64encode(pub_der).decode("ascii"), | |
| + | }, | |
| "keyDetails": "PKIX_ED25519", | ||
| } | ||
| - | }, | |
| + | ], | |
| } | ||
| } | ||
| ).encode("utf-8") |
| @@ -36,6 +36,7 @@ from pydantic import BaseModel | ||
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | ||
| from oversight_core.tlog import TransparencyLog | ||
| from oversight_core.manifest import Manifest | ||
| + | from oversight_core import rekor as rekor_mod | |
| DB_PATH = Path(os.environ.get("OVERSIGHT_DB", "/tmp/oversight-registry.sqlite")) | ||
| @@ -45,6 +46,13 @@ IDENTITY_PATH = DATA_DIR / "registry-identity.json" | ||
| TRUSTED_PROXY = bool(int(os.environ.get("TRUSTED_PROXY", "0"))) | ||
| # When TRUSTED_PROXY=1, honor X-Forwarded-For for rate limiting. | ||
| + | # Rekor v2 wiring (v0.5 Session B). Off by default so existing tests do not | |
| + | # generate live network traffic. Set OVERSIGHT_REKOR_ENABLED=1 to opt in. | |
| + | # Failures are non-fatal: registry remains usable when Rekor is unreachable; | |
| + | # the local SQLite tlog continues to be the authoritative event index. | |
| + | REKOR_ENABLED = bool(int(os.environ.get("OVERSIGHT_REKOR_ENABLED", "0"))) | |
| + | REKOR_URL = os.environ.get("OVERSIGHT_REKOR_URL", rekor_mod.DEFAULT_REKOR_URL) | |
| + | ||
| SCHEMA = """ | ||
| CREATE TABLE IF NOT EXISTS beacons ( | ||
| @@ -254,6 +262,74 @@ def _append_tlog(event: dict) -> int: | ||
| return TLOG.append(event) if TLOG else -1 | ||
| + | def _attest_to_rekor( | |
| + | file_id: str, | |
| + | issuer_pub_hex: str, | |
| + | recipient_id: str, | |
| + | recipient_pubkey_hex: Optional[str], | |
| + | suite: str, | |
| + | content_hash_sha256_hex: str, | |
| + | mark_id_hex: str, | |
| + | ) -> Optional[dict]: | |
| + | """Sign a registration predicate with the registry's identity key and | |
| + | append it to a public Rekor v2 log. | |
| + | ||
| + | Returns a small JSON-serializable summary on success (log_url, log_index, | |
| + | log_id, integrated_time) so the response can carry it back to the client. | |
| + | Returns ``None`` when REKOR_ENABLED is false. Returns a dict with an | |
| + | ``error`` field (and no log_index) when the upload itself fails - the | |
| + | caller treats this as non-fatal. | |
| + | """ | |
| + | if not REKOR_ENABLED or IDENTITY is None: | |
| + | return None | |
| + | try: | |
| + | recipient_hash = ( | |
| + | rekor_mod.hash_recipient_pubkey(recipient_pubkey_hex) | |
| + | if recipient_pubkey_hex | |
| + | else "0" * 64 | |
| + | ) | |
| + | predicate = rekor_mod.OversightRegistrationPredicate( | |
| + | file_id=file_id, | |
| + | issuer_pubkey_ed25519=issuer_pub_hex, | |
| + | recipient_id=recipient_id, | |
| + | recipient_pubkey_sha256=recipient_hash, | |
| + | suite=suite, | |
| + | registered_at=timestamp_stub(), | |
| + | ) | |
| + | statement = rekor_mod.build_statement( | |
| + | mark_id_hex=mark_id_hex, | |
| + | content_hash_sha256_hex=content_hash_sha256_hex, | |
| + | predicate=predicate, | |
| + | ) | |
| + | envelope = rekor_mod.sign_dsse( | |
| + | statement=statement, | |
| + | issuer_ed25519_priv=bytes.fromhex(IDENTITY["ed25519_priv"]), | |
| + | ) | |
| + | # Build a PEM for the registry's verifier key. Rekor v2 needs PEM. | |
| + | registry_pub = Ed25519PublicKey.from_public_bytes( | |
| + | bytes.fromhex(IDENTITY["ed25519_pub"]) | |
| + | ) | |
| + | pub_pem = registry_pub.public_bytes( | |
| + | encoding=serialization.Encoding.PEM, | |
| + | format=serialization.PublicFormat.SubjectPublicKeyInfo, | |
| + | ).decode("ascii") | |
| + | result = rekor_mod.upload_dsse( | |
| + | envelope=envelope, | |
| + | issuer_ed25519_pub_pem=pub_pem, | |
| + | log_url=REKOR_URL, | |
| + | ) | |
| + | return { | |
| + | "log_url": result.log_url, | |
| + | "log_index": result.log_index, | |
| + | "log_id": result.log_id, | |
| + | "integrated_time": result.integrated_time, | |
| + | "tlog_kind": rekor_mod.TLOG_KIND, | |
| + | "bundle_schema": rekor_mod.BUNDLE_SCHEMA, | |
| + | } | |
| + | except Exception as e: | |
| + | return {"error": f"{type(e).__name__}: {e}", "tlog_kind": rekor_mod.TLOG_KIND} | |
| + | ||
| + | ||
| def _rate_limit(request: Request): | ||
| if not BUCKET.allow(_client_key(request)): | ||
| raise HTTPException(429, "rate limit exceeded") | ||
| @@ -348,11 +424,22 @@ def register(req: RegistrationRequest, request: Request): | ||
| "timestamp": timestamp_stub(), | ||
| }) | ||
| + | rekor_result = _attest_to_rekor( | |
| + | file_id=file_id, | |
| + | issuer_pub_hex=issuer_pub, | |
| + | recipient_id=recipient_id, | |
| + | recipient_pubkey_hex=recipient.get("x25519_pub"), | |
| + | suite=m.get("suite", "classic"), | |
| + | content_hash_sha256_hex=(m.get("content") or {}).get("sha256", "0" * 64), | |
| + | mark_id_hex=file_id, | |
| + | ) | |
| + | ||
| return { | ||
| "ok": True, | ||
| "file_id": file_id, | ||
| "registered_beacons": len(req.beacons), | ||
| "tlog_index": tlog_idx, | ||
| + | "rekor": rekor_result, | |
| } | ||
| @@ -0,0 +1,146 @@ | ||
| + | """ | |
| + | test_rekor_backcompat | |
| + | ===================== | |
| + | ||
| + | Confirms the v0.5 Rekor work has not broken any v0.4 behavior. | |
| + | ||
| + | Per the v0.5 plan §6 (Backward compatibility rules): | |
| + | 1. Every v0.4.1 bundle/.sealed file must still parse, open, verify exactly. | |
| + | 2. Bundles missing ``tlog_kind`` / ``bundle_schema`` are interpreted as the | |
| + | v0.4 path (oversight-self-merkle-v1). | |
| + | 3. JCS canonical ordering still applies; new fields are additions only. | |
| + | ||
| + | These checks run fully offline. | |
| + | """ | |
| + | from __future__ import annotations | |
| + | ||
| + | import json | |
| + | import os | |
| + | import sys | |
| + | import tempfile | |
| + | ||
| + | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) | |
| + | ||
| + | from oversight_core.tlog import TransparencyLog, verify_inclusion_proof | |
| + | from oversight_core import rekor as R | |
| + | ||
| + | ||
| + | def t1_legacy_tlog_still_works(): | |
| + | """A TransparencyLog built and verified the v0.4 way must still pass.""" | |
| + | with tempfile.TemporaryDirectory() as td: | |
| + | tl = TransparencyLog(td) | |
| + | for i in range(7): | |
| + | tl.append({"event": "register", "i": i, "file_id": f"f{i}"}) | |
| + | size = tl.size() | |
| + | root = tl.root() | |
| + | assert size == 7, f"expected size 7, got {size}" | |
| + | assert len(root) == 32, "root must be 32 bytes (sha256)" | |
| + | ||
| + | # Inclusion proof for the middle leaf. | |
| + | proof = tl.inclusion_proof(3) | |
| + | assert proof is not None, "inclusion_proof returned None for valid index" | |
| + | ok = verify_inclusion_proof( | |
| + | leaf_hash=bytes.fromhex(proof["leaf_hash"]), | |
| + | index=proof["index"], | |
| + | proof=[bytes.fromhex(h) for h in proof["proof"]], | |
| + | tree_size=proof["tree_size"], | |
| + | expected_root=bytes.fromhex(proof["root"]), | |
| + | ) | |
| + | assert ok, "RFC 6962 inclusion proof failed to verify" | |
| + | print(" [PASS] 1. v0.4 TransparencyLog API still verifies end-to-end") | |
| + | ||
| + | ||
| + | def t2_legacy_bundle_shape_default_kind(): | |
| + | """A v0.4-shaped bundle (no tlog_kind, no bundle_schema) must be readable | |
| + | and interpretable as ``oversight-self-merkle-v1``.""" | |
| + | legacy_bundle = { | |
| + | "version": "0.4", | |
| + | "file_id": "abcd" * 16, | |
| + | "issuer_pubkey_ed25519": "11" * 32, | |
| + | "tlog": { | |
| + | "size": 7, | |
| + | "root": "00" * 32, | |
| + | "signature": "22" * 64, | |
| + | }, | |
| + | "inclusion_proof": { | |
| + | "index": 3, | |
| + | "leaf_hash": "33" * 32, | |
| + | "proof": ["44" * 32, "55" * 32], | |
| + | "tree_size": 7, | |
| + | "root": "00" * 32, | |
| + | }, | |
| + | } | |
| + | # Forward-looking interpretation: a v0.5+ verifier sees no tlog_kind → | |
| + | # treats this as the legacy local-merkle path. | |
| + | assert "rekor" not in legacy_bundle, "v0.4 bundle must not have a rekor field" | |
| + | assert "bundle_schema" not in legacy_bundle, "v0.4 bundle must not advertise bundle_schema" | |
| + | inferred_kind = legacy_bundle.get("tlog_kind", R.LEGACY_TLOG_KIND) | |
| + | assert inferred_kind == R.LEGACY_TLOG_KIND, ( | |
| + | f"missing tlog_kind must default to {R.LEGACY_TLOG_KIND}, got {inferred_kind!r}" | |
| + | ) | |
| + | inferred_schema = legacy_bundle.get("bundle_schema", 1) | |
| + | assert inferred_schema == 1, "missing bundle_schema must default to 1 (v0.4 implicit)" | |
| + | print(" [PASS] 2. v0.4 bundle defaults: tlog_kind=legacy, schema=1") | |
| + | ||
| + | ||
| + | def t3_v05_bundle_advertises_new_fields(): | |
| + | """The new bundle the v0.5 path emits MUST advertise both fields explicitly | |
| + | so an old (v0.4) verifier fails fast with 'unknown schema' rather than | |
| + | silently mis-routing.""" | |
| + | assert R.BUNDLE_SCHEMA == 2, f"BUNDLE_SCHEMA must be 2, got {R.BUNDLE_SCHEMA}" | |
| + | assert R.TLOG_KIND == "rekor-v2-dsse", f"TLOG_KIND drift: {R.TLOG_KIND!r}" | |
| + | assert R.LEGACY_TLOG_KIND == "oversight-self-merkle-v1" | |
| + | print(" [PASS] 3. v0.5 constants: TLOG_KIND=rekor-v2-dsse, schema=2") | |
| + | ||
| + | ||
| + | def t4_canonical_jcs_unchanged_for_legacy_payload(): | |
| + | """The exact JCS encoding for a v0.4-shaped event must not have changed. | |
| + | If this fails, downstream verifiers re-checking historical signatures | |
| + | over canonical JSON will reject events they previously accepted.""" | |
| + | event = { | |
| + | "event": "register", | |
| + | "file_id": "f0", | |
| + | "issuer_pub": "11" * 32, | |
| + | "n_beacons": 3, | |
| + | "n_watermarks": 1, | |
| + | "recipient_id": "r0", | |
| + | "timestamp": "2026-04-19T00:00:00Z", | |
| + | } | |
| + | expected = ( | |
| + | '{"event":"register","file_id":"f0","issuer_pub":' | |
| + | + '"' + "11" * 32 + '",' | |
| + | + '"n_beacons":3,"n_watermarks":1,"recipient_id":"r0","timestamp":"2026-04-19T00:00:00Z"}' | |
| + | ) | |
| + | actual = json.dumps(event, sort_keys=True, separators=(",", ":")) | |
| + | assert actual == expected, f"JCS drift!\n exp: {expected}\n got: {actual}" | |
| + | print(" [PASS] 4. v0.4 event JCS encoding unchanged") | |
| + | ||
| + | ||
| + | def t5_predicate_uri_resolves_at_tagged_path(): | |
| + | """Sanity: the PREDICATE_TYPE URI references a git-tagged path. We don't | |
| + | fetch (the e2e test does that); we just confirm the URI shape so a typo | |
| + | like missing the tag won't make it through to a release.""" | |
| + | assert R.PREDICATE_TYPE.startswith( | |
| + | "https://github.com/oversight-protocol/oversight/blob/v0.5" | |
| + | ), f"PREDICATE_TYPE not pinned to a v0.5 git tag: {R.PREDICATE_TYPE}" | |
| + | assert R.PREDICATE_TYPE.endswith("/docs/predicates/registration-v1.md") | |
| + | assert R.PREDICATE_VERSION == 1 | |
| + | print(" [PASS] 5. PREDICATE_TYPE pinned to v0.5 git-tagged path") | |
| + | ||
| + | ||
| + | def main() -> int: | |
| + | print("=" * 60) | |
| + | print(" test_rekor_backcompat - v0.4 contract preservation (offline)") | |
| + | print("=" * 60) | |
| + | t1_legacy_tlog_still_works() | |
| + | t2_legacy_bundle_shape_default_kind() | |
| + | t3_v05_bundle_advertises_new_fields() | |
| + | t4_canonical_jcs_unchanged_for_legacy_payload() | |
| + | t5_predicate_uri_resolves_at_tagged_path() | |
| + | print() | |
| + | print(" ALL BACKCOMPAT TESTS PASSED - 5/5") | |
| + | return 0 | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | sys.exit(main()) |
| @@ -0,0 +1,148 @@ | ||
| + | """ | |
| + | test_rekor_e2e | |
| + | ============== | |
| + | ||
| + | Live end-to-end test against a public Sigstore Rekor v2 log. | |
| + | ||
| + | This test makes real network calls and writes a real (immutable) entry to | |
| + | the public log shard. It is therefore gated behind the OVERSIGHT_REKOR_E2E=1 | |
| + | environment variable so routine test runs do not append to the public log. | |
| + | ||
| + | Run with: | |
| + | OVERSIGHT_REKOR_E2E=1 python3 tests/test_rekor_e2e.py | |
| + | ||
| + | What is verified: | |
| + | 1. A DSSE-wrapped Oversight registration predicate uploads successfully. | |
| + | 2. The log returns a JSON response carrying a logIndex (or equivalent | |
| + | under the v2 field naming). | |
| + | 3. The DSSE envelope verifies under the issuer pubkey AFTER the upload - | |
| + | i.e., the round-trip did not mutate signature-bearing bytes. | |
| + | 4. The on-log predicate carries recipient_pubkey_sha256, never the raw | |
| + | X25519 public key (privacy invariant). | |
| + | ||
| + | Skipped automatically when OVERSIGHT_REKOR_E2E is unset or 0. | |
| + | """ | |
| + | from __future__ import annotations | |
| + | ||
| + | import base64 | |
| + | import json | |
| + | import os | |
| + | import sys | |
| + | ||
| + | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) | |
| + | ||
| + | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey | |
| + | from cryptography.hazmat.primitives import serialization | |
| + | ||
| + | from oversight_core import rekor as R | |
| + | ||
| + | ||
| + | GATE = os.environ.get("OVERSIGHT_REKOR_E2E", "0") == "1" | |
| + | LOG_URL = os.environ.get("OVERSIGHT_REKOR_URL", R.DEFAULT_REKOR_URL) | |
| + | ||
| + | ||
| + | def _new_keypair() -> tuple[bytes, bytes, str]: | |
| + | sk = Ed25519PrivateKey.generate() | |
| + | priv_raw = sk.private_bytes_raw() | |
| + | pub_raw = sk.public_key().public_bytes_raw() | |
| + | pub_pem = sk.public_key().public_bytes( | |
| + | encoding=serialization.Encoding.PEM, | |
| + | format=serialization.PublicFormat.SubjectPublicKeyInfo, | |
| + | ).decode("ascii") | |
| + | return priv_raw, pub_raw, pub_pem | |
| + | ||
| + | ||
| + | def t1_live_upload_round_trip(): | |
| + | priv_raw, pub_raw, pub_pem = _new_keypair() | |
| + | ||
| + | # Recipient X25519 pubkey is hashed before going on-log. | |
| + | fake_x25519 = b"\x42" * 32 | |
| + | recipient_hash = R.hash_recipient_pubkey(fake_x25519.hex()) | |
| + | ||
| + | predicate = R.OversightRegistrationPredicate( | |
| + | file_id="e2e-test-" + base64.b16encode(os.urandom(8)).decode().lower(), | |
| + | issuer_pubkey_ed25519=pub_raw.hex(), | |
| + | recipient_id="opaque-recipient-id-1", | |
| + | recipient_pubkey_sha256=recipient_hash, | |
| + | suite="classic", | |
| + | registered_at="2026-04-19T00:00:00Z", | |
| + | ) | |
| + | statement = R.build_statement( | |
| + | mark_id_hex=predicate.file_id, | |
| + | content_hash_sha256_hex="ab" * 32, | |
| + | predicate=predicate, | |
| + | ) | |
| + | envelope = R.sign_dsse(statement=statement, issuer_ed25519_priv=priv_raw) | |
| + | ||
| + | # Round-trip verify BEFORE upload (sanity). | |
| + | assert R.verify_dsse(envelope, pub_raw), "local DSSE verify failed before upload" | |
| + | ||
| + | print(f" uploading to {LOG_URL} ...") | |
| + | result = R.upload_dsse(envelope=envelope, issuer_ed25519_pub_pem=pub_pem, log_url=LOG_URL) | |
| + | ||
| + | assert result.transparency_log_entry, "rekor returned empty body" | |
| + | print(f" log_index={result.log_index} log_id={(result.log_id or '')[:24]}...") | |
| + | ||
| + | # The envelope must still verify after the round trip. | |
| + | assert R.verify_dsse(envelope, pub_raw), "DSSE verify failed AFTER upload (envelope mutated?)" | |
| + | ||
| + | # Privacy invariant: raw X25519 must not appear in the on-log envelope. | |
| + | on_log_payload = base64.b64decode(envelope.payload_b64) | |
| + | assert fake_x25519.hex() not in on_log_payload.decode("utf-8", errors="ignore"), ( | |
| + | "raw recipient X25519 pubkey leaked into on-log payload" | |
| + | ) | |
| + | print(" [PASS] live round trip + privacy invariant held") | |
| + | ||
| + | ||
| + | def t2_response_carries_inclusion_data(): | |
| + | """The bundled response must give a verifier enough to verify offline. | |
| + | ||
| + | Per the v0.5 plan: the write response is the only place we get an | |
| + | inclusion proof; there is no online proof-by-index API. | |
| + | """ | |
| + | priv_raw, pub_raw, pub_pem = _new_keypair() | |
| + | predicate = R.OversightRegistrationPredicate( | |
| + | file_id="e2e-incl-" + base64.b16encode(os.urandom(8)).decode().lower(), | |
| + | issuer_pubkey_ed25519=pub_raw.hex(), | |
| + | recipient_id="opaque-recipient-id-2", | |
| + | recipient_pubkey_sha256="0" * 64, | |
| + | suite="classic", | |
| + | registered_at="2026-04-19T00:00:00Z", | |
| + | ) | |
| + | statement = R.build_statement( | |
| + | mark_id_hex=predicate.file_id, | |
| + | content_hash_sha256_hex="cd" * 32, | |
| + | predicate=predicate, | |
| + | ) | |
| + | envelope = R.sign_dsse(statement=statement, issuer_ed25519_priv=priv_raw) | |
| + | result = R.upload_dsse(envelope=envelope, issuer_ed25519_pub_pem=pub_pem, log_url=LOG_URL) | |
| + | ||
| + | body = result.transparency_log_entry | |
| + | assert isinstance(body, dict) and body, "rekor body not a non-empty dict" | |
| + | # Either logIndex appears, or inclusionProof / logEntry shape is present. | |
| + | has_idx = result.log_index is not None | |
| + | has_proof = any(k in body for k in ("inclusionProof", "inclusion_proof", "logEntry")) | |
| + | assert has_idx or has_proof, f"response missing index AND proof shape: keys={list(body.keys())}" | |
| + | print(f" [PASS] response carries inclusion data (idx={has_idx}, proof_shape={has_proof})") | |
| + | ||
| + | ||
| + | def main() -> int: | |
| + | if not GATE: | |
| + | print("=" * 60) | |
| + | print(" test_rekor_e2e: SKIPPED") | |
| + | print(" (set OVERSIGHT_REKOR_E2E=1 to run; this writes to the") | |
| + | print(" public Sigstore log)") | |
| + | print("=" * 60) | |
| + | return 0 | |
| + | print("=" * 60) | |
| + | print(f" test_rekor_e2e: LIVE against {LOG_URL}") | |
| + | print("=" * 60) | |
| + | t1_live_upload_round_trip() | |
| + | t2_response_carries_inclusion_data() | |
| + | print() | |
| + | print(" ALL E2E TESTS PASSED - 2/2") | |
| + | return 0 | |
| + | ||
| + | ||
| + | if __name__ == "__main__": | |
| + | sys.exit(main()) |