provenance.py python
350 lines 12.7 KB
Raw
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