fdai-deployment-cli 0.1.2__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.
- fdai_deployment_cli/__about__.py +3 -0
- fdai_deployment_cli/__init__.py +5 -0
- fdai_deployment_cli/__main__.py +5 -0
- fdai_deployment_cli/acr_publish.py +585 -0
- fdai_deployment_cli/aks_catalog_review.py +137 -0
- fdai_deployment_cli/aks_historical_reconciliation.py +732 -0
- fdai_deployment_cli/aks_job_environment.py +59 -0
- fdai_deployment_cli/aks_job_execution.py +794 -0
- fdai_deployment_cli/aks_kubernetes_defaults.py +66 -0
- fdai_deployment_cli/aks_preflight.py +422 -0
- fdai_deployment_cli/aks_readiness.py +220 -0
- fdai_deployment_cli/aks_service_update.py +180 -0
- fdai_deployment_cli/aks_workload_jobs.py +281 -0
- fdai_deployment_cli/application_state_adoption.py +347 -0
- fdai_deployment_cli/azure_actor.py +61 -0
- fdai_deployment_cli/azure_naming.py +252 -0
- fdai_deployment_cli/azure_retail_prices.py +173 -0
- fdai_deployment_cli/bootstrap_approval.py +99 -0
- fdai_deployment_cli/bootstrap_channel.py +321 -0
- fdai_deployment_cli/bootstrap_executor.py +311 -0
- fdai_deployment_cli/bootstrap_plan.py +237 -0
- fdai_deployment_cli/bootstrap_reconcile.py +402 -0
- fdai_deployment_cli/bootstrap_support.py +138 -0
- fdai_deployment_cli/bootstrap_worker.py +448 -0
- fdai_deployment_cli/bundle.py +352 -0
- fdai_deployment_cli/capacity.py +143 -0
- fdai_deployment_cli/catalog_review_profile.py +306 -0
- fdai_deployment_cli/cli.py +798 -0
- fdai_deployment_cli/cli_help.py +214 -0
- fdai_deployment_cli/cli_parser.py +791 -0
- fdai_deployment_cli/cli_plan.py +450 -0
- fdai_deployment_cli/compiler.py +99 -0
- fdai_deployment_cli/console_artifact.py +218 -0
- fdai_deployment_cli/console_config.py +214 -0
- fdai_deployment_cli/console_update.py +832 -0
- fdai_deployment_cli/contracts.py +307 -0
- fdai_deployment_cli/control_package.py +140 -0
- fdai_deployment_cli/deadline_transport.py +70 -0
- fdai_deployment_cli/deployment_cost.py +131 -0
- fdai_deployment_cli/deployment_deadline.py +28 -0
- fdai_deployment_cli/deployment_kit.py +790 -0
- fdai_deployment_cli/deployment_kit_cache.py +191 -0
- fdai_deployment_cli/deployment_progress.py +648 -0
- fdai_deployment_cli/development_authority_cli.py +56 -0
- fdai_deployment_cli/development_authority_profile.py +319 -0
- fdai_deployment_cli/doctor.py +136 -0
- fdai_deployment_cli/entitlement_preflight.py +68 -0
- fdai_deployment_cli/entitlement_transport.py +49 -0
- fdai_deployment_cli/entra_bootstrap.py +579 -0
- fdai_deployment_cli/entra_control_profile.py +214 -0
- fdai_deployment_cli/entra_graph.py +349 -0
- fdai_deployment_cli/entra_profiles.py +215 -0
- fdai_deployment_cli/entra_source.py +244 -0
- fdai_deployment_cli/execution_bundle.py +478 -0
- fdai_deployment_cli/execution_copies.py +63 -0
- fdai_deployment_cli/foundation_adoption.py +438 -0
- fdai_deployment_cli/foundation_adoption_evidence.py +245 -0
- fdai_deployment_cli/foundation_adoption_host.py +157 -0
- fdai_deployment_cli/foundation_adoption_transport.py +51 -0
- fdai_deployment_cli/foundation_failure.py +72 -0
- fdai_deployment_cli/foundation_image.py +168 -0
- fdai_deployment_cli/foundation_input.py +235 -0
- fdai_deployment_cli/foundation_output.py +154 -0
- fdai_deployment_cli/foundation_plan.py +381 -0
- fdai_deployment_cli/foundation_process.py +64 -0
- fdai_deployment_cli/foundation_progress.py +231 -0
- fdai_deployment_cli/github_actions.py +171 -0
- fdai_deployment_cli/github_apply_artifact.py +259 -0
- fdai_deployment_cli/github_artifact_io.py +48 -0
- fdai_deployment_cli/github_dispatch.py +248 -0
- fdai_deployment_cli/github_plan_artifact.py +181 -0
- fdai_deployment_cli/github_provider_artifact.py +170 -0
- fdai_deployment_cli/github_transport.py +46 -0
- fdai_deployment_cli/github_workflow_values.py +312 -0
- fdai_deployment_cli/hub_image_deploy.py +354 -0
- fdai_deployment_cli/hub_image_stage.py +162 -0
- fdai_deployment_cli/hub_image_trust.py +281 -0
- fdai_deployment_cli/hub_ownership.py +161 -0
- fdai_deployment_cli/hub_workload_handoff.py +264 -0
- fdai_deployment_cli/hub_workload_handoff_inputs.py +227 -0
- fdai_deployment_cli/identity_profile.py +227 -0
- fdai_deployment_cli/image_targets.py +197 -0
- fdai_deployment_cli/installation_scope.py +280 -0
- fdai_deployment_cli/license.py +311 -0
- fdai_deployment_cli/license_issue.py +239 -0
- fdai_deployment_cli/lifecycle_authorization.py +252 -0
- fdai_deployment_cli/lifecycle_commands.py +173 -0
- fdai_deployment_cli/lifecycle_configuration.py +722 -0
- fdai_deployment_cli/lifecycle_configuration_artifact.py +45 -0
- fdai_deployment_cli/lifecycle_configuration_cli.py +324 -0
- fdai_deployment_cli/lifecycle_configuration_document.py +74 -0
- fdai_deployment_cli/lifecycle_configuration_entities.py +146 -0
- fdai_deployment_cli/lifecycle_configuration_package.py +492 -0
- fdai_deployment_cli/lifecycle_configuration_publication.py +277 -0
- fdai_deployment_cli/lifecycle_configuration_publication_cli.py +191 -0
- fdai_deployment_cli/lifecycle_configuration_sealing.py +127 -0
- fdai_deployment_cli/lifecycle_enrollment.py +45 -0
- fdai_deployment_cli/lifecycle_infrastructure_agent.py +174 -0
- fdai_deployment_cli/lifecycle_installation_binding.py +65 -0
- fdai_deployment_cli/lifecycle_native.py +247 -0
- fdai_deployment_cli/lifecycle_native_reconciliation.py +568 -0
- fdai_deployment_cli/lifecycle_native_transition.py +339 -0
- fdai_deployment_cli/lifecycle_ownership_exclusion.py +78 -0
- fdai_deployment_cli/lifecycle_plan.py +800 -0
- fdai_deployment_cli/lifecycle_promotion.py +185 -0
- fdai_deployment_cli/lifecycle_release.py +308 -0
- fdai_deployment_cli/lifecycle_self_upgrade.py +202 -0
- fdai_deployment_cli/lifecycle_upgrade_bundle.py +176 -0
- fdai_deployment_cli/notification_recipients_input.py +222 -0
- fdai_deployment_cli/observer_artifact.py +106 -0
- fdai_deployment_cli/oci_archive.py +353 -0
- fdai_deployment_cli/offline_kit.py +797 -0
- fdai_deployment_cli/offline_prepare.py +135 -0
- fdai_deployment_cli/operational_evidence_verifier_input.py +575 -0
- fdai_deployment_cli/plan_input.py +101 -0
- fdai_deployment_cli/private_output.py +192 -0
- fdai_deployment_cli/profile.py +70 -0
- fdai_deployment_cli/progress.py +183 -0
- fdai_deployment_cli/readiness.py +286 -0
- fdai_deployment_cli/run_command_receiver.py +418 -0
- fdai_deployment_cli/runtime_build.py +386 -0
- fdai_deployment_cli/runtime_profile.py +287 -0
- fdai_deployment_cli/runtime_release.py +745 -0
- fdai_deployment_cli/runtime_stage.py +82 -0
- fdai_deployment_cli/runtime_support_installation.py +215 -0
- fdai_deployment_cli/simulation.py +80 -0
- fdai_deployment_cli/source_application_inputs.py +397 -0
- fdai_deployment_cli/source_azure.py +586 -0
- fdai_deployment_cli/source_change_cli.py +107 -0
- fdai_deployment_cli/source_change_parser.py +75 -0
- fdai_deployment_cli/source_change_plan.py +181 -0
- fdai_deployment_cli/source_changed_update.py +196 -0
- fdai_deployment_cli/source_deploy.py +170 -0
- fdai_deployment_cli/source_foundation.py +174 -0
- fdai_deployment_cli/source_host_tools.py +163 -0
- fdai_deployment_cli/source_image_stage.py +400 -0
- fdai_deployment_cli/source_input.py +173 -0
- fdai_deployment_cli/source_projection.py +164 -0
- fdai_deployment_cli/source_receiver.py +76 -0
- fdai_deployment_cli/source_recovery.py +264 -0
- fdai_deployment_cli/source_runtime.py +81 -0
- fdai_deployment_cli/source_runtime_support.py +368 -0
- fdai_deployment_cli/source_service_update.py +726 -0
- fdai_deployment_cli/source_service_update_cli.py +47 -0
- fdai_deployment_cli/source_snapshot.py +178 -0
- fdai_deployment_cli/source_teardown.py +574 -0
- fdai_deployment_cli/source_terraform.py +85 -0
- fdai_deployment_cli/source_transport.py +399 -0
- fdai_deployment_cli/standalone_aks_inventory.py +558 -0
- fdai_deployment_cli/standalone_aks_job_execution.py +769 -0
- fdai_deployment_cli/standalone_aks_nodepool_guard.py +141 -0
- fdai_deployment_cli/standalone_application.py +977 -0
- fdai_deployment_cli/standalone_application_completion.py +151 -0
- fdai_deployment_cli/standalone_application_input_recovery.py +107 -0
- fdai_deployment_cli/standalone_application_recovery.py +116 -0
- fdai_deployment_cli/standalone_approval_input.py +24 -0
- fdai_deployment_cli/standalone_catalog_checkpoint.py +122 -0
- fdai_deployment_cli/standalone_catalog_review.py +251 -0
- fdai_deployment_cli/standalone_checkpoint_failure.py +127 -0
- fdai_deployment_cli/standalone_console_publish.py +174 -0
- fdai_deployment_cli/standalone_database_principals.py +41 -0
- fdai_deployment_cli/standalone_deploy.py +638 -0
- fdai_deployment_cli/standalone_development_authority.py +166 -0
- fdai_deployment_cli/standalone_foundation_adoption.py +87 -0
- fdai_deployment_cli/standalone_foundation_transition.py +767 -0
- fdai_deployment_cli/standalone_host.py +4608 -0
- fdai_deployment_cli/standalone_host_rebind.py +226 -0
- fdai_deployment_cli/standalone_host_residual.py +104 -0
- fdai_deployment_cli/standalone_host_state.py +145 -0
- fdai_deployment_cli/standalone_host_values.py +563 -0
- fdai_deployment_cli/standalone_kubernetes_tools.py +100 -0
- fdai_deployment_cli/standalone_license_installation.py +177 -0
- fdai_deployment_cli/standalone_management_egress.py +83 -0
- fdai_deployment_cli/standalone_migration_evidence.py +38 -0
- fdai_deployment_cli/standalone_operational_evidence.py +356 -0
- fdai_deployment_cli/standalone_planned_outputs.py +147 -0
- fdai_deployment_cli/standalone_private_access_probe.py +100 -0
- fdai_deployment_cli/standalone_product_profile.py +110 -0
- fdai_deployment_cli/standalone_remote_prepare.py +442 -0
- fdai_deployment_cli/standalone_residual_apply.py +540 -0
- fdai_deployment_cli/standalone_residual_successor.py +431 -0
- fdai_deployment_cli/standalone_review.py +266 -0
- fdai_deployment_cli/standalone_service_update_finalization.py +116 -0
- fdai_deployment_cli/standalone_service_update_recovery.py +277 -0
- fdai_deployment_cli/standalone_stage_targets.py +325 -0
- fdai_deployment_cli/standalone_status.py +67 -0
- fdai_deployment_cli/standalone_terraform_environment.py +237 -0
- fdai_deployment_cli/standalone_transfer_cleanup.py +237 -0
- fdai_deployment_cli/standalone_trial_activation.py +193 -0
- fdai_deployment_cli/state.py +434 -0
- fdai_deployment_cli/state_handoff.py +253 -0
- fdai_deployment_cli/status_projection.py +97 -0
- fdai_deployment_cli/support_install.py +257 -0
- fdai_deployment_cli/target.py +46 -0
- fdai_deployment_cli/trust/capabilities.json +62 -0
- fdai_deployment_cli/trust/deployment-bundle-root.pub +3 -0
- fdai_deployment_cli/trust/deployment-release-root.pub +3 -0
- fdai_deployment_cli/trust/upstream-signing-key.pub +3 -0
- fdai_deployment_cli/trust_roots.py +50 -0
- fdai_deployment_cli-0.1.2.dist-info/METADATA +112 -0
- fdai_deployment_cli-0.1.2.dist-info/RECORD +203 -0
- fdai_deployment_cli-0.1.2.dist-info/WHEEL +4 -0
- fdai_deployment_cli-0.1.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Venue-scoped persistence, not Git review, Hub acceptance or a global publisher lease."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from uuid import uuid4
|
|
15
|
+
|
|
16
|
+
from fdai_deployment_cli.contracts import canonical_bytes
|
|
17
|
+
from fdai_deployment_cli.deployment_kit_cache import acquisition_lock, path_present
|
|
18
|
+
from fdai_deployment_cli.lifecycle_configuration_artifact import (
|
|
19
|
+
MAX_CONFIGURATION_ARTIFACT_BYTES,
|
|
20
|
+
configuration_artifact_bytes,
|
|
21
|
+
read_configuration_artifact,
|
|
22
|
+
)
|
|
23
|
+
from fdai_deployment_cli.lifecycle_configuration_package import SignedConfigurationPackage
|
|
24
|
+
from fdai_deployment_cli.lifecycle_configuration_sealing import read_object
|
|
25
|
+
from fdai_deployment_cli.private_output import read_private_bytes, write_private_bytes
|
|
26
|
+
|
|
27
|
+
MAX_PUBLICATIONS = 256
|
|
28
|
+
MAX_PUBLICATION_BYTES = 16 * 1024 * 1024
|
|
29
|
+
_DATABASE = "publication.sqlite3"
|
|
30
|
+
_FORMAT = "fdai.configuration-publication-state.v1"
|
|
31
|
+
_REVISION = re.compile(r"[0-9a-f]{40}")
|
|
32
|
+
_SHA256 = re.compile(r"[0-9a-f]{64}")
|
|
33
|
+
Build = Callable[[Sequence[SignedConfigurationPackage]], SignedConfigurationPackage]
|
|
34
|
+
Verify = Callable[[Sequence[SignedConfigurationPackage], SignedConfigurationPackage], None]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Publication:
|
|
39
|
+
venue_id: str
|
|
40
|
+
sequence: int
|
|
41
|
+
revision_id: str
|
|
42
|
+
created_at: str
|
|
43
|
+
package: SignedConfigurationPackage
|
|
44
|
+
artifact: bytes
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def artifact_name(self) -> str:
|
|
48
|
+
return f"{self.package.digest}.json"
|
|
49
|
+
|
|
50
|
+
def receipt(self) -> dict[str, object]:
|
|
51
|
+
value: dict[str, object] = {
|
|
52
|
+
"format": "fdai.configuration-publication-receipt.v1",
|
|
53
|
+
"venue_id": self.venue_id,
|
|
54
|
+
"sequence": self.sequence,
|
|
55
|
+
"revision_id": self.revision_id,
|
|
56
|
+
"predecessor_digest": read_object(self.package.payload)["predecessor_digest"],
|
|
57
|
+
"package_digest": self.package.digest,
|
|
58
|
+
"artifact_digest": hashlib.sha256(self.artifact).hexdigest(),
|
|
59
|
+
"artifact_name": self.artifact_name,
|
|
60
|
+
"created_at": self.created_at,
|
|
61
|
+
}
|
|
62
|
+
return {**value, "receipt_digest": hashlib.sha256(canonical_bytes(value)).hexdigest()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _revision_id(value: str) -> str:
|
|
66
|
+
if _REVISION.fullmatch(value) is None:
|
|
67
|
+
raise ValueError("publication revision must be 40 lowercase hexadecimal characters")
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def _connection(
|
|
73
|
+
venue: Path, *, initial: bool = False, readonly: bool = False
|
|
74
|
+
) -> Iterator[sqlite3.Connection]:
|
|
75
|
+
database = venue / _DATABASE
|
|
76
|
+
new = not path_present(database)
|
|
77
|
+
if new:
|
|
78
|
+
if readonly or not initial or any(venue.iterdir()):
|
|
79
|
+
raise ValueError("publication state is absent; preserve the venue for review")
|
|
80
|
+
write_private_bytes(database, b"")
|
|
81
|
+
else:
|
|
82
|
+
snapshot = read_private_bytes(database, max_bytes=2 * MAX_PUBLICATION_BYTES)
|
|
83
|
+
if not snapshot.startswith(b"SQLite format 3\0") or snapshot[18:20] != b"\x01\x01":
|
|
84
|
+
raise ValueError("publication journal storage format is corrupt or unsupported")
|
|
85
|
+
if any(path_present(venue / (_DATABASE + suffix)) for suffix in ("-journal", "-wal", "-shm")):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"retained publication transaction sidecar requires review, not automatic recovery"
|
|
88
|
+
)
|
|
89
|
+
connection = sqlite3.connect(
|
|
90
|
+
database.as_uri() + "?mode=ro" if readonly else str(database),
|
|
91
|
+
uri=readonly,
|
|
92
|
+
timeout=0.1,
|
|
93
|
+
isolation_level=None,
|
|
94
|
+
)
|
|
95
|
+
deadline = time.monotonic() + 10
|
|
96
|
+
connection.set_progress_handler(lambda: int(time.monotonic() > deadline), 1000)
|
|
97
|
+
try:
|
|
98
|
+
if new:
|
|
99
|
+
connection.executescript(
|
|
100
|
+
"PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; BEGIN IMMEDIATE;"
|
|
101
|
+
"CREATE TABLE metadata (id INTEGER PRIMARY KEY, format TEXT NOT NULL, venue_id TEXT NOT NULL);"
|
|
102
|
+
"CREATE TABLE packages (sequence INTEGER PRIMARY KEY, revision_id TEXT NOT NULL UNIQUE,"
|
|
103
|
+
"receipt BLOB NOT NULL, artifact BLOB NOT NULL);"
|
|
104
|
+
)
|
|
105
|
+
connection.execute("INSERT INTO metadata VALUES (1, ?, ?)", (_FORMAT, uuid4().hex))
|
|
106
|
+
connection.execute("PRAGMA user_version=1")
|
|
107
|
+
connection.commit()
|
|
108
|
+
if connection.execute("PRAGMA user_version").fetchone() != (1,):
|
|
109
|
+
raise ValueError("unsupported publication journal version")
|
|
110
|
+
if connection.execute("PRAGMA quick_check(1)").fetchone() != ("ok",):
|
|
111
|
+
raise ValueError("publication journal integrity check failed")
|
|
112
|
+
tables = {
|
|
113
|
+
row[0]
|
|
114
|
+
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
115
|
+
}
|
|
116
|
+
if tables != {"metadata", "packages"}:
|
|
117
|
+
raise ValueError("publication journal schema is incomplete or unknown")
|
|
118
|
+
for table, columns in (
|
|
119
|
+
("metadata", ("id", "format", "venue_id")),
|
|
120
|
+
("packages", ("sequence", "revision_id", "receipt", "artifact")),
|
|
121
|
+
):
|
|
122
|
+
actual = tuple(row[1] for row in connection.execute(f"PRAGMA table_info({table})")) # noqa: S608
|
|
123
|
+
if actual != columns:
|
|
124
|
+
raise ValueError("publication journal columns are invalid")
|
|
125
|
+
if not readonly:
|
|
126
|
+
connection.execute("PRAGMA synchronous=FULL")
|
|
127
|
+
yield connection
|
|
128
|
+
finally:
|
|
129
|
+
connection.close()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _load(connection: sqlite3.Connection, venue: Path) -> tuple[str, list[Publication]]:
|
|
133
|
+
metadata = connection.execute("SELECT id, format, venue_id FROM metadata").fetchall()
|
|
134
|
+
if (
|
|
135
|
+
len(metadata) != 1
|
|
136
|
+
or metadata[0][0] != 1
|
|
137
|
+
or metadata[0][1] != _FORMAT
|
|
138
|
+
or not isinstance(metadata[0][2], str)
|
|
139
|
+
or re.fullmatch(r"[0-9a-f]{32}", metadata[0][2]) is None
|
|
140
|
+
):
|
|
141
|
+
raise ValueError("publication venue binding is invalid")
|
|
142
|
+
venue_id = metadata[0][2]
|
|
143
|
+
count, total, receipt_size = connection.execute(
|
|
144
|
+
"SELECT COUNT(*), COALESCE(SUM(length(artifact)),0), COALESCE(MAX(length(receipt)),0) FROM packages"
|
|
145
|
+
).fetchone()
|
|
146
|
+
if count > MAX_PUBLICATIONS or total > MAX_PUBLICATION_BYTES or receipt_size > 4096:
|
|
147
|
+
raise ValueError("retained publication history exceeds its bound")
|
|
148
|
+
result: list[Publication] = []
|
|
149
|
+
previous: str | None = None
|
|
150
|
+
for expected, row in enumerate(
|
|
151
|
+
connection.execute(
|
|
152
|
+
"SELECT sequence, revision_id, receipt, artifact FROM packages ORDER BY sequence"
|
|
153
|
+
),
|
|
154
|
+
1,
|
|
155
|
+
):
|
|
156
|
+
sequence, revision, receipt_bytes, artifact = row
|
|
157
|
+
if (
|
|
158
|
+
sequence != expected
|
|
159
|
+
or not isinstance(revision, str)
|
|
160
|
+
or not isinstance(receipt_bytes, bytes)
|
|
161
|
+
or not isinstance(artifact, bytes)
|
|
162
|
+
):
|
|
163
|
+
raise ValueError("retained publication identity is invalid")
|
|
164
|
+
_revision_id(revision)
|
|
165
|
+
package = read_configuration_artifact(artifact)
|
|
166
|
+
payload = read_object(package.payload)
|
|
167
|
+
if (
|
|
168
|
+
payload.get("revision_id") != revision
|
|
169
|
+
or "predecessor_digest" not in payload
|
|
170
|
+
or payload["predecessor_digest"] != previous
|
|
171
|
+
):
|
|
172
|
+
raise ValueError("retained publication chain is incomplete or forked")
|
|
173
|
+
receipt = read_object(receipt_bytes)
|
|
174
|
+
created = receipt.get("created_at")
|
|
175
|
+
if not isinstance(created, str) or datetime.fromisoformat(created).utcoffset() is None:
|
|
176
|
+
raise ValueError("publication receipt time is invalid")
|
|
177
|
+
record = Publication(venue_id, sequence, revision, created, package, artifact)
|
|
178
|
+
if receipt != record.receipt() or canonical_bytes(receipt) != receipt_bytes:
|
|
179
|
+
raise ValueError("publication receipt or artifact digest is inconsistent")
|
|
180
|
+
result.append(record)
|
|
181
|
+
_artifact_state(venue, record, materialize=False)
|
|
182
|
+
previous = package.digest
|
|
183
|
+
expected_names = {_DATABASE} | {r.artifact_name for r in result}
|
|
184
|
+
if any(path.name not in expected_names for path in venue.iterdir()):
|
|
185
|
+
raise ValueError("publication venue contains unbound or orphan artifacts")
|
|
186
|
+
return venue_id, result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _artifact_state(venue: Path, record: Publication, *, materialize: bool) -> str:
|
|
190
|
+
path = venue / record.artifact_name
|
|
191
|
+
if not path_present(path):
|
|
192
|
+
if not materialize:
|
|
193
|
+
return "missing"
|
|
194
|
+
write_private_bytes(path, record.artifact)
|
|
195
|
+
if read_private_bytes(path, max_bytes=MAX_CONFIGURATION_ARTIFACT_BYTES) != record.artifact:
|
|
196
|
+
raise ValueError("retained publication artifact conflicts; it will not be overwritten")
|
|
197
|
+
return "present"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def publish(
|
|
201
|
+
venue: Path,
|
|
202
|
+
revision_id: str,
|
|
203
|
+
build: Build,
|
|
204
|
+
verify: Verify,
|
|
205
|
+
*,
|
|
206
|
+
initial: bool = False,
|
|
207
|
+
expected_head: str | None = None,
|
|
208
|
+
) -> Publication:
|
|
209
|
+
"""Trusted callbacks verify complete history and output; historical retry never rewinds it."""
|
|
210
|
+
_revision_id(revision_id)
|
|
211
|
+
if not venue.is_absolute() or initial == (expected_head is not None):
|
|
212
|
+
raise ValueError("select explicit initial publication or retained publication head")
|
|
213
|
+
if expected_head is not None and _SHA256.fullmatch(expected_head) is None:
|
|
214
|
+
raise ValueError("publication head must be a SHA-256 digest")
|
|
215
|
+
with acquisition_lock(venue), _connection(venue, initial=initial) as connection:
|
|
216
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
217
|
+
venue_id, records = _load(connection, venue)
|
|
218
|
+
existing = next((r for r in records if r.revision_id == revision_id), None)
|
|
219
|
+
head = records[-1].package.digest if records else None
|
|
220
|
+
predecessor = (
|
|
221
|
+
read_object(existing.package.payload)["predecessor_digest"] if existing else head
|
|
222
|
+
)
|
|
223
|
+
if initial:
|
|
224
|
+
if records and (existing is None or existing.sequence != 1):
|
|
225
|
+
raise ValueError("initial publication cannot advance retained history")
|
|
226
|
+
elif expected_head not in {head, predecessor}:
|
|
227
|
+
raise ValueError("publication head changed; review retained evidence")
|
|
228
|
+
if existing is None and len(records) >= MAX_PUBLICATIONS:
|
|
229
|
+
raise ValueError("new publication exceeds its count bound")
|
|
230
|
+
if existing is not None and existing.sequence < len(records):
|
|
231
|
+
verify(tuple(r.package for r in records[:-1]), records[-1].package)
|
|
232
|
+
selected = records[: existing.sequence]
|
|
233
|
+
else:
|
|
234
|
+
selected = records
|
|
235
|
+
package = build(tuple(r.package for r in selected))
|
|
236
|
+
if read_object(package.payload).get("revision_id") != revision_id:
|
|
237
|
+
raise ValueError("publication input revision changed during validation")
|
|
238
|
+
if existing is not None:
|
|
239
|
+
if package != existing.package:
|
|
240
|
+
raise ValueError("the revision already has different retained publication bytes")
|
|
241
|
+
record = existing
|
|
242
|
+
else:
|
|
243
|
+
artifact = configuration_artifact_bytes(package)
|
|
244
|
+
if (
|
|
245
|
+
sum(len(r.artifact) for r in records) + len(artifact) > MAX_PUBLICATION_BYTES
|
|
246
|
+
or read_object(package.payload).get("predecessor_digest") != head
|
|
247
|
+
):
|
|
248
|
+
raise ValueError("new publication exceeds history bounds or forks its head")
|
|
249
|
+
record = Publication(
|
|
250
|
+
venue_id,
|
|
251
|
+
len(records) + 1,
|
|
252
|
+
revision_id,
|
|
253
|
+
datetime.now(UTC).isoformat(),
|
|
254
|
+
package,
|
|
255
|
+
artifact,
|
|
256
|
+
)
|
|
257
|
+
connection.execute(
|
|
258
|
+
"INSERT INTO packages VALUES (?, ?, ?, ?)",
|
|
259
|
+
(record.sequence, revision_id, canonical_bytes(record.receipt()), artifact),
|
|
260
|
+
)
|
|
261
|
+
connection.commit()
|
|
262
|
+
_artifact_state(venue, record, materialize=True)
|
|
263
|
+
return record
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def inspect_publication(venue: Path, revision_id: str, verify: Verify) -> tuple[Publication, str]:
|
|
267
|
+
"""Verify the entire retained chain with public inputs; never repair or create state."""
|
|
268
|
+
_revision_id(revision_id)
|
|
269
|
+
with acquisition_lock(venue), _connection(venue, readonly=True) as connection:
|
|
270
|
+
_, records = _load(connection, venue)
|
|
271
|
+
if not records:
|
|
272
|
+
raise ValueError("publication history is empty")
|
|
273
|
+
verify(tuple(r.package for r in records[:-1]), records[-1].package)
|
|
274
|
+
record = next((r for r in records if r.revision_id == revision_id), None)
|
|
275
|
+
if record is None:
|
|
276
|
+
raise ValueError("the revision has no retained publication")
|
|
277
|
+
return record, _artifact_state(venue, record, materialize=False)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Local publication commands with explicit unverified authority boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sqlite3
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from functools import partial
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from cryptography.exceptions import UnsupportedAlgorithm
|
|
14
|
+
|
|
15
|
+
from fdai_deployment_cli.cli_help import command
|
|
16
|
+
from fdai_deployment_cli.lifecycle_configuration_package import SignedConfigurationPackage
|
|
17
|
+
from fdai_deployment_cli.lifecycle_configuration_publication import inspect_publication, publish
|
|
18
|
+
from fdai_deployment_cli.lifecycle_configuration_sealing import read_object
|
|
19
|
+
from fdai_deployment_cli.private_output import read_private_bytes
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigurationExecutor(Protocol):
|
|
23
|
+
def __call__(
|
|
24
|
+
self,
|
|
25
|
+
args: argparse.Namespace,
|
|
26
|
+
*,
|
|
27
|
+
retained_history: Sequence[SignedConfigurationPackage] | None = None,
|
|
28
|
+
retained_package: SignedConfigurationPackage | None = None,
|
|
29
|
+
persist_output: bool = True,
|
|
30
|
+
) -> SignedConfigurationPackage: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register_publication_commands(
|
|
34
|
+
commands: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
35
|
+
execute: ConfigurationExecutor,
|
|
36
|
+
) -> None:
|
|
37
|
+
for operation in ("publish", "inspect-publication"):
|
|
38
|
+
parser = command(
|
|
39
|
+
commands,
|
|
40
|
+
operation,
|
|
41
|
+
f"{operation.title()} venue-scoped configuration evidence",
|
|
42
|
+
epilog="Local custody only. Git review, global publisher identity and Hub acceptance are unverified.",
|
|
43
|
+
)
|
|
44
|
+
for name, help_text in (
|
|
45
|
+
(
|
|
46
|
+
"release-input",
|
|
47
|
+
"Independent JSON Release/schema and optional opaque Entity selection",
|
|
48
|
+
),
|
|
49
|
+
("configuration-key", "Independently supplied Ed25519 verification key in PEM format"),
|
|
50
|
+
("installation-key", "Explicit installation X25519 public key in PEM format"),
|
|
51
|
+
):
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
f"--{name}", type=Path, required=True, metavar="PRIVATE_FILE", help=help_text
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--state-dir",
|
|
57
|
+
type=Path,
|
|
58
|
+
required=True,
|
|
59
|
+
metavar="PRIVATE_DIRECTORY",
|
|
60
|
+
help="Existing absolute operator-owned mode-0700 venue; no reset or destructive recovery",
|
|
61
|
+
)
|
|
62
|
+
if operation == "publish":
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--input",
|
|
65
|
+
type=Path,
|
|
66
|
+
required=True,
|
|
67
|
+
metavar="PRIVATE_JSON_FILE",
|
|
68
|
+
help="Private v1/v2 revision inputs; plaintext identifiers are sealed, not journaled",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--signing-key",
|
|
72
|
+
type=Path,
|
|
73
|
+
required=True,
|
|
74
|
+
metavar="PRIVATE_PEM_FILE",
|
|
75
|
+
help="Private unencrypted PKCS8 Ed25519 PEM file; never supply key bytes in argv",
|
|
76
|
+
)
|
|
77
|
+
head = parser.add_mutually_exclusive_group(required=True)
|
|
78
|
+
head.add_argument(
|
|
79
|
+
"--initial-publication",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="First publication in an empty local venue or its exact retained retry",
|
|
82
|
+
)
|
|
83
|
+
head.add_argument(
|
|
84
|
+
"--expected-publication-head", help="Retained local publication head SHA-256"
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--revision-id", required=True, help="Full lowercase source revision shape"
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--output",
|
|
92
|
+
choices=("text", "json"),
|
|
93
|
+
default="text",
|
|
94
|
+
help="Render local evidence as readable text or a JSON object",
|
|
95
|
+
)
|
|
96
|
+
parser.set_defaults(
|
|
97
|
+
handler=partial(_run, execute=execute),
|
|
98
|
+
configuration_operation=operation,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _arguments(
|
|
103
|
+
args: argparse.Namespace,
|
|
104
|
+
operation: str,
|
|
105
|
+
history: Sequence[SignedConfigurationPackage],
|
|
106
|
+
) -> argparse.Namespace:
|
|
107
|
+
return argparse.Namespace(
|
|
108
|
+
**{
|
|
109
|
+
**vars(args),
|
|
110
|
+
"configuration_operation": operation,
|
|
111
|
+
"history": [],
|
|
112
|
+
"initial_revision": not history,
|
|
113
|
+
"expected_predecessor": history[-1].digest if history else None,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _run(args: argparse.Namespace, *, execute: ConfigurationExecutor) -> int:
|
|
119
|
+
def verify(
|
|
120
|
+
history: Sequence[SignedConfigurationPackage],
|
|
121
|
+
package: SignedConfigurationPackage,
|
|
122
|
+
) -> None:
|
|
123
|
+
execute(
|
|
124
|
+
_arguments(args, "verify", history),
|
|
125
|
+
retained_history=history,
|
|
126
|
+
retained_package=package,
|
|
127
|
+
persist_output=False,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
if args.configuration_operation == "publish":
|
|
132
|
+
source = read_object(read_private_bytes(args.input, max_bytes=1024 * 1024))
|
|
133
|
+
revision = source.get("revision_id")
|
|
134
|
+
if not isinstance(revision, str):
|
|
135
|
+
raise ValueError("publication revision is required")
|
|
136
|
+
|
|
137
|
+
def build(history: Sequence[SignedConfigurationPackage]) -> SignedConfigurationPackage:
|
|
138
|
+
return execute(
|
|
139
|
+
_arguments(args, "build", history),
|
|
140
|
+
retained_history=history,
|
|
141
|
+
persist_output=False,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
record = publish(
|
|
145
|
+
args.state_dir,
|
|
146
|
+
revision,
|
|
147
|
+
build,
|
|
148
|
+
verify,
|
|
149
|
+
initial=args.initial_publication,
|
|
150
|
+
expected_head=args.expected_publication_head,
|
|
151
|
+
)
|
|
152
|
+
artifact_state = "present"
|
|
153
|
+
else:
|
|
154
|
+
record, artifact_state = inspect_publication(args.state_dir, args.revision_id, verify)
|
|
155
|
+
except (
|
|
156
|
+
ValueError,
|
|
157
|
+
TypeError,
|
|
158
|
+
OSError,
|
|
159
|
+
OverflowError,
|
|
160
|
+
RecursionError,
|
|
161
|
+
UnsupportedAlgorithm,
|
|
162
|
+
sqlite3.Error,
|
|
163
|
+
):
|
|
164
|
+
raise ValueError(
|
|
165
|
+
"configuration publication not confirmed; inspect retained evidence before retrying; "
|
|
166
|
+
"check the private venue, revision/head selection and independent keys/Release inputs"
|
|
167
|
+
) from None
|
|
168
|
+
result = {
|
|
169
|
+
"operation": args.configuration_operation,
|
|
170
|
+
"receipt": record.receipt(),
|
|
171
|
+
"artifact_state": artifact_state,
|
|
172
|
+
"publication_confirmed": artifact_state == "present",
|
|
173
|
+
"verified": True,
|
|
174
|
+
"local_only": True,
|
|
175
|
+
"git_revision_existence": "unverified",
|
|
176
|
+
"git_review": "unverified",
|
|
177
|
+
"global_publisher_identity": "unverified",
|
|
178
|
+
"trust_registered": False,
|
|
179
|
+
"hub_accepted": False,
|
|
180
|
+
"deployment_activated": False,
|
|
181
|
+
"execution_authority": False,
|
|
182
|
+
"production_release_eligibility": "unverified",
|
|
183
|
+
}
|
|
184
|
+
print(
|
|
185
|
+
json.dumps(result, sort_keys=True)
|
|
186
|
+
if args.output == "json"
|
|
187
|
+
else f"verified retained configuration evidence sha256={record.package.digest}; artifact={artifact_state}; "
|
|
188
|
+
f"local publication confirmed={artifact_state == 'present'}; "
|
|
189
|
+
"no Git review, Hub acceptance or execution authority"
|
|
190
|
+
)
|
|
191
|
+
return 0
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Installation-only identifier encryption; no identity or enrollment authority."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
from cryptography.exceptions import InvalidTag
|
|
13
|
+
from cryptography.hazmat.primitives import hashes
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
|
15
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
16
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
17
|
+
|
|
18
|
+
from fdai_deployment_cli.contracts import canonical_bytes
|
|
19
|
+
from fdai_deployment_cli.lifecycle_configuration import validate_configuration_package_for_signing
|
|
20
|
+
|
|
21
|
+
_ALGORITHM = "X25519-HKDF-SHA256-AES256GCM"
|
|
22
|
+
_INFO = b"fdai.lifecycle-configuration.sealed.v1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def encryption_key_digest(key: X25519PublicKey) -> str:
|
|
26
|
+
"""Identify an explicitly bound encryption key, never an Ed25519 identity key."""
|
|
27
|
+
return hashlib.sha256(key.public_bytes_raw()).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def seal_identifiers(
|
|
31
|
+
values: Mapping[str, object], recipient: X25519PublicKey, *, context: bytes
|
|
32
|
+
) -> dict[str, object]:
|
|
33
|
+
"""Scan plaintext before encryption and bind ciphertext to the signed revision context."""
|
|
34
|
+
validate_configuration_package_for_signing(values)
|
|
35
|
+
plaintext = canonical_bytes(values)
|
|
36
|
+
ephemeral = X25519PrivateKey.generate()
|
|
37
|
+
nonce = os.urandom(12)
|
|
38
|
+
key = _derive_key(ephemeral, recipient, context)
|
|
39
|
+
ciphertext = AESGCM(key).encrypt(nonce, plaintext, context)
|
|
40
|
+
return {
|
|
41
|
+
"algorithm": _ALGORITHM,
|
|
42
|
+
"recipient_digest": encryption_key_digest(recipient),
|
|
43
|
+
"ephemeral_key": _encode(ephemeral.public_key().public_bytes_raw()),
|
|
44
|
+
"nonce": _encode(nonce),
|
|
45
|
+
"ciphertext": _encode(ciphertext),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_sealed_identifiers(envelope: Mapping[str, object], *, recipient_digest: str) -> None:
|
|
50
|
+
"""Check the public envelope without decrypting or exposing identifier values."""
|
|
51
|
+
if set(envelope) != {"algorithm", "recipient_digest", "ephemeral_key", "nonce", "ciphertext"}:
|
|
52
|
+
raise ValueError("sealed identifier envelope fields are invalid")
|
|
53
|
+
if envelope["algorithm"] != _ALGORITHM or envelope["recipient_digest"] != recipient_digest:
|
|
54
|
+
raise ValueError("sealed identifier recipient or algorithm is invalid")
|
|
55
|
+
if len(_decode(envelope["ephemeral_key"])) != 32 or len(_decode(envelope["nonce"])) != 12:
|
|
56
|
+
raise ValueError("sealed identifier key or nonce length is invalid")
|
|
57
|
+
if len(_decode(envelope["ciphertext"])) < 16:
|
|
58
|
+
raise ValueError("sealed identifier ciphertext is invalid")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def open_identifiers(
|
|
62
|
+
envelope: Mapping[str, object], recipient: X25519PrivateKey, *, context: bytes
|
|
63
|
+
) -> dict[str, object]:
|
|
64
|
+
"""Decrypt only inside the installation; a wrong key or changed revision fails closed."""
|
|
65
|
+
validate_sealed_identifiers(
|
|
66
|
+
envelope, recipient_digest=encryption_key_digest(recipient.public_key())
|
|
67
|
+
)
|
|
68
|
+
ephemeral = X25519PublicKey.from_public_bytes(_decode(envelope["ephemeral_key"]))
|
|
69
|
+
key = _derive_key(recipient, ephemeral, context)
|
|
70
|
+
try:
|
|
71
|
+
plaintext = AESGCM(key).decrypt(
|
|
72
|
+
_decode(envelope["nonce"]), _decode(envelope["ciphertext"]), context
|
|
73
|
+
)
|
|
74
|
+
except InvalidTag:
|
|
75
|
+
raise ValueError("sealed identifier authentication failed") from None
|
|
76
|
+
values = read_object(plaintext)
|
|
77
|
+
validate_configuration_package_for_signing(values)
|
|
78
|
+
return values
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_object(payload: bytes) -> dict[str, object]:
|
|
82
|
+
"""Read strict JSON without duplicate fields, nonfinite numbers, or error-data leakage."""
|
|
83
|
+
try:
|
|
84
|
+
value: object = json.loads(
|
|
85
|
+
payload, object_pairs_hook=_unique_object, parse_constant=_invalid_constant
|
|
86
|
+
)
|
|
87
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
88
|
+
raise ValueError("configuration package JSON is invalid") from None
|
|
89
|
+
if not isinstance(value, dict):
|
|
90
|
+
raise ValueError("configuration package JSON must be an object")
|
|
91
|
+
return {str(key): item for key, item in value.items()}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
95
|
+
result: dict[str, object] = {}
|
|
96
|
+
for key, item in pairs:
|
|
97
|
+
if key in result:
|
|
98
|
+
raise ValueError("duplicate JSON field")
|
|
99
|
+
result[key] = item
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _invalid_constant(value: str) -> object:
|
|
104
|
+
raise ValueError("nonfinite JSON number")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _derive_key(private: X25519PrivateKey, public: X25519PublicKey, context: bytes) -> bytes:
|
|
108
|
+
try:
|
|
109
|
+
shared = private.exchange(public)
|
|
110
|
+
except ValueError:
|
|
111
|
+
raise ValueError("installation encryption key is invalid") from None
|
|
112
|
+
return HKDF(
|
|
113
|
+
algorithm=hashes.SHA256(), length=32, salt=hashlib.sha256(context).digest(), info=_INFO
|
|
114
|
+
).derive(shared)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _encode(value: bytes) -> str:
|
|
118
|
+
return base64.b64encode(value).decode("ascii")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _decode(value: object) -> bytes:
|
|
122
|
+
if not isinstance(value, str):
|
|
123
|
+
raise ValueError("sealed identifier encoding is invalid")
|
|
124
|
+
try:
|
|
125
|
+
return base64.b64decode(value, validate=True)
|
|
126
|
+
except (ValueError, binascii.Error):
|
|
127
|
+
raise ValueError("sealed identifier encoding is invalid") from None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The one canonical installation enrollment signing implementation.
|
|
2
|
+
|
|
3
|
+
The Lifecycle Hub's offline `sign-enrollment` and the Lifecycle Agent's online `enroll` both build
|
|
4
|
+
the signed request here, so the two cannot drift: the Hub verifies exactly these bytes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from base64 import b64encode
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives import serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
16
|
+
|
|
17
|
+
from fdai_deployment_cli.contracts import canonical_bytes
|
|
18
|
+
|
|
19
|
+
# The Hub rejects a proof whose request time differs from its clock by more than this.
|
|
20
|
+
ENROLLMENT_PROOF_WINDOW = timedelta(minutes=5)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def raw_installation_public_key(key: Ed25519PrivateKey) -> bytes:
|
|
24
|
+
return key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def installation_key_id(public_key: bytes) -> str:
|
|
28
|
+
"""The id an approver compares with the key the installation shows out of band."""
|
|
29
|
+
|
|
30
|
+
return "installation-" + hashlib.sha256(public_key).hexdigest()[:16]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sign_enrollment_payload(
|
|
34
|
+
template: Mapping[str, object], key: Ed25519PrivateKey, at: datetime
|
|
35
|
+
) -> tuple[bytes, bytes]:
|
|
36
|
+
"""Add the installation key and `at` to `template`, and return the payload and its proof."""
|
|
37
|
+
|
|
38
|
+
if at.tzinfo is None or at.utcoffset() is None:
|
|
39
|
+
raise ValueError("requested_at must be timezone-aware")
|
|
40
|
+
fields = {
|
|
41
|
+
"installation_key": b64encode(raw_installation_public_key(key)).decode("ascii"),
|
|
42
|
+
"requested_at": at.isoformat(),
|
|
43
|
+
}
|
|
44
|
+
payload = canonical_bytes({**template, **fields})
|
|
45
|
+
return payload, key.sign(payload)
|