velophex 0.2.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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
velophex/signing.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Detached package signing: ``velophex/signature.json`` (ADR-P003).
|
|
2
|
+
|
|
3
|
+
This is the Python twin of ``Velophex.Workflow.Security.Signing.DetachedPackageSigner``, and it
|
|
4
|
+
exists so that a package the CLI publishes can be trusted by the same pinned-certificate check
|
|
5
|
+
the Orchestrator and the Runner already apply to a Studio package. What it writes is read by
|
|
6
|
+
``VeloPhex.Platform.Contracts.Packages.PackageSignatureVerifier``, which fails closed, so every
|
|
7
|
+
detail here is a compatibility requirement rather than a choice:
|
|
8
|
+
|
|
9
|
+
* The signed bytes are :func:`create_payload`, not the bare hash. Signing the package's identity
|
|
10
|
+
alongside the hash is what stops a valid signature being lifted onto another package.
|
|
11
|
+
* The identity in the document comes from the manifest, never from a CLI argument: the verifier
|
|
12
|
+
refuses a document whose ``packageId``/``packageVersion`` differ from the manifest's, so an
|
|
13
|
+
argument could only ever produce a package that is refused on upload.
|
|
14
|
+
* ECDSA signatures are IEEE P1363 (fixed-width ``r || s``), because that is what .NET's
|
|
15
|
+
``ECDsa.VerifyData`` reads; :mod:`cryptography` produces DER and has to be converted.
|
|
16
|
+
|
|
17
|
+
``cryptography`` is imported lazily and is an optional dependency (``velophex[signing]``). Only a
|
|
18
|
+
publisher signs; a robot only ever verifies, and ADR-P008 keeps its environment minimal.
|
|
19
|
+
|
|
20
|
+
The private key never leaves :class:`SigningKey`. Nothing in this module formats it, puts it in a
|
|
21
|
+
message or lets a third-party exception carrying it escape.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import base64
|
|
27
|
+
import hashlib
|
|
28
|
+
import zipfile
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
from io import BytesIO
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from types import SimpleNamespace
|
|
34
|
+
from typing import Any, Final
|
|
35
|
+
|
|
36
|
+
from velophex import _json
|
|
37
|
+
from velophex.contracts.package import PackageLayout
|
|
38
|
+
from velophex.errors import VelophexError
|
|
39
|
+
from velophex.packaging import ENTRY_TIMESTAMP
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"CURRENT_FORMAT_VERSION",
|
|
43
|
+
"ECDSA_SHA512",
|
|
44
|
+
"FORMAT_NAME",
|
|
45
|
+
"MINIMUM_FORMAT_VERSION",
|
|
46
|
+
"RSA_PSS_SHA512",
|
|
47
|
+
"SIGNED_AT_FORMAT",
|
|
48
|
+
"SIGNING_EXTRA_HINT",
|
|
49
|
+
"SigningError",
|
|
50
|
+
"SigningKey",
|
|
51
|
+
"create_payload",
|
|
52
|
+
"create_signature_document",
|
|
53
|
+
"format_signed_at",
|
|
54
|
+
"load_signing_key",
|
|
55
|
+
"sign_package_bytes",
|
|
56
|
+
"sign_package_file",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
FORMAT_NAME: Final = "velophex.package-signature"
|
|
60
|
+
|
|
61
|
+
#: Format 2 signs the signing time (ADR-P012): the verifier checks the certificate as of
|
|
62
|
+
#: ``signedAt`` instead of now, so the signature outlives the certificate. Format 1 is still
|
|
63
|
+
#: verified everywhere but dies with its certificate; nothing writes it any more.
|
|
64
|
+
CURRENT_FORMAT_VERSION: Final = 2
|
|
65
|
+
MINIMUM_FORMAT_VERSION: Final = 1
|
|
66
|
+
|
|
67
|
+
#: The one spelling of ``signedAt``: UTC to the second, ``Z`` suffix, no fraction, no offset.
|
|
68
|
+
#: Byte for byte ``PackageSignature.SignedAtFormat``; the payload carries it verbatim.
|
|
69
|
+
SIGNED_AT_FORMAT: Final = "%Y-%m-%dT%H:%M:%SZ"
|
|
70
|
+
|
|
71
|
+
#: RSA with PSS padding over SHA-512; the salt is the hash length, as .NET's ``RSASignaturePadding.Pss``.
|
|
72
|
+
RSA_PSS_SHA512: Final = "RSASSA-PSS-SHA512"
|
|
73
|
+
|
|
74
|
+
#: ECDSA over SHA-512, signature in IEEE P1363 form.
|
|
75
|
+
ECDSA_SHA512: Final = "ECDSA-SHA512"
|
|
76
|
+
|
|
77
|
+
#: What a signature document larger than this is: not one of ours. Mirrors the verifier's limit.
|
|
78
|
+
MAXIMUM_DOCUMENT_BYTES: Final = 64 * 1024
|
|
79
|
+
|
|
80
|
+
#: The verifier refuses a larger certificate unread, so refuse it here rather than on upload.
|
|
81
|
+
MAXIMUM_CERTIFICATE_BYTES: Final = 16 * 1024
|
|
82
|
+
|
|
83
|
+
SIGNING_EXTRA_HINT: Final = (
|
|
84
|
+
"Signing needs the 'cryptography' package, which the plain SDK install deliberately leaves out. "
|
|
85
|
+
'Install it with: uv add "velophex[signing]" (or: uv pip install "cryptography>=43,<47")'
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SigningError(VelophexError):
|
|
90
|
+
"""Signing could not be completed.
|
|
91
|
+
|
|
92
|
+
Carries only what the operator has to act on. A password or a private key is never part of
|
|
93
|
+
the message, and a third-party exception that might quote one is never chained to it.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class SigningKey:
|
|
99
|
+
"""A signer's certificate and the private key that goes with it.
|
|
100
|
+
|
|
101
|
+
Everything a caller needs for display or for a policy check is copied out at load time, so
|
|
102
|
+
the key object itself is only ever touched by :func:`_sign` and never reaches a ``repr``.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
#: The certificate's DER bytes: what the document carries, Base64, and what is thumbprinted.
|
|
106
|
+
certificate_der: bytes
|
|
107
|
+
|
|
108
|
+
#: RFC 4514 subject, e.g. ``CN=VeloPhex Publisher``, for the line the CLI prints.
|
|
109
|
+
subject: str
|
|
110
|
+
|
|
111
|
+
not_before: datetime
|
|
112
|
+
|
|
113
|
+
not_after: datetime
|
|
114
|
+
|
|
115
|
+
#: :data:`RSA_PSS_SHA512` or :data:`ECDSA_SHA512`, decided by the key's type.
|
|
116
|
+
algorithm: str
|
|
117
|
+
|
|
118
|
+
private_key: Any = field(repr=False)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def thumbprint(self) -> str:
|
|
122
|
+
"""Upper-case hex SHA-256 of the DER bytes: the value a verifier pins."""
|
|
123
|
+
return hashlib.sha256(self.certificate_der).hexdigest().upper()
|
|
124
|
+
|
|
125
|
+
def is_valid_at(self, moment: datetime) -> bool:
|
|
126
|
+
"""Whether *moment* falls inside the certificate's validity period.
|
|
127
|
+
|
|
128
|
+
The verifier rejects a signature from a certificate outside it, so a publisher is better
|
|
129
|
+
told before the package is uploaded than after.
|
|
130
|
+
"""
|
|
131
|
+
return self.not_before <= moment <= self.not_after
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def format_signed_at(moment: datetime) -> str:
|
|
135
|
+
"""*moment* in the one canonical spelling of ``signedAt``, UTC and truncated to the second.
|
|
136
|
+
|
|
137
|
+
Byte for byte ``PackageSignature.FormatSignedAt``. A naive datetime is taken as UTC.
|
|
138
|
+
"""
|
|
139
|
+
return _utc(moment).replace(microsecond=0).strftime(SIGNED_AT_FORMAT)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def create_payload(
|
|
143
|
+
package_id: str,
|
|
144
|
+
package_version: str,
|
|
145
|
+
manifest_sha512: str,
|
|
146
|
+
signed_at: datetime | None = None,
|
|
147
|
+
) -> bytes:
|
|
148
|
+
"""Build the exact bytes a signer signs and a verifier checks.
|
|
149
|
+
|
|
150
|
+
Byte for byte ``PackageSignature.CreatePayload``: a domain-separation prefix line, then the
|
|
151
|
+
id, the version and the manifest hash, one per line, lower-cased, LF separated, trailing LF.
|
|
152
|
+
The version is lower-cased and nothing more - versions are SemVer 2.0 without build metadata
|
|
153
|
+
(ADR-P011), so the text is already normalized and normalizing again here would sign bytes no
|
|
154
|
+
.NET verifier would reconstruct.
|
|
155
|
+
|
|
156
|
+
With *signed_at* the payload is **format 2**: the prefix says ``/2`` and a fifth line carries
|
|
157
|
+
the signing time as :func:`format_signed_at` writes it, verbatim (not lower-cased). Without it
|
|
158
|
+
the payload is format 1, which no signer writes any more and every verifier still reads.
|
|
159
|
+
"""
|
|
160
|
+
for name, value in (
|
|
161
|
+
("package id", package_id),
|
|
162
|
+
("package version", package_version),
|
|
163
|
+
("manifest hash", manifest_sha512),
|
|
164
|
+
):
|
|
165
|
+
if not value or not value.strip():
|
|
166
|
+
raise SigningError(f"The {name} is required to sign a package.")
|
|
167
|
+
|
|
168
|
+
format_version = MINIMUM_FORMAT_VERSION if signed_at is None else CURRENT_FORMAT_VERSION
|
|
169
|
+
fifth_line = "" if signed_at is None else f"{format_signed_at(signed_at)}\n"
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
f"{FORMAT_NAME}/{format_version}\n"
|
|
173
|
+
f"{package_id.lower()}\n"
|
|
174
|
+
f"{package_version.lower()}\n"
|
|
175
|
+
f"{manifest_sha512.lower()}\n"
|
|
176
|
+
f"{fifth_line}"
|
|
177
|
+
).encode()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_signing_key(pkcs12_path: str | Path, password: bytes | None) -> SigningKey:
|
|
181
|
+
"""Load a signer from a PKCS#12 (``.pfx``/``.p12``) file.
|
|
182
|
+
|
|
183
|
+
*password* is ``None`` for an unprotected file. It is read by the caller from an environment
|
|
184
|
+
variable or from stdin - never from a command-line argument, which every process listing on
|
|
185
|
+
the machine can see.
|
|
186
|
+
"""
|
|
187
|
+
crypto = _cryptography()
|
|
188
|
+
path = Path(pkcs12_path)
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
payload = path.read_bytes()
|
|
192
|
+
except OSError as error:
|
|
193
|
+
raise SigningError(f"The signing key file '{path}' could not be read: {error.strerror}.") from None
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
private_key, certificate, _ = crypto.pkcs12.load_key_and_certificates(payload, password)
|
|
197
|
+
except Exception:
|
|
198
|
+
# Never chain: the underlying exception is the only thing in this module that has ever
|
|
199
|
+
# been anywhere near the password, and a traceback in CI output is a disclosure.
|
|
200
|
+
raise SigningError(
|
|
201
|
+
f"'{path}' could not be opened as a PKCS#12 signing key. Check the file and the password."
|
|
202
|
+
) from None
|
|
203
|
+
|
|
204
|
+
if certificate is None:
|
|
205
|
+
raise SigningError(f"'{path}' carries no certificate, so there is nothing to sign with.")
|
|
206
|
+
|
|
207
|
+
if isinstance(private_key, crypto.rsa.RSAPrivateKey):
|
|
208
|
+
algorithm = RSA_PSS_SHA512
|
|
209
|
+
elif isinstance(private_key, crypto.ec.EllipticCurvePrivateKey):
|
|
210
|
+
algorithm = ECDSA_SHA512
|
|
211
|
+
else:
|
|
212
|
+
raise SigningError(
|
|
213
|
+
f"'{path}' has no RSA or ECDSA private key; the platform signs with "
|
|
214
|
+
f"{RSA_PSS_SHA512} or {ECDSA_SHA512} only."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
certificate_der = certificate.public_bytes(crypto.Encoding.DER)
|
|
218
|
+
|
|
219
|
+
if len(certificate_der) > MAXIMUM_CERTIFICATE_BYTES:
|
|
220
|
+
raise SigningError(
|
|
221
|
+
f"The signer certificate is {len(certificate_der)} bytes; verifiers refuse anything "
|
|
222
|
+
f"over {MAXIMUM_CERTIFICATE_BYTES}."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
_require_may_sign(crypto, certificate, path)
|
|
226
|
+
|
|
227
|
+
return SigningKey(
|
|
228
|
+
certificate_der=certificate_der,
|
|
229
|
+
subject=certificate.subject.rfc4514_string(),
|
|
230
|
+
not_before=certificate.not_valid_before_utc,
|
|
231
|
+
not_after=certificate.not_valid_after_utc,
|
|
232
|
+
algorithm=algorithm,
|
|
233
|
+
private_key=private_key,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def create_signature_document(
|
|
238
|
+
manifest_bytes: bytes,
|
|
239
|
+
key: SigningKey,
|
|
240
|
+
signed_at: datetime | None = None,
|
|
241
|
+
) -> bytes:
|
|
242
|
+
"""Sign *manifest_bytes* and return the format-2 ``velophex/signature.json`` document.
|
|
243
|
+
|
|
244
|
+
*manifest_bytes* must be ``velophex/package-manifest.json`` exactly as stored in the archive:
|
|
245
|
+
the verifier hashes what it reads out of the package, so anything re-serialized here would
|
|
246
|
+
hash to a different value. *signed_at* defaults to now; the document's ``signedAt`` member is
|
|
247
|
+
the same text the payload carries, so a verifier reconstructs the signed bytes exactly.
|
|
248
|
+
"""
|
|
249
|
+
package_id, package_version = _read_identity(manifest_bytes)
|
|
250
|
+
manifest_sha512 = hashlib.sha512(manifest_bytes).hexdigest()
|
|
251
|
+
moment = datetime.now(UTC) if signed_at is None else _utc(signed_at)
|
|
252
|
+
|
|
253
|
+
if not key.is_valid_at(moment):
|
|
254
|
+
raise SigningError(
|
|
255
|
+
f"The signer certificate '{key.subject}' is not valid at {format_signed_at(moment)}; "
|
|
256
|
+
"a verifier would refuse the signature."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
signature = _sign(create_payload(package_id, package_version, manifest_sha512, moment), key)
|
|
260
|
+
|
|
261
|
+
document = {
|
|
262
|
+
"format": FORMAT_NAME,
|
|
263
|
+
"formatVersion": CURRENT_FORMAT_VERSION,
|
|
264
|
+
"packageId": package_id,
|
|
265
|
+
"packageVersion": package_version,
|
|
266
|
+
"manifestSha512": manifest_sha512,
|
|
267
|
+
"algorithm": key.algorithm,
|
|
268
|
+
# The public certificate only. A PKCS#12 holds the private key too; it stops here.
|
|
269
|
+
"certificate": base64.b64encode(key.certificate_der).decode("ascii"),
|
|
270
|
+
"signedAt": format_signed_at(moment),
|
|
271
|
+
"signature": base64.b64encode(signature).decode("ascii"),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# Member order, indent and escaping of System.Text.Json with WriteIndented, in the order
|
|
275
|
+
# PackageSignature declares its members; the reserved timestampToken is not written.
|
|
276
|
+
return _json.dumps_document(document).encode("utf-8")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def sign_package_bytes(package: bytes, key: SigningKey, signed_at: datetime | None = None) -> bytes:
|
|
280
|
+
"""Return *package* with ``velophex/signature.json`` added.
|
|
281
|
+
|
|
282
|
+
The signature is appended as a new archive entry, which leaves every existing entry - the
|
|
283
|
+
manifest above all - byte for byte where it was. A signature that changed the bytes it
|
|
284
|
+
covers could never verify.
|
|
285
|
+
"""
|
|
286
|
+
with zipfile.ZipFile(BytesIO(package)) as archive:
|
|
287
|
+
names = set(archive.namelist())
|
|
288
|
+
|
|
289
|
+
if PackageLayout.SIGNATURE_PATH in names:
|
|
290
|
+
raise SigningError("The package is already signed; build it again before signing.")
|
|
291
|
+
|
|
292
|
+
if PackageLayout.MANIFEST_PATH not in names:
|
|
293
|
+
raise SigningError(f"The package has no '{PackageLayout.MANIFEST_PATH}' to sign.")
|
|
294
|
+
|
|
295
|
+
manifest_bytes = archive.read(PackageLayout.MANIFEST_PATH)
|
|
296
|
+
|
|
297
|
+
document = create_signature_document(manifest_bytes, key, signed_at)
|
|
298
|
+
|
|
299
|
+
if len(document) > MAXIMUM_DOCUMENT_BYTES:
|
|
300
|
+
raise SigningError(f"The signature document is larger than {MAXIMUM_DOCUMENT_BYTES} bytes.")
|
|
301
|
+
|
|
302
|
+
buffer = BytesIO(package)
|
|
303
|
+
|
|
304
|
+
with zipfile.ZipFile(buffer, "a", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
|
305
|
+
info = zipfile.ZipInfo(PackageLayout.SIGNATURE_PATH, date_time=ENTRY_TIMESTAMP)
|
|
306
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
307
|
+
info.external_attr = 0o600 << 16
|
|
308
|
+
archive.writestr(info, document)
|
|
309
|
+
|
|
310
|
+
return buffer.getvalue()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def sign_package_file(package_path: str | Path, key: SigningKey) -> Path:
|
|
314
|
+
"""Sign the ``.nupkg`` at *package_path* in place and return its path."""
|
|
315
|
+
path = Path(package_path)
|
|
316
|
+
path.write_bytes(sign_package_bytes(path.read_bytes(), key))
|
|
317
|
+
return path
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# --------------------------------------------------------------------------- internals
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _utc(moment: datetime) -> datetime:
|
|
324
|
+
"""*moment* as an aware UTC datetime; a naive one is taken as UTC already."""
|
|
325
|
+
return moment.replace(tzinfo=UTC) if moment.tzinfo is None else moment.astimezone(UTC)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _read_identity(manifest_bytes: bytes) -> tuple[str, str]:
|
|
329
|
+
"""Read the package id and version the manifest itself declares.
|
|
330
|
+
|
|
331
|
+
Mirrors ``PackageManifestIdentity.TryRead``: only the identity is needed, and taking it from
|
|
332
|
+
the manifest is what makes the verifier's equality check pass.
|
|
333
|
+
"""
|
|
334
|
+
try:
|
|
335
|
+
document = _json.loads(manifest_bytes)
|
|
336
|
+
except ValueError as error:
|
|
337
|
+
raise SigningError(f"The package manifest is not valid JSON: {error}") from None
|
|
338
|
+
|
|
339
|
+
if not isinstance(document, dict):
|
|
340
|
+
raise SigningError("The package manifest is not a JSON object.")
|
|
341
|
+
|
|
342
|
+
package_id = document.get("packageId")
|
|
343
|
+
package_version = document.get("packageVersion")
|
|
344
|
+
|
|
345
|
+
if not isinstance(package_id, str) or not isinstance(package_version, str):
|
|
346
|
+
raise SigningError("The package manifest has no 'packageId' and 'packageVersion' to sign.")
|
|
347
|
+
|
|
348
|
+
return package_id, package_version
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _require_may_sign(crypto: SimpleNamespace, certificate: Any, path: Path) -> None:
|
|
352
|
+
"""Refuse a certificate whose key usage forbids signing, as the verifier would.
|
|
353
|
+
|
|
354
|
+
A certificate with no key-usage extension is allowed: the verifier only enforces the
|
|
355
|
+
extension when it is there, and refusing more here would reject signers it accepts.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
usage = certificate.extensions.get_extension_for_class(crypto.x509.KeyUsage).value
|
|
359
|
+
except crypto.x509.ExtensionNotFound:
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
if not usage.digital_signature:
|
|
363
|
+
raise SigningError(
|
|
364
|
+
f"The certificate in '{path}' has a key usage that forbids digital signatures, "
|
|
365
|
+
"so every verifier would refuse its signature."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _sign(payload: bytes, key: SigningKey) -> bytes:
|
|
370
|
+
crypto = _cryptography()
|
|
371
|
+
|
|
372
|
+
if key.algorithm == RSA_PSS_SHA512:
|
|
373
|
+
return key.private_key.sign(
|
|
374
|
+
payload,
|
|
375
|
+
crypto.padding.PSS(
|
|
376
|
+
mgf=crypto.padding.MGF1(crypto.hashes.SHA512()),
|
|
377
|
+
# .NET's RSASignaturePadding.Pss salts with the hash length; a verifier that
|
|
378
|
+
# expects 64 bytes of salt will not accept any other length.
|
|
379
|
+
salt_length=crypto.hashes.SHA512.digest_size,
|
|
380
|
+
),
|
|
381
|
+
crypto.hashes.SHA512(),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
der = key.private_key.sign(payload, crypto.ec.ECDSA(crypto.hashes.SHA512()))
|
|
385
|
+
|
|
386
|
+
return _to_p1363(der, key.private_key.curve.key_size, crypto)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _to_p1363(der_signature: bytes, key_size_bits: int, crypto: SimpleNamespace) -> bytes:
|
|
390
|
+
"""Convert a DER ``SEQUENCE { r, s }`` to the fixed-width ``r || s`` .NET reads.
|
|
391
|
+
|
|
392
|
+
``ECDsa.VerifyData`` defaults to IEEE P1363, so a DER signature - which is what
|
|
393
|
+
:mod:`cryptography` returns - is simply not a signature as far as the verifier is concerned.
|
|
394
|
+
"""
|
|
395
|
+
r, s = crypto.decode_dss_signature(der_signature)
|
|
396
|
+
width = (key_size_bits + 7) // 8
|
|
397
|
+
|
|
398
|
+
return r.to_bytes(width, "big") + s.to_bytes(width, "big")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _cryptography() -> SimpleNamespace:
|
|
402
|
+
"""Import the pieces of ``cryptography`` signing needs, or say how to install it.
|
|
403
|
+
|
|
404
|
+
One import site, so the optional dependency turns into one actionable sentence instead of a
|
|
405
|
+
traceback from wherever it happened to be first touched.
|
|
406
|
+
"""
|
|
407
|
+
try:
|
|
408
|
+
from cryptography import x509
|
|
409
|
+
from cryptography.hazmat.primitives import hashes
|
|
410
|
+
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
|
|
411
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
|
412
|
+
from cryptography.hazmat.primitives.serialization import Encoding, pkcs12
|
|
413
|
+
except ImportError:
|
|
414
|
+
raise SigningError(SIGNING_EXTRA_HINT) from None
|
|
415
|
+
|
|
416
|
+
return SimpleNamespace(
|
|
417
|
+
Encoding=Encoding,
|
|
418
|
+
decode_dss_signature=decode_dss_signature,
|
|
419
|
+
ec=ec,
|
|
420
|
+
hashes=hashes,
|
|
421
|
+
padding=padding,
|
|
422
|
+
pkcs12=pkcs12,
|
|
423
|
+
rsa=rsa,
|
|
424
|
+
x509=x509,
|
|
425
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The VeloPhex Python executor: ``python -m velophex.worker``.
|
|
2
|
+
|
|
3
|
+
The Runner (or Studio, or ``velophex run``) starts one worker process per automation run and
|
|
4
|
+
talks to it over the process's stdin and stdout with the executor protocol (ADR-P005). stdout
|
|
5
|
+
belongs to the protocol; everything a handler prints goes to stderr, which the parent captures
|
|
6
|
+
as free-text diagnostics.
|
|
7
|
+
|
|
8
|
+
Modules:
|
|
9
|
+
|
|
10
|
+
* :mod:`velophex.worker.framing` - length-prefixed, HMAC-authenticated frames
|
|
11
|
+
* :mod:`velophex.worker.outbox` - outgoing messages and the ``logging`` bridge
|
|
12
|
+
* :mod:`velophex.worker.loader` - ``project.json`` to an imported handler
|
|
13
|
+
* :mod:`velophex.worker.session` - the conversation itself
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from velophex.worker.framing import FrameReader, FrameWriter, take_session_key_from_environment
|
|
17
|
+
from velophex.worker.outbox import ExecutorOutbox, WorkerLogHandler
|
|
18
|
+
from velophex.worker.session import WorkerSession
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ExecutorOutbox",
|
|
22
|
+
"FrameReader",
|
|
23
|
+
"FrameWriter",
|
|
24
|
+
"WorkerLogHandler",
|
|
25
|
+
"WorkerSession",
|
|
26
|
+
"take_session_key_from_environment",
|
|
27
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""``python -m velophex.worker`` - the process the Runner launches for a Python automation.
|
|
2
|
+
|
|
3
|
+
Exit codes match the workflow executor: ``0`` the session ended, ``2`` a protocol violation,
|
|
4
|
+
``3`` no session key. Anything else is a crash, which the parent reports as
|
|
5
|
+
``Application / EXECUTOR_CRASHED``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import sys
|
|
13
|
+
from typing import BinaryIO
|
|
14
|
+
|
|
15
|
+
from velophex import __version__
|
|
16
|
+
from velophex.contracts.executor import SESSION_KEY_ENVIRONMENT_VARIABLE, ExecutorFrameDirection
|
|
17
|
+
from velophex.worker.framing import FrameReader, FrameWriter, take_session_key_from_environment
|
|
18
|
+
from velophex.worker.outbox import ExecutorOutbox
|
|
19
|
+
from velophex.worker.session import EXIT_PROTOCOL_VIOLATION, WorkerSession
|
|
20
|
+
|
|
21
|
+
EXIT_NO_SESSION_KEY = 3
|
|
22
|
+
_STD_INPUT_HANDLE = -10
|
|
23
|
+
_STD_OUTPUT_HANDLE = -11
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _set_std_handle(which: int, fd: int) -> None:
|
|
27
|
+
"""Point a Win32 standard handle at *fd*: ``dup2`` changes the CRT descriptor only."""
|
|
28
|
+
if sys.platform == "win32":
|
|
29
|
+
import ctypes
|
|
30
|
+
import msvcrt
|
|
31
|
+
|
|
32
|
+
ctypes.windll.kernel32.SetStdHandle(which, msvcrt.get_osfhandle(fd))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _take_command_stream() -> BinaryIO:
|
|
36
|
+
"""Move the parent's frame pipe off standard input and give standard input the null device.
|
|
37
|
+
|
|
38
|
+
The same reasoning as :func:`_take_frame_stream`, in the other direction: a child process
|
|
39
|
+
inherits fd 0 / ``STD_INPUT_HANDLE``, and one that reads it (``input()`` in a subprocess, a
|
|
40
|
+
CLI prompting for a password) would consume the parent's frames - a stop request among
|
|
41
|
+
them. So the pipe moves to a private, non-inheritable descriptor, and anything the
|
|
42
|
+
automation or its children read from standard input gets end-of-file.
|
|
43
|
+
"""
|
|
44
|
+
command_fd = os.dup(0) # PEP 446: non-inheritable
|
|
45
|
+
null = os.open(os.devnull, os.O_RDONLY)
|
|
46
|
+
os.dup2(null, 0)
|
|
47
|
+
os.close(null)
|
|
48
|
+
_set_std_handle(_STD_INPUT_HANDLE, 0)
|
|
49
|
+
sys.stdin = open(os.devnull, encoding="utf-8") # noqa: SIM115 - lives as long as the process
|
|
50
|
+
|
|
51
|
+
# Unbuffered: the listener thread stays blocked in a read after the session ends, and a
|
|
52
|
+
# BufferedReader's lock held by a daemon thread is a fatal error at interpreter shutdown.
|
|
53
|
+
return open(command_fd, "rb", buffering=0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _take_frame_stream() -> BinaryIO:
|
|
57
|
+
"""Move the frame pipe off standard output and point standard output at stderr.
|
|
58
|
+
|
|
59
|
+
Rebinding ``sys.stdout`` alone is not enough: a child process (``os.system``, ``subprocess``
|
|
60
|
+
without ``stdout=``), a C extension or a direct ``WriteFile`` on the standard handle writes
|
|
61
|
+
to fd 1 / ``STD_OUTPUT_HANDLE``, which would still be the frame pipe and corrupt the stream.
|
|
62
|
+
So the pipe moves to a private, non-inheritable descriptor, and fd 1 - plus the Win32
|
|
63
|
+
standard handle, which ``dup2`` does not touch - become stderr. A grandchild that outlives
|
|
64
|
+
the worker therefore cannot hold the pipe open either: the parent sees end-of-file when
|
|
65
|
+
the worker exits.
|
|
66
|
+
"""
|
|
67
|
+
frame_fd = os.dup(1) # PEP 446: non-inheritable
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
os.dup2(2, 1)
|
|
71
|
+
except OSError:
|
|
72
|
+
# No usable stderr (pythonw launched without one): standard output goes nowhere.
|
|
73
|
+
null = os.open(os.devnull, os.O_WRONLY)
|
|
74
|
+
os.dup2(null, 1)
|
|
75
|
+
os.close(null)
|
|
76
|
+
|
|
77
|
+
_set_std_handle(_STD_OUTPUT_HANDLE, 1)
|
|
78
|
+
|
|
79
|
+
# Anything automation code prints goes to stderr, so a stray print() is diagnostics.
|
|
80
|
+
sys.stdout = sys.stderr
|
|
81
|
+
return open(frame_fd, "wb", buffering=0)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> int:
|
|
85
|
+
"""Serve one executor session on stdin and stdout."""
|
|
86
|
+
key = take_session_key_from_environment()
|
|
87
|
+
|
|
88
|
+
if key is None:
|
|
89
|
+
print(
|
|
90
|
+
f"{SESSION_KEY_ENVIRONMENT_VARIABLE} is missing or invalid; "
|
|
91
|
+
"the worker must be started by a VeloPhex parent.",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
return EXIT_NO_SESSION_KEY
|
|
95
|
+
|
|
96
|
+
reader = FrameReader(_take_command_stream(), key, ExecutorFrameDirection.PARENT_TO_EXECUTOR)
|
|
97
|
+
writer = FrameWriter(_take_frame_stream(), key, ExecutorFrameDirection.EXECUTOR_TO_PARENT)
|
|
98
|
+
|
|
99
|
+
session = WorkerSession(
|
|
100
|
+
reader,
|
|
101
|
+
ExecutorOutbox(writer),
|
|
102
|
+
runtime_version=platform.python_version(),
|
|
103
|
+
worker_version=__version__,
|
|
104
|
+
diagnostics=sys.stderr,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
return session.run()
|
|
109
|
+
except KeyboardInterrupt:
|
|
110
|
+
return EXIT_PROTOCOL_VIOLATION
|
|
111
|
+
finally:
|
|
112
|
+
# The key protected this process only; it must not outlive the session in memory
|
|
113
|
+
# any longer than necessary.
|
|
114
|
+
del key
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
raise SystemExit(main())
|