Skip to content
Draft
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 acestep/provenance/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Local provenance: session logs, C2PA manifests, local signing keys.

Phase-1 scope of the DEMON provenance architecture (see the
demon-provenance spec, 02-architecture.md §2/§3/§5/§7):

- :mod:`~acestep.provenance.session_log` — local JSONL session logs
using the same event schema as the (future) cloud ledger, tapped off
the streaming event bus.
- :mod:`~acestep.provenance.keys` — self-signed local ES256 signing
material, claim generator "DEMON (local, self-signed)".
- :mod:`~acestep.provenance.manifest` — C2PA manifest construction +
embedding on the track-asset WAV writers via ``c2pa-python``.
- :mod:`~acestep.provenance.ledger_client` — thin event/slice-hash
POST client, a complete no-op until ``DEMON_LEDGER_URL`` is set.
- :mod:`~acestep.provenance.receipts` — client-side verification of
the ledger's signed receipts (spec 06 §2.4: key-cert chain, JCS +
chain-head recomputation, Ed25519 framing checks).

Everything here degrades to a no-op (one logged warning, never an
exception on a write path) when the optional ``provenance`` extra
(``c2pa-python`` + ``cryptography``) is not installed.
"""

from __future__ import annotations

import os
from pathlib import Path

_ENV_PROVENANCE_DIR = "ACESTEP_PROVENANCE_DIR"
_DEFAULT_PROVENANCE_DIR = os.path.join(
os.path.expanduser("~"), ".daydream-scope", "provenance",
)


def provenance_dir() -> Path:
"""Root directory for local provenance state (keys, session logs).

Resolution order mirrors :func:`acestep.paths.models_dir`:
1. ACESTEP_PROVENANCE_DIR environment variable
2. ~/.daydream-scope/provenance

Deliberately NOT memoized: tests point it at a tmp dir per case,
and it is read only on session start / file sign, never per tick.
"""
return Path(os.environ.get(_ENV_PROVENANCE_DIR, _DEFAULT_PROVENANCE_DIR))


def session_logs_dir() -> Path:
"""Directory holding per-session JSONL event logs."""
return provenance_dir() / "sessions"


def keys_dir() -> Path:
"""Directory holding the local self-signed signing material."""
return provenance_dir() / "keys"
216 changes: 216 additions & 0 deletions acestep/provenance/keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Local signing key management for self-signed C2PA manifests.

On first use, generates an ES256 (P-256) key plus a certificate chain
into :func:`acestep.provenance.keys_dir` and reuses it afterwards. The
claim generator identity is deliberately distinct ("DEMON (local,
self-signed)") so locally signed content can never be confused with
Daydream-signed content (spec 02 §8).

The chain is a throwaway local mini-CA plus one leaf rather than a
single self-issued cert because c2pa-rs enforces the C2PA claim-signing
certificate profile at sign time and rejects self-issued leafs. The CA
private key is discarded after issuing the leaf, so the "CA" can never
sign anything else; trust-wise this is still a self-signed identity.

Requires the optional ``cryptography`` dependency (``provenance``
extra); without it every entry point returns ``None`` after a single
logged warning.
"""

from __future__ import annotations

import datetime
import os
from dataclasses import dataclass
from pathlib import Path

from loguru import logger

from acestep.provenance import keys_dir

__all__ = [
"CLAIM_GENERATOR_NAME",
"SigningMaterial",
"signing_material",
]

CLAIM_GENERATOR_NAME = "DEMON (local, self-signed)"

_KEY_FILENAME = "demon-local-es256.key.pem"
_CERTS_FILENAME = "demon-local-es256.certs.pem"
_VALIDITY_DAYS = 3650

_warned_unavailable = False


@dataclass(frozen=True)
class SigningMaterial:
"""PEM material ready to hand to a C2PA signer."""

key_pem: bytes
cert_chain_pem: bytes
key_path: Path
cert_path: Path


def _cryptography_available() -> bool:
global _warned_unavailable
try:
import cryptography # noqa: F401
return True
except ImportError:
if not _warned_unavailable:
_warned_unavailable = True
logger.warning(
"provenance signing disabled: `cryptography` is not "
"installed (install the `provenance` extra to enable "
"local C2PA signing)"
)
return False


def _generate(directory: Path) -> SigningMaterial:
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID

now = datetime.datetime.now(datetime.timezone.utc)
not_before = now - datetime.timedelta(days=1)
not_after = now + datetime.timedelta(days=_VALIDITY_DAYS)

ca_key = ec.generate_private_key(ec.SECP256R1())
ca_name = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "DEMON local provenance CA"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "DEMON local provenance"),
])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(not_before)
.not_valid_after(not_after)
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True, content_commitment=False,
key_encipherment=False, data_encipherment=False,
key_agreement=False, key_cert_sign=True, crl_sign=True,
encipher_only=False, decipher_only=False,
),
critical=True,
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()),
critical=False,
)
.sign(ca_key, hashes.SHA256())
)

leaf_key = ec.generate_private_key(ec.SECP256R1())
leaf_name = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, CLAIM_GENERATOR_NAME),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "DEMON local provenance"),
])
leaf_cert = (
x509.CertificateBuilder()
.subject_name(leaf_name)
.issuer_name(ca_name)
.public_key(leaf_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(not_before)
.not_valid_after(not_after)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True, content_commitment=False,
key_encipherment=False, data_encipherment=False,
key_agreement=False, key_cert_sign=False, crl_sign=False,
encipher_only=False, decipher_only=False,
),
critical=True,
)
.add_extension(
# The cert profile wants a non-empty EKU; emailProtection is
# what the c2pa-rs test fixtures use for document signing.
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.EMAIL_PROTECTION]),
critical=False,
)
.add_extension(
x509.SubjectKeyIdentifier.from_public_key(leaf_key.public_key()),
critical=False,
)
.add_extension(
x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
critical=False,
)
.sign(ca_key, hashes.SHA256())
)

key_pem = leaf_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
chain_pem = (
leaf_cert.public_bytes(serialization.Encoding.PEM)
+ ca_cert.public_bytes(serialization.Encoding.PEM)
)

directory.mkdir(parents=True, exist_ok=True)
key_path = directory / _KEY_FILENAME
cert_path = directory / _CERTS_FILENAME
key_path.write_bytes(key_pem)
os.chmod(key_path, 0o600)
cert_path.write_bytes(chain_pem)
logger.info(
"provenance signing material generated key={} certs={}",
key_path, cert_path,
)
return SigningMaterial(
key_pem=key_pem, cert_chain_pem=chain_pem,
key_path=key_path, cert_path=cert_path,
)


def _still_valid(cert_chain_pem: bytes) -> bool:
from cryptography import x509

try:
leaf = x509.load_pem_x509_certificate(cert_chain_pem)
except Exception:
return False
now = datetime.datetime.now(datetime.timezone.utc)
return leaf.not_valid_before_utc <= now < leaf.not_valid_after_utc


def signing_material(directory: Path | None = None) -> SigningMaterial | None:
"""Return the local signing material, generating it on first use.

``None`` when ``cryptography`` is unavailable or generation failed;
callers must treat that as "provenance off", never as an error.
"""
if not _cryptography_available():
return None
d = Path(directory) if directory is not None else keys_dir()
key_path = d / _KEY_FILENAME
cert_path = d / _CERTS_FILENAME
try:
if key_path.is_file() and cert_path.is_file():
key_pem = key_path.read_bytes()
chain_pem = cert_path.read_bytes()
if _still_valid(chain_pem):
return SigningMaterial(
key_pem=key_pem, cert_chain_pem=chain_pem,
key_path=key_path, cert_path=cert_path,
)
logger.warning(
"provenance cert at {} invalid or expired; regenerating",
cert_path,
)
return _generate(d)
except Exception as exc: # noqa: BLE001 — never break a caller's write path
logger.warning("provenance signing material unavailable: {}", exc)
return None
Loading