agentic-runner 2.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""The installation's Recipient Key, and the sealed box it opens (PRD issue 48, 22 A4-A7).
|
|
2
|
+
|
|
3
|
+
One X25519 keypair per **installation** -- a Helm release's replicas share the host Secret
|
|
4
|
+
and register the same key, a workstation process is its own installation (issues 46, 47).
|
|
5
|
+
The public half is registered at bootstrap (issue 41) and relayed by a platform that did
|
|
6
|
+
not mint it; the private half never leaves this state directory, and the plaintext it
|
|
7
|
+
opens never leaves process memory.
|
|
8
|
+
|
|
9
|
+
**The construction**, and why this one. Research 18's precedents are HPKE Base, the age
|
|
10
|
+
stanza and libsodium's ``crypto_box_seal`` -- all the same sealed-box shape: an ephemeral
|
|
11
|
+
X25519 keypair, one Diffie-Hellman against the recipient's public key, a KDF over the
|
|
12
|
+
result, and an AEAD. We build that shape out of primitives both ends already have rather
|
|
13
|
+
than shipping a fourth implementation of it:
|
|
14
|
+
|
|
15
|
+
ikm = X25519(ephemeral_private, recipient_public)
|
|
16
|
+
key = HKDF-SHA256(ikm, salt="", info=SEAL_DOMAIN || ephemeral_public
|
|
17
|
+
|| recipient_public, length=32)
|
|
18
|
+
body = AES-256-GCM(key, nonce=0, plaintext, aad=delivery_binding(...))
|
|
19
|
+
blob = base64(ephemeral_public || body)
|
|
20
|
+
|
|
21
|
+
The all-zero nonce is safe and is the reason the ephemeral public key is in the KDF info:
|
|
22
|
+
the key is derived per sealing from a fresh ephemeral scalar, so it encrypts exactly one
|
|
23
|
+
message. The binding triple is the AEAD's associated data, which is what makes a
|
|
24
|
+
ciphertext lifted from another ``(contract, slot, recipient key)`` fail the tag check
|
|
25
|
+
rather than decrypt to something.
|
|
26
|
+
|
|
27
|
+
That choice is what lets the **browser** seal with no library at all: Web Crypto does
|
|
28
|
+
X25519, HKDF and AES-GCM natively (ADR-0009's constraint is that the console pulls in no
|
|
29
|
+
new dependency, and the native platform feature is the smallest way to honour it), and
|
|
30
|
+
``frontend/src/lib/sealed-credential.ts`` is the same five lines in TypeScript.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import base64
|
|
36
|
+
import binascii
|
|
37
|
+
import json
|
|
38
|
+
import secrets
|
|
39
|
+
from collections.abc import Callable, Sequence
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from datetime import UTC, datetime
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Final
|
|
44
|
+
from uuid import UUID
|
|
45
|
+
|
|
46
|
+
from cryptography.exceptions import InvalidTag
|
|
47
|
+
from cryptography.hazmat.primitives import hashes
|
|
48
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import (
|
|
49
|
+
X25519PrivateKey,
|
|
50
|
+
X25519PublicKey,
|
|
51
|
+
)
|
|
52
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
53
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
54
|
+
|
|
55
|
+
from agentic_runner_contracts.runner_registration import RecipientKey
|
|
56
|
+
from agentic_runner_contracts.sealed_credential import (
|
|
57
|
+
RENEWAL_INTERVAL,
|
|
58
|
+
SEAL_DOMAIN,
|
|
59
|
+
OpenedCredential,
|
|
60
|
+
SealedCredential,
|
|
61
|
+
delivery_binding,
|
|
62
|
+
key_fingerprint,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"RECIPIENT_KEY_FILENAME",
|
|
67
|
+
"RecipientKeyPair",
|
|
68
|
+
"RecipientKeyStore",
|
|
69
|
+
"SealedCredentialError",
|
|
70
|
+
"SealedCredentialStream",
|
|
71
|
+
"generate_recipient_key",
|
|
72
|
+
"open_sealed",
|
|
73
|
+
"seal",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
RECIPIENT_KEY_FILENAME: Final[str] = "recipient-key.json"
|
|
77
|
+
|
|
78
|
+
_PUBLIC_KEY_BYTES: Final[int] = 32
|
|
79
|
+
_KEY_BYTES: Final[int] = 32
|
|
80
|
+
_NONCE: Final[bytes] = bytes(12)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SealedCredentialError(ValueError):
|
|
84
|
+
"""A sealed value could not be opened: wrong key, wrong binding, or malformed."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class RecipientKeyPair:
|
|
89
|
+
"""One installation's Recipient Key. ``private_key`` is memory and 0600 disk only."""
|
|
90
|
+
|
|
91
|
+
key_id: str
|
|
92
|
+
public_key: str
|
|
93
|
+
private_key: str
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def fingerprint(self) -> str:
|
|
97
|
+
return key_fingerprint(self.public_key)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def generate_recipient_key() -> RecipientKeyPair:
|
|
101
|
+
"""A fresh X25519 keypair and the id the platform will relay it under.
|
|
102
|
+
|
|
103
|
+
The id is random rather than derived from the key: a derived id would let anyone
|
|
104
|
+
holding the public half recompute it, and the id is what a delivered ciphertext is
|
|
105
|
+
bound to -- it has to change on every renewal even in the (impossible, but) case of a
|
|
106
|
+
repeated key.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
private = X25519PrivateKey.generate()
|
|
110
|
+
return RecipientKeyPair(
|
|
111
|
+
key_id=f"rk_{secrets.token_hex(16)}",
|
|
112
|
+
public_key=_b64(private.public_key().public_bytes_raw()),
|
|
113
|
+
private_key=_b64(private.private_bytes_raw()),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def seal(*, public_key: str, binding: bytes, plaintext: str) -> str:
|
|
118
|
+
"""Seal one value to a Recipient Key. The Runner's own half of 22 A7's renewal.
|
|
119
|
+
|
|
120
|
+
Delivery itself happens in the browser; this exists because a renewal re-seals values
|
|
121
|
+
the Runner already holds, and because a test that seals in Python and opens in Python
|
|
122
|
+
proves nothing about the wire -- the fixture the round-trip test opens is produced by
|
|
123
|
+
the *TypeScript* sealer for exactly that reason.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
ephemeral = X25519PrivateKey.generate()
|
|
127
|
+
ephemeral_public = ephemeral.public_key().public_bytes_raw()
|
|
128
|
+
recipient = _public_key(public_key)
|
|
129
|
+
shared = ephemeral.exchange(recipient)
|
|
130
|
+
key = _derive(shared, ephemeral_public, recipient.public_bytes_raw())
|
|
131
|
+
body = AESGCM(key).encrypt(_NONCE, plaintext.encode("utf-8"), binding)
|
|
132
|
+
return _b64(ephemeral_public + body)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def open_sealed(*, private_key: str, binding: bytes, ciphertext: str) -> str:
|
|
136
|
+
"""Open a sealed value, or refuse.
|
|
137
|
+
|
|
138
|
+
Every failure is one exception type: a ciphertext bound to another triple, one sealed
|
|
139
|
+
to a different Recipient Key and one that is simply corrupt are indistinguishable to
|
|
140
|
+
the holder, and telling them apart would be an oracle. The caller's answer to all
|
|
141
|
+
three is the same -- refuse the slot and say which reference could not be opened.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
blob = base64.b64decode(ciphertext, validate=True)
|
|
146
|
+
except (binascii.Error, ValueError) as error:
|
|
147
|
+
raise SealedCredentialError("the sealed value is not base64") from error
|
|
148
|
+
if len(blob) <= _PUBLIC_KEY_BYTES:
|
|
149
|
+
raise SealedCredentialError("the sealed value is too short to carry an ephemeral key")
|
|
150
|
+
ephemeral_public, body = blob[:_PUBLIC_KEY_BYTES], blob[_PUBLIC_KEY_BYTES:]
|
|
151
|
+
private = _private_key(private_key)
|
|
152
|
+
shared = private.exchange(X25519PublicKey.from_public_bytes(ephemeral_public))
|
|
153
|
+
key = _derive(shared, ephemeral_public, private.public_key().public_bytes_raw())
|
|
154
|
+
try:
|
|
155
|
+
opened = AESGCM(key).decrypt(_NONCE, body, binding)
|
|
156
|
+
except InvalidTag as error:
|
|
157
|
+
raise SealedCredentialError(
|
|
158
|
+
"the sealed value does not open with this Recipient Key and binding"
|
|
159
|
+
) from error
|
|
160
|
+
return opened.decode("utf-8")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class RecipientKeyStore:
|
|
164
|
+
"""The installation's key on disk, plus the 30-day renewal's two-key window (22 A7).
|
|
165
|
+
|
|
166
|
+
A renewal is the Runner's own act on its own schedule -- nothing on the platform side
|
|
167
|
+
triggers it and no funder is asked for anything. It generates a new keypair, keeps the
|
|
168
|
+
old one beside it, re-seals every slot and only then destroys the old private half.
|
|
169
|
+
The window is what makes the sequence safe to interrupt: a Runner that crashes between
|
|
170
|
+
the push and the confirmation re-reads both keys and opens whatever it is handed.
|
|
171
|
+
|
|
172
|
+
A **reinstall** is this state file being gone. The new process generates a key with no
|
|
173
|
+
previous half, so nothing it is handed opens -- which is precisely the signal the
|
|
174
|
+
control plane turns into "every slot of that installation is stale, notify each
|
|
175
|
+
funder" (22 A7's last clause).
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self, state_dir: Path, *, clock: Callable[[], datetime] = lambda: datetime.now(UTC)
|
|
180
|
+
) -> None:
|
|
181
|
+
self._path = state_dir / RECIPIENT_KEY_FILENAME
|
|
182
|
+
self._state: dict[str, object] | None = None
|
|
183
|
+
# Injected so the renewal window is testable without waiting 30 days. Only the
|
|
184
|
+
# *first* generation reads it; every rotation is stamped with the caller's `now`.
|
|
185
|
+
self._clock = clock
|
|
186
|
+
|
|
187
|
+
def current(self) -> RecipientKeyPair:
|
|
188
|
+
"""This installation's Recipient Key, generated on first call."""
|
|
189
|
+
|
|
190
|
+
state = self._load()
|
|
191
|
+
return _pair(state["current"])
|
|
192
|
+
|
|
193
|
+
def previous(self) -> RecipientKeyPair | None:
|
|
194
|
+
"""The key being retired, while a renewal is in flight."""
|
|
195
|
+
|
|
196
|
+
raw = self._load().get("previous")
|
|
197
|
+
return None if raw is None else _pair(raw)
|
|
198
|
+
|
|
199
|
+
def rotated_at(self) -> datetime:
|
|
200
|
+
return datetime.fromisoformat(str(self._load()["rotated_at"]))
|
|
201
|
+
|
|
202
|
+
def managed_by(self) -> str | None:
|
|
203
|
+
"""Who owns rotation: ``None`` for this process, else the installer (issue 46).
|
|
204
|
+
|
|
205
|
+
A Helm release's replicas share one key through a Secret, so a rotation by one of
|
|
206
|
+
them would strand the others on a key the platform no longer relays to. There the
|
|
207
|
+
installer rotates, by replacing the Secret, and :meth:`install` hands the new
|
|
208
|
+
key in with the old one kept as ``previous`` -- the same two-key window a
|
|
209
|
+
process-driven renewal walks through.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
raw = self._load().get("managed_by")
|
|
213
|
+
return None if raw is None else str(raw)
|
|
214
|
+
|
|
215
|
+
def due(self, now: datetime) -> bool:
|
|
216
|
+
"""Whether the renewal window has passed. An installation offline past it is due
|
|
217
|
+
the moment it wakes, which is 22 A7's "re-seals on wake" with no extra state."""
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
self.managed_by() is None
|
|
221
|
+
and self.previous() is None
|
|
222
|
+
and now - self.rotated_at() >= RENEWAL_INTERVAL
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def install(self, pair: RecipientKeyPair, *, managed_by: str, now: datetime) -> bool:
|
|
226
|
+
"""Adopt an installer-held key; returns whether anything changed.
|
|
227
|
+
|
|
228
|
+
The same key again is a no-op, so a pod restart re-reads its Secret without
|
|
229
|
+
touching the file. A *different* key is the installer's rotation: the key on
|
|
230
|
+
disk becomes ``previous`` so every value sealed to it still opens until the
|
|
231
|
+
re-seal confirms (22 A7), and the process never rotates on its own after this.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
if not self._path.exists():
|
|
235
|
+
# A first boot: adopt outright, rather than let `_load` mint a key nobody
|
|
236
|
+
# registered only to demote it.
|
|
237
|
+
self._save(
|
|
238
|
+
{
|
|
239
|
+
"current": _raw(pair),
|
|
240
|
+
"previous": None,
|
|
241
|
+
"rotated_at": now.isoformat(),
|
|
242
|
+
"managed_by": managed_by,
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
return True
|
|
246
|
+
state = self._load()
|
|
247
|
+
current = _pair(state["current"])
|
|
248
|
+
if current.key_id == pair.key_id and state.get("managed_by") == managed_by:
|
|
249
|
+
return False
|
|
250
|
+
if current.key_id != pair.key_id:
|
|
251
|
+
state["previous"] = state["current"]
|
|
252
|
+
state["current"] = _raw(pair)
|
|
253
|
+
state["rotated_at"] = now.isoformat()
|
|
254
|
+
state["managed_by"] = managed_by
|
|
255
|
+
self._save(state)
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
def rotate(self, now: datetime) -> RecipientKeyPair:
|
|
259
|
+
"""Generate the next key and keep the old one openable until every slot confirms."""
|
|
260
|
+
|
|
261
|
+
state = self._load()
|
|
262
|
+
fresh = generate_recipient_key()
|
|
263
|
+
state["previous"] = state["current"]
|
|
264
|
+
state["current"] = _raw(fresh)
|
|
265
|
+
state["rotated_at"] = now.isoformat()
|
|
266
|
+
self._save(state)
|
|
267
|
+
return fresh
|
|
268
|
+
|
|
269
|
+
def retire_previous(self) -> None:
|
|
270
|
+
"""Destroy the old private key. Called only once every slot reports the new id."""
|
|
271
|
+
|
|
272
|
+
state = self._load()
|
|
273
|
+
if state.get("previous") is None:
|
|
274
|
+
return
|
|
275
|
+
state["previous"] = None
|
|
276
|
+
self._save(state)
|
|
277
|
+
|
|
278
|
+
def _load(self) -> dict[str, object]:
|
|
279
|
+
if self._state is not None:
|
|
280
|
+
return self._state
|
|
281
|
+
if self._path.exists():
|
|
282
|
+
self._state = json.loads(self._path.read_text(encoding="utf-8"))
|
|
283
|
+
else:
|
|
284
|
+
self._state = {
|
|
285
|
+
"current": _raw(generate_recipient_key()),
|
|
286
|
+
"previous": None,
|
|
287
|
+
"rotated_at": self._clock().isoformat(),
|
|
288
|
+
}
|
|
289
|
+
self._save(self._state)
|
|
290
|
+
return self._state
|
|
291
|
+
|
|
292
|
+
def _save(self, state: dict[str, object]) -> None:
|
|
293
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
self._path.write_text(json.dumps(state), encoding="utf-8")
|
|
295
|
+
# The private half is in this file; a shared PVC or a workstation home is not a
|
|
296
|
+
# place to leave it group-readable (the same rule `registration.save_state` keeps).
|
|
297
|
+
self._path.chmod(0o600)
|
|
298
|
+
self._state = state
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _raw(pair: RecipientKeyPair) -> dict[str, str]:
|
|
302
|
+
return {
|
|
303
|
+
"key_id": pair.key_id,
|
|
304
|
+
"public_key": pair.public_key,
|
|
305
|
+
"private_key": pair.private_key,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _pair(raw: object) -> RecipientKeyPair:
|
|
310
|
+
if not isinstance(raw, dict):
|
|
311
|
+
raise SealedCredentialError("the Recipient Key state file is malformed")
|
|
312
|
+
return RecipientKeyPair(
|
|
313
|
+
key_id=str(raw["key_id"]),
|
|
314
|
+
public_key=str(raw["public_key"]),
|
|
315
|
+
private_key=str(raw["private_key"]),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _derive(shared: bytes, ephemeral_public: bytes, recipient_public: bytes) -> bytes:
|
|
320
|
+
return HKDF(
|
|
321
|
+
algorithm=hashes.SHA256(),
|
|
322
|
+
length=_KEY_BYTES,
|
|
323
|
+
salt=b"",
|
|
324
|
+
info=SEAL_DOMAIN.encode("utf-8") + ephemeral_public + recipient_public,
|
|
325
|
+
).derive(shared)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _public_key(value: str) -> X25519PublicKey:
|
|
329
|
+
return X25519PublicKey.from_public_bytes(_raw_key(value, "public"))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _private_key(value: str) -> X25519PrivateKey:
|
|
333
|
+
return X25519PrivateKey.from_private_bytes(_raw_key(value, "private"))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _raw_key(value: str, half: str) -> bytes:
|
|
337
|
+
try:
|
|
338
|
+
raw = base64.b64decode(value, validate=True)
|
|
339
|
+
except (binascii.Error, ValueError) as error:
|
|
340
|
+
raise SealedCredentialError(f"a Recipient Key's {half} half must be base64") from error
|
|
341
|
+
if len(raw) != _PUBLIC_KEY_BYTES:
|
|
342
|
+
raise SealedCredentialError(f"a Recipient Key's {half} half must be 32 bytes")
|
|
343
|
+
return raw
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _b64(raw: bytes) -> str:
|
|
347
|
+
return base64.b64encode(raw).decode("ascii")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class SealedCredentialStream:
|
|
351
|
+
"""The sealed half of the heartbeat, as one testable step (22 A5, A7, A9).
|
|
352
|
+
|
|
353
|
+
The Runner has no long-running heartbeat driver of its own yet (PRD issue 44 owns
|
|
354
|
+
that loop), so this is written as a pure step over one ack: hand it what the control
|
|
355
|
+
plane sent, take back the plaintext it opened and the fields the *next* envelope must
|
|
356
|
+
carry. A loop calls it every 30 s; a test calls it twice with a fake clock.
|
|
357
|
+
|
|
358
|
+
Three behaviours fall out of the ack being **authoritative** rather than incremental:
|
|
359
|
+
|
|
360
|
+
* **Pull on boot, on activation, on a version change.** The Runner opens a slot only
|
|
361
|
+
when its version or key id differs from what it holds, so a steady state costs no
|
|
362
|
+
X25519 at all and a re-delivery lands at the next beat.
|
|
363
|
+
* **Termination drops the value.** A slot absent from the ack is forgotten here and
|
|
364
|
+
dropped from the resolver -- wiped, not listed (22 A9; the revocation checklist is
|
|
365
|
+
for org-provisioned credentials, which this is not).
|
|
366
|
+
* **Suspension keeps it.** Suspension does not delete the ciphertext row, so the ack
|
|
367
|
+
still carries the slot and nothing above happens.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
def __init__(self, keys: RecipientKeyStore) -> None:
|
|
371
|
+
self._keys = keys
|
|
372
|
+
self._plaintext: dict[tuple[str, str], str] = {}
|
|
373
|
+
self._held: dict[tuple[str, str], tuple[int, str]] = {}
|
|
374
|
+
self._awaiting: set[tuple[str, str]] = set()
|
|
375
|
+
self._opened: list[OpenedCredential] = []
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def plaintext(self) -> dict[tuple[str, str], str]:
|
|
379
|
+
"""``{(contract_id, slot): value}`` -- process memory only, never disk (22 A5)."""
|
|
380
|
+
|
|
381
|
+
return dict(self._plaintext)
|
|
382
|
+
|
|
383
|
+
def apply(self, credentials: Sequence[SealedCredential]) -> list[SealedCredential]:
|
|
384
|
+
"""Open what is new, forget what is gone; return what failed to open.
|
|
385
|
+
|
|
386
|
+
A failure is not an exception: one slot sealed to a Recipient Key this
|
|
387
|
+
installation no longer has (a half-finished renewal on the other side, or a
|
|
388
|
+
reinstall) must not stop the other slots from opening. The caller records
|
|
389
|
+
Evidence naming each refused slot -- ids only, never a fingerprint of the value.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
refused: list[SealedCredential] = []
|
|
393
|
+
seen: set[tuple[str, str]] = set()
|
|
394
|
+
current = self._keys.current().key_id
|
|
395
|
+
# The confirmation a renewal waits for is read off the ack itself, never carried
|
|
396
|
+
# in memory: a Runner that crashed after `rotate()` persisted but before the push
|
|
397
|
+
# landed comes back with nothing awaited, and an in-memory set would let its first
|
|
398
|
+
# ack -- still all under the old key -- retire the only key that opens it.
|
|
399
|
+
self._awaiting = {
|
|
400
|
+
(str(sealed.contract_id), sealed.slot)
|
|
401
|
+
for sealed in credentials
|
|
402
|
+
if sealed.recipient_key_id != current
|
|
403
|
+
}
|
|
404
|
+
for sealed in credentials:
|
|
405
|
+
key = (str(sealed.contract_id), sealed.slot)
|
|
406
|
+
seen.add(key)
|
|
407
|
+
if self._held.get(key) == (sealed.version, sealed.recipient_key_id):
|
|
408
|
+
continue
|
|
409
|
+
opened = self._open(sealed)
|
|
410
|
+
if opened is None:
|
|
411
|
+
refused.append(sealed)
|
|
412
|
+
continue
|
|
413
|
+
self._plaintext[key] = opened
|
|
414
|
+
self._held[key] = (sealed.version, sealed.recipient_key_id)
|
|
415
|
+
self._opened.append(
|
|
416
|
+
OpenedCredential(
|
|
417
|
+
contract_id=sealed.contract_id,
|
|
418
|
+
slot=sealed.slot,
|
|
419
|
+
recipient_key_id=sealed.recipient_key_id,
|
|
420
|
+
version=sealed.version,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
for gone in set(self._held) - seen:
|
|
424
|
+
self._held.pop(gone, None)
|
|
425
|
+
self._plaintext.pop(gone, None)
|
|
426
|
+
self._retire_if_confirmed()
|
|
427
|
+
return refused
|
|
428
|
+
|
|
429
|
+
def take_opened(self) -> list[OpenedCredential]:
|
|
430
|
+
"""What the next envelope reports as opened, drained (22 A10's Evidence).
|
|
431
|
+
|
|
432
|
+
Drained, not read: "opened by Runner" is one event per open, and a beat that
|
|
433
|
+
reported it must not report it again thirty seconds later. A heartbeat that fails
|
|
434
|
+
loses the report rather than the value -- this is Evidence, not state, and the
|
|
435
|
+
plaintext it describes is already held.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
opened, self._opened = self._opened, []
|
|
439
|
+
return opened
|
|
440
|
+
|
|
441
|
+
def renew(self, now: datetime) -> tuple[RecipientKey | None, list[SealedCredential]]:
|
|
442
|
+
"""22 A7, the Runner-driven renewal: a new key and every slot re-sealed to it.
|
|
443
|
+
|
|
444
|
+
Nothing in flight is affected -- the provider values themselves do not change, so
|
|
445
|
+
a running Directive keeps spending the same key while this happens, and a funder
|
|
446
|
+
sees only the fingerprint change. Returns what the next heartbeat envelope carries;
|
|
447
|
+
the old private half is destroyed in :meth:`apply`, once the ack shows every slot
|
|
448
|
+
stored under the new id.
|
|
449
|
+
|
|
450
|
+
While a previous key is still held, every call re-announces the current key and
|
|
451
|
+
re-seals whatever the last ack still showed under the old one: the push that
|
|
452
|
+
registers it may never have landed, and nothing else would ever send it again.
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
if self._keys.due(now):
|
|
456
|
+
fresh = self._keys.rotate(now)
|
|
457
|
+
pending = set(self._plaintext)
|
|
458
|
+
elif self._keys.previous() is not None:
|
|
459
|
+
fresh = self._keys.current()
|
|
460
|
+
pending = self._awaiting & set(self._plaintext)
|
|
461
|
+
else:
|
|
462
|
+
return None, []
|
|
463
|
+
resealed = [
|
|
464
|
+
SealedCredential(
|
|
465
|
+
contract_id=UUID(contract_id),
|
|
466
|
+
slot=slot,
|
|
467
|
+
recipient_key_id=fresh.key_id,
|
|
468
|
+
version=self._held[(contract_id, slot)][0] + 1,
|
|
469
|
+
ciphertext=seal(
|
|
470
|
+
public_key=fresh.public_key,
|
|
471
|
+
binding=delivery_binding(
|
|
472
|
+
contract_id=contract_id, slot=slot, recipient_key_id=fresh.key_id
|
|
473
|
+
),
|
|
474
|
+
plaintext=value,
|
|
475
|
+
),
|
|
476
|
+
)
|
|
477
|
+
for (contract_id, slot), value in sorted(self._plaintext.items())
|
|
478
|
+
if (contract_id, slot) in pending
|
|
479
|
+
]
|
|
480
|
+
# `_held` is deliberately left at what was *opened*, not what was pushed (PRD
|
|
481
|
+
# issue 70): a funder delivery landing between this seal and the push can take
|
|
482
|
+
# the very (version, key) this re-seal names, and marking it held here would skip
|
|
483
|
+
# opening the funder's value. The echo of a re-seal that did land costs one open.
|
|
484
|
+
return RecipientKey(key_id=fresh.key_id, public_key=fresh.public_key), resealed
|
|
485
|
+
|
|
486
|
+
def _retire_if_confirmed(self) -> None:
|
|
487
|
+
"""Destroy the old private key only once every slot reports the new key id.
|
|
488
|
+
|
|
489
|
+
Order matters and is the whole safety property: retiring first would strand any
|
|
490
|
+
slot whose re-seal the control plane never stored, with no key left to open the
|
|
491
|
+
value it still holds.
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
if not self._awaiting:
|
|
495
|
+
self._keys.retire_previous()
|
|
496
|
+
|
|
497
|
+
def _open(self, sealed: SealedCredential) -> str | None:
|
|
498
|
+
for pair in (self._keys.current(), self._keys.previous()):
|
|
499
|
+
if pair is None or pair.key_id != sealed.recipient_key_id:
|
|
500
|
+
continue
|
|
501
|
+
try:
|
|
502
|
+
return open_sealed(
|
|
503
|
+
private_key=pair.private_key,
|
|
504
|
+
binding=sealed.binding(),
|
|
505
|
+
ciphertext=sealed.ciphertext,
|
|
506
|
+
)
|
|
507
|
+
except SealedCredentialError:
|
|
508
|
+
return None
|
|
509
|
+
return None
|