provenance.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 days ago
| 1 | """Agent identity and commit signing for the Muse VCS. |
| 2 | |
| 3 | Every commit in Muse can carry cryptographic provenance metadata that |
| 4 | identifies *who* or *what* produced it — a human author, an autonomous AI |
| 5 | agent, or a specific toolchain run. |
| 6 | |
| 7 | Signing model |
| 8 | ------------- |
| 9 | Signatures use **Ed25519** with the per-hub identity keypair stored under |
| 10 | ``~/.muse/keys/{hostname}.pem`` (same keypair used for MSign request |
| 11 | authentication). This is an asymmetric scheme: the private key signs; the |
| 12 | public key (embedded in the commit record) verifies. Any party with the |
| 13 | commit record can verify the signature without access to the private key or |
| 14 | any external service. |
| 15 | |
| 16 | Signature format |
| 17 | ---------------- |
| 18 | The signed input is :func:`provenance_payload` — a SHA-256 hex digest that |
| 19 | binds the commit content identity (``commit_id``) to authorship claims |
| 20 | (``author``, ``agent_id``, ``model_id``, ``toolchain_id``, ``prompt_hash``). |
| 21 | |
| 22 | ``CommitRecord.signature`` |
| 23 | Base64url-encoded Ed25519 signature (no padding), 86 characters. |
| 24 | |
| 25 | ``CommitRecord.signer_public_key`` |
| 26 | Base64url-encoded raw Ed25519 public key bytes (32 bytes → 43 chars). |
| 27 | Embedded in the commit record so that verification is fully offline. |
| 28 | |
| 29 | ``CommitRecord.signer_key_id`` |
| 30 | First 16 hex characters of SHA-256(raw public key bytes). Short enough |
| 31 | to log, long enough for practical uniqueness. |
| 32 | |
| 33 | Key management |
| 34 | -------------- |
| 35 | Keys are the same Ed25519 keypairs used for MSign HTTP authentication. |
| 36 | Generate and register a keypair with:: |
| 37 | |
| 38 | muse auth keygen --hub http://localhost:10003 |
| 39 | muse auth register --hub http://localhost:10003 --handle <handle> |
| 40 | |
| 41 | Usage |
| 42 | ----- |
| 43 | :: |
| 44 | |
| 45 | from muse.core.provenance import ( |
| 46 | make_agent_identity, sign_commit_ed25519, verify_commit_ed25519, |
| 47 | public_key_fingerprint, sign_commit_record, |
| 48 | ) |
| 49 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 50 | |
| 51 | private_key: Ed25519PrivateKey = ... |
| 52 | payload = provenance_payload(commit_id, agent_id="my-agent", ...) |
| 53 | sig = sign_commit_ed25519(payload, private_key) |
| 54 | pub_bytes = private_key.public_key().public_bytes(Raw, Raw) |
| 55 | assert verify_commit_ed25519(payload, sig, pub_bytes) |
| 56 | """ |
| 57 | |
| 58 | from __future__ import annotations |
| 59 | |
| 60 | import base64 |
| 61 | import hashlib |
| 62 | import logging |
| 63 | import pathlib |
| 64 | |
| 65 | from typing import TYPE_CHECKING, TypedDict |
| 66 | |
| 67 | if TYPE_CHECKING: |
| 68 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 69 | |
| 70 | logger = logging.getLogger(__name__) |
| 71 | |
| 72 | # Null-byte field separator — same convention as compute_commit_id in snapshot.py. |
| 73 | # Prevents separator-injection attacks where a field value contains the separator. |
| 74 | _PROV_SEP = "\x00" |
| 75 | |
| 76 | # Version constant for the provenance payload format. |
| 77 | # v2 adds: version prefix + committed_at timestamp binding. |
| 78 | # v1 (no prefix): commit_id + author + agent_id + model_id + toolchain_id + prompt_hash |
| 79 | # v2 (this version): "muse-provenance-v2\n" + same fields + committed_at |
| 80 | PROVENANCE_PAYLOAD_VERSION = 2 |
| 81 | _PROV_VERSION_PREFIX = "muse-provenance-v2\n" |
| 82 | |
| 83 | |
| 84 | # --------------------------------------------------------------------------- |
| 85 | # Provenance signing payload |
| 86 | # --------------------------------------------------------------------------- |
| 87 | |
| 88 | |
| 89 | def provenance_payload( |
| 90 | commit_id: str, |
| 91 | *, |
| 92 | author: str = "", |
| 93 | agent_id: str = "", |
| 94 | model_id: str = "", |
| 95 | toolchain_id: str = "", |
| 96 | prompt_hash: str = "", |
| 97 | committed_at: str = "", |
| 98 | ) -> str: |
| 99 | """Compute the SHA-256 hex digest of the provenance signing payload (v2). |
| 100 | |
| 101 | Binds the commit's *content identity* (``commit_id``, which covers |
| 102 | snapshot + message + parents + timestamp) to its *authorship claims* |
| 103 | (``author``, ``agent_id``, ``model_id``, ``toolchain_id``, |
| 104 | ``prompt_hash``) and the ``committed_at`` wall-clock timestamp. |
| 105 | |
| 106 | **Version 2 changes (this version):** |
| 107 | - Payload is prefixed with ``"muse-provenance-v2\\n"`` so v1 and v2 |
| 108 | signatures are clearly distinguishable. |
| 109 | - ``committed_at`` is appended as the last field so that the commit |
| 110 | timestamp cannot be mutated without invalidating the signature. |
| 111 | |
| 112 | This is what :func:`sign_commit_ed25519` signs — not the bare |
| 113 | ``commit_id``. The verifier must recompute this payload from the stored |
| 114 | record fields and then call :func:`verify_commit_ed25519`. |
| 115 | |
| 116 | ``branch``, ``repo_id``, and ``metadata`` are intentionally excluded: |
| 117 | they are mutable by design (a commit is reachable from multiple branches |
| 118 | after a merge) and their mutation does not represent an integrity |
| 119 | violation. |
| 120 | |
| 121 | Null bytes are used as field separators to prevent injection attacks |
| 122 | from field values that contain the separator character. |
| 123 | |
| 124 | Canonical payload format:: |
| 125 | |
| 126 | muse-provenance-v2\\n |
| 127 | <commit_id>\\x00<author>\\x00<agent_id>\\x00<model_id>\\x00<toolchain_id> |
| 128 | \\x00<prompt_hash>\\x00<committed_at> |
| 129 | |
| 130 | Args: |
| 131 | commit_id: SHA-256 hex commit ID (canonical content identity). |
| 132 | author: Display author name / email. |
| 133 | agent_id: Stable agent identifier. |
| 134 | model_id: Model name/version (empty for humans). |
| 135 | toolchain_id: Toolchain producing the commit. |
| 136 | prompt_hash: SHA-256 hex of the instruction prompt (privacy-preserving). |
| 137 | committed_at: ISO-8601 timestamp of the commit (e.g. ``"2026-04-08T12:00:00"``). |
| 138 | Empty string is accepted for backward compatibility with unsigned commits. |
| 139 | |
| 140 | Returns: |
| 141 | 64-character lowercase hex SHA-256 digest of the combined payload. |
| 142 | """ |
| 143 | fields = [commit_id, author, agent_id, model_id, toolchain_id, prompt_hash, committed_at] |
| 144 | raw = (_PROV_VERSION_PREFIX + _PROV_SEP.join(fields)).encode() |
| 145 | return hashlib.sha256(raw).hexdigest() |
| 146 | |
| 147 | |
| 148 | # --------------------------------------------------------------------------- |
| 149 | # Agent identity |
| 150 | # --------------------------------------------------------------------------- |
| 151 | |
| 152 | |
| 153 | class AgentIdentity(TypedDict, total=False): |
| 154 | """Structured identity record for a human or AI agent. |
| 155 | |
| 156 | All fields are optional so that partial provenance (e.g. only |
| 157 | ``agent_id`` is known) can be expressed without filling dummy values. |
| 158 | |
| 159 | ``agent_id`` |
| 160 | Stable human-readable identifier chosen by the agent or its operator. |
| 161 | Should be unique within a team (e.g. ``"counterpoint-bot-v1"``). |
| 162 | ``model_id`` |
| 163 | Model identifier for AI agents (e.g. ``"claude-opus-4"``). |
| 164 | Empty for human authors. |
| 165 | ``toolchain_id`` |
| 166 | Build system or IDE that produced the commit |
| 167 | (e.g. ``"cursor-agent-v2"``). |
| 168 | ``prompt_hash`` |
| 169 | SHA-256 hex of the instruction/prompt that triggered this session. |
| 170 | Privacy-preserving: the hash is logged without storing the content. |
| 171 | ``execution_context_hash`` |
| 172 | SHA-256 hex of any additional execution context (system prompt, |
| 173 | environment config, etc.). |
| 174 | """ |
| 175 | |
| 176 | agent_id: str |
| 177 | model_id: str |
| 178 | toolchain_id: str |
| 179 | prompt_hash: str |
| 180 | execution_context_hash: str |
| 181 | |
| 182 | |
| 183 | def make_agent_identity( |
| 184 | agent_id: str, |
| 185 | *, |
| 186 | model_id: str = "", |
| 187 | toolchain_id: str = "", |
| 188 | prompt: str = "", |
| 189 | execution_context: str = "", |
| 190 | ) -> AgentIdentity: |
| 191 | """Build an :class:`AgentIdentity` with optional hashed sensitive fields. |
| 192 | |
| 193 | ``prompt`` and ``execution_context`` are hashed before storage so that |
| 194 | the raw instruction text never appears in the commit record. |
| 195 | |
| 196 | Args: |
| 197 | agent_id: Stable agent identifier string. |
| 198 | model_id: Model name/version (empty for humans). |
| 199 | toolchain_id: Toolchain producing the commit. |
| 200 | prompt: Raw instruction text to hash (not stored). |
| 201 | execution_context: Additional context to hash (not stored). |
| 202 | |
| 203 | Returns: |
| 204 | An :class:`AgentIdentity` with only non-empty fields populated. |
| 205 | """ |
| 206 | identity = AgentIdentity(agent_id=agent_id) |
| 207 | if model_id: |
| 208 | identity["model_id"] = model_id |
| 209 | if toolchain_id: |
| 210 | identity["toolchain_id"] = toolchain_id |
| 211 | if prompt: |
| 212 | identity["prompt_hash"] = hashlib.sha256(prompt.encode()).hexdigest() |
| 213 | if execution_context: |
| 214 | identity["execution_context_hash"] = hashlib.sha256( |
| 215 | execution_context.encode() |
| 216 | ).hexdigest() |
| 217 | return identity |
| 218 | |
| 219 | |
| 220 | # --------------------------------------------------------------------------- |
| 221 | # Ed25519 signing and verification |
| 222 | # --------------------------------------------------------------------------- |
| 223 | |
| 224 | |
| 225 | def sign_commit_ed25519(payload: str, private_key: Ed25519PrivateKey) -> str: |
| 226 | """Sign *payload* with an Ed25519 private key. |
| 227 | |
| 228 | Args: |
| 229 | payload: Hex SHA-256 provenance payload from :func:`provenance_payload`. |
| 230 | private_key: ``Ed25519PrivateKey`` instance from the ``cryptography`` package. |
| 231 | |
| 232 | Returns: |
| 233 | Base64url-encoded signature (no padding), 88 characters. |
| 234 | """ |
| 235 | sig_bytes = private_key.sign(payload.encode()) |
| 236 | return base64.urlsafe_b64encode(sig_bytes).rstrip(b"=").decode("ascii") |
| 237 | |
| 238 | |
| 239 | def verify_commit_ed25519(payload: str, signature_b64: str, public_key_bytes: bytes) -> bool: |
| 240 | """Verify an Ed25519 *signature_b64* over *payload* using *public_key_bytes*. |
| 241 | |
| 242 | Args: |
| 243 | payload: Hex SHA-256 provenance payload from :func:`provenance_payload`. |
| 244 | signature_b64: Base64url-encoded signature (with or without padding). |
| 245 | public_key_bytes: Raw 32-byte Ed25519 public key. |
| 246 | |
| 247 | Returns: |
| 248 | ``True`` when the signature is valid, ``False`` otherwise. |
| 249 | """ |
| 250 | from cryptography.exceptions import InvalidSignature |
| 251 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey |
| 252 | |
| 253 | # Restore padding if stripped. |
| 254 | padded = signature_b64 + "=" * (-len(signature_b64) % 4) |
| 255 | try: |
| 256 | sig_bytes = base64.urlsafe_b64decode(padded) |
| 257 | except Exception: |
| 258 | return False |
| 259 | |
| 260 | try: |
| 261 | pub_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) |
| 262 | pub_key.verify(sig_bytes, payload.encode()) |
| 263 | return True |
| 264 | except InvalidSignature: |
| 265 | return False |
| 266 | except Exception: |
| 267 | return False |
| 268 | |
| 269 | |
| 270 | def public_key_fingerprint(public_key_bytes: bytes) -> str: |
| 271 | """Return the first 16 hex characters of SHA-256(*public_key_bytes*). |
| 272 | |
| 273 | Short enough to log, long enough for practical uniqueness. |
| 274 | |
| 275 | Args: |
| 276 | public_key_bytes: Raw 32-byte Ed25519 public key. |
| 277 | |
| 278 | Returns: |
| 279 | 16-character lowercase hex string. |
| 280 | """ |
| 281 | return hashlib.sha256(public_key_bytes).hexdigest()[:16] |
| 282 | |
| 283 | |
| 284 | def encode_public_key(private_key: Ed25519PrivateKey) -> tuple[bytes, str]: |
| 285 | """Extract and encode the public key from an Ed25519 private key. |
| 286 | |
| 287 | Args: |
| 288 | private_key: ``Ed25519PrivateKey`` instance. |
| 289 | |
| 290 | Returns: |
| 291 | ``(raw_bytes, b64url_no_padding)`` — the 32-byte raw public key and |
| 292 | its base64url encoding (no padding, 43 characters). |
| 293 | """ |
| 294 | from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat |
| 295 | |
| 296 | pub_key = private_key.public_key() |
| 297 | raw_bytes = pub_key.public_bytes(Encoding.Raw, PublicFormat.Raw) |
| 298 | b64 = base64.urlsafe_b64encode(raw_bytes).rstrip(b"=").decode("ascii") |
| 299 | return raw_bytes, b64 |
| 300 | |
| 301 | |
| 302 | # --------------------------------------------------------------------------- |
| 303 | # Convenience: sign a CommitRecord in-place |
| 304 | # --------------------------------------------------------------------------- |
| 305 | |
| 306 | |
| 307 | def sign_commit_record( |
| 308 | commit_id: str, |
| 309 | agent_id: str, |
| 310 | private_key: Ed25519PrivateKey, |
| 311 | *, |
| 312 | author: str = "", |
| 313 | model_id: str = "", |
| 314 | toolchain_id: str = "", |
| 315 | prompt_hash: str = "", |
| 316 | committed_at: str = "", |
| 317 | ) -> tuple[str, str, str] | None: |
| 318 | """Sign the provenance payload (v2) for *commit_id* with *private_key*. |
| 319 | |
| 320 | Computes :func:`provenance_payload` from the supplied fields so the |
| 321 | signature covers both the content identity (``commit_id``) and the |
| 322 | authorship claims, including the ``committed_at`` timestamp. |
| 323 | |
| 324 | Args: |
| 325 | commit_id: SHA-256 hex commit ID. |
| 326 | agent_id: Stable agent identifier (metadata only; key is from hub identity). |
| 327 | private_key: ``Ed25519PrivateKey`` instance. |
| 328 | author: Display author name / email. |
| 329 | model_id: Model name/version (empty for humans). |
| 330 | toolchain_id: Toolchain producing the commit. |
| 331 | prompt_hash: SHA-256 hex of the instruction prompt. |
| 332 | committed_at: ISO-8601 timestamp — binds the wall-clock time to the signature. |
| 333 | |
| 334 | Returns: |
| 335 | ``(signature_b64, public_key_b64, key_fingerprint)`` on success. |
| 336 | """ |
| 337 | payload = provenance_payload( |
| 338 | commit_id, |
| 339 | author=author, |
| 340 | agent_id=agent_id, |
| 341 | model_id=model_id, |
| 342 | toolchain_id=toolchain_id, |
| 343 | prompt_hash=prompt_hash, |
| 344 | committed_at=committed_at, |
| 345 | ) |
| 346 | sig = sign_commit_ed25519(payload, private_key) |
| 347 | raw_bytes, pub_b64 = encode_public_key(private_key) |
| 348 | fprint = public_key_fingerprint(raw_bytes) |
| 349 | logger.debug("✅ Ed25519-signed commit %s with key %s", commit_id[:8], fprint) |
| 350 | return sig, pub_b64, fprint |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
32 days ago