pgc-assembler 4.0.0__tar.gz → 5.0.0__tar.gz
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.
- {pgc_assembler-4.0.0/pgc_assembler.egg-info → pgc_assembler-5.0.0}/PKG-INFO +13 -9
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/README.md +10 -8
- pgc_assembler-5.0.0/VERSION +1 -0
- pgc_assembler-5.0.0/assembler/VERSION +1 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/cli.py +67 -1
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/core.py +55 -11
- pgc_assembler-5.0.0/assembler/signing.py +229 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0/pgc_assembler.egg-info}/PKG-INFO +13 -9
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/SOURCES.txt +1 -0
- pgc_assembler-5.0.0/pgc_assembler.egg-info/requires.txt +4 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pyproject.toml +8 -1
- pgc_assembler-4.0.0/VERSION +0 -1
- pgc_assembler-4.0.0/assembler/VERSION +0 -1
- pgc_assembler-4.0.0/pgc_assembler.egg-info/requires.txt +0 -1
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/LICENSE +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/MANIFEST.in +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/NOTICE +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/_build_hook.py +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/__init__.py +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/conformance.py +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/indexes.py +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/dependency_links.txt +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/entry_points.txt +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/top_level.txt +0 -0
- {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgc-assembler
|
|
3
|
-
Version:
|
|
3
|
+
Version: 5.0.0
|
|
4
4
|
Summary: PGC snapshot assembler — composes compiled projections into a manifest-pinned executable snapshot (import package: assembler)
|
|
5
5
|
Author-email: Bhash Ganti <bachipeachy@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -21,6 +21,8 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
License-File: NOTICE
|
|
23
23
|
Requires-Dist: pyyaml
|
|
24
|
+
Provides-Extra: signing
|
|
25
|
+
Requires-Dist: cryptography>=42; extra == "signing"
|
|
24
26
|
Dynamic: license-file
|
|
25
27
|
|
|
26
28
|
# snapshot_assembler
|
|
@@ -40,8 +42,10 @@ repos → protocol_compiler → each repo's compiled/ projections
|
|
|
40
42
|
```
|
|
41
43
|
|
|
42
44
|
The platform is an ordinary member of the composition — no singleton branch. The composition
|
|
43
|
-
currently assembles **
|
|
44
|
-
and the business domains `ai_governance`, `blockchain` and
|
|
45
|
+
currently assembles **eight domains**: `platform`, `workload`, `inspection`, `transformation`,
|
|
46
|
+
and the business domains `ai_governance`, `blockchain`, `book_library_mgmt` and
|
|
47
|
+
`causal_language_model`. A deliberately narrower composition is assembled by naming its roots in
|
|
48
|
+
`PGC_SOURCE_ROOTS`.
|
|
45
49
|
|
|
46
50
|
## Install
|
|
47
51
|
|
|
@@ -134,13 +138,13 @@ pgc # reports what is installed and whether the anchor resolves
|
|
|
134
138
|
|
|
135
139
|
`PGC_DOMAIN_ROOTS` names an additional domain contributing its own `registry/structures` — the
|
|
136
140
|
directory that *directly contains* it, not the repository above it; pointing one level too high is a
|
|
137
|
-
silent no-op.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
silent no-op. Where compiled output is written is declared by each build configuration
|
|
142
|
+
(`output_configuration.root`), not by the environment. `PGC_SNAPSHOT_PROFILES` is the directory
|
|
143
|
+
holding snapshot profiles, required by the assembler and the runtime alike. A snapshot's identity
|
|
144
|
+
covers the content of the profile it claims, so the profiles a node reads must be the ones it was
|
|
145
|
+
sealed against.
|
|
141
146
|
|
|
142
|
-
`PGC_BUILD_ROOT` is accepted and reported and **nothing reads it
|
|
143
|
-
anchor that controls output.
|
|
147
|
+
`PGC_BUILD_ROOT` is accepted and reported and **nothing reads it**.
|
|
144
148
|
|
|
145
149
|
The full sequence, with the repositories it needs, is in
|
|
146
150
|
[`pgc_install`](https://github.com/protocol-governed-computing/pgc_install).
|
|
@@ -15,8 +15,10 @@ repos → protocol_compiler → each repo's compiled/ projections
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
The platform is an ordinary member of the composition — no singleton branch. The composition
|
|
18
|
-
currently assembles **
|
|
19
|
-
and the business domains `ai_governance`, `blockchain` and
|
|
18
|
+
currently assembles **eight domains**: `platform`, `workload`, `inspection`, `transformation`,
|
|
19
|
+
and the business domains `ai_governance`, `blockchain`, `book_library_mgmt` and
|
|
20
|
+
`causal_language_model`. A deliberately narrower composition is assembled by naming its roots in
|
|
21
|
+
`PGC_SOURCE_ROOTS`.
|
|
20
22
|
|
|
21
23
|
## Install
|
|
22
24
|
|
|
@@ -109,13 +111,13 @@ pgc # reports what is installed and whether the anchor resolves
|
|
|
109
111
|
|
|
110
112
|
`PGC_DOMAIN_ROOTS` names an additional domain contributing its own `registry/structures` — the
|
|
111
113
|
directory that *directly contains* it, not the repository above it; pointing one level too high is a
|
|
112
|
-
silent no-op.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
silent no-op. Where compiled output is written is declared by each build configuration
|
|
115
|
+
(`output_configuration.root`), not by the environment. `PGC_SNAPSHOT_PROFILES` is the directory
|
|
116
|
+
holding snapshot profiles, required by the assembler and the runtime alike. A snapshot's identity
|
|
117
|
+
covers the content of the profile it claims, so the profiles a node reads must be the ones it was
|
|
118
|
+
sealed against.
|
|
116
119
|
|
|
117
|
-
`PGC_BUILD_ROOT` is accepted and reported and **nothing reads it
|
|
118
|
-
anchor that controls output.
|
|
120
|
+
`PGC_BUILD_ROOT` is accepted and reported and **nothing reads it**.
|
|
119
121
|
|
|
120
122
|
The full sequence, with the repositories it needs, is in
|
|
121
123
|
[`pgc_install`](https://github.com/protocol-governed-computing/pgc_install).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
17
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
17
|
|
@@ -22,7 +22,7 @@ import os
|
|
|
22
22
|
import sys
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
|
|
25
|
-
from assembler import conformance, core
|
|
25
|
+
from assembler import conformance, core, signing
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _build_parser() -> argparse.ArgumentParser:
|
|
@@ -44,9 +44,19 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
44
44
|
"none cannot have clause 4 of 3b §7 evaluated about it.",
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
+
a.add_argument(
|
|
48
|
+
"--signing-key", metavar="PEM", default=None,
|
|
49
|
+
help="Private key authenticating the sealed snapshot's identity. The build machine "
|
|
50
|
+
"holds this and no node that executes a workflow does: a node able to sign can "
|
|
51
|
+
"mint a snapshot its peers would accept.")
|
|
52
|
+
|
|
47
53
|
v = subs.add_parser("verify", help="Verify an assembled snapshot against its manifest")
|
|
48
54
|
v.add_argument("--out", required=True, metavar="SNAPSHOT_DIR", help="Assembled snapshot dir to verify")
|
|
49
55
|
|
|
56
|
+
k = subs.add_parser("keygen", help="Mint a signing keypair for a snapshot trust root")
|
|
57
|
+
k.add_argument("--private", required=True, metavar="PEM", help="Where to write the private half")
|
|
58
|
+
k.add_argument("--public", required=True, metavar="PEM", help="Where to write the public half")
|
|
59
|
+
|
|
50
60
|
c = subs.add_parser("conform", help="Run Composition Conformance against an assembled snapshot")
|
|
51
61
|
c.add_argument("--out", required=True, metavar="SNAPSHOT_DIR", help="Assembled snapshot dir to check")
|
|
52
62
|
|
|
@@ -58,6 +68,37 @@ def _fatal(msg: str) -> None:
|
|
|
58
68
|
sys.exit(1)
|
|
59
69
|
|
|
60
70
|
|
|
71
|
+
def _report_authentication(out_root: Path, snapshot_id: str) -> None:
|
|
72
|
+
"""Report whether the snapshot is authenticated, and refuse where this party requires it.
|
|
73
|
+
|
|
74
|
+
A party holding a trust root is a party that requires one. Absent `PGC_TRUST_ROOT_PUBKEY`
|
|
75
|
+
this reports what it finds and refuses nothing: an unsigned snapshot is well-formed, and a
|
|
76
|
+
platform whose profile does not select signing must still verify. Holding an anchor is the
|
|
77
|
+
opt-in, and it lives outside the artifact being authenticated — which is also why the key
|
|
78
|
+
is read from here and never from the signature record.
|
|
79
|
+
"""
|
|
80
|
+
anchor = os.environ.get("PGC_TRUST_ROOT_PUBKEY")
|
|
81
|
+
try:
|
|
82
|
+
record = signing.read_signature(out_root)
|
|
83
|
+
except signing.SignatureMalformed as exc:
|
|
84
|
+
_fatal(f"snapshot authentication: {exc}")
|
|
85
|
+
|
|
86
|
+
if not anchor:
|
|
87
|
+
state = (f"present, unchecked (signed under key {record.get('key_id', '?')})"
|
|
88
|
+
if record else "absent")
|
|
89
|
+
print(f"[signing] {state} — set PGC_TRUST_ROOT_PUBKEY to require and check one")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if record is None:
|
|
93
|
+
_fatal("snapshot authentication: this party anchors a trust root and the snapshot "
|
|
94
|
+
"carries no signature. An unsigned snapshot is not one this party accepts.")
|
|
95
|
+
try:
|
|
96
|
+
key = signing.verify_identity(snapshot_id, record, Path(anchor))
|
|
97
|
+
except (signing.SignatureError, signing.SigningUnavailable, OSError) as exc:
|
|
98
|
+
_fatal(f"snapshot authentication: {type(exc).__name__}: {exc}")
|
|
99
|
+
print(f"[signing] AUTHENTICATED under trust root {key}")
|
|
100
|
+
|
|
101
|
+
|
|
61
102
|
def main() -> None:
|
|
62
103
|
args = _build_parser().parse_args()
|
|
63
104
|
|
|
@@ -88,6 +129,17 @@ def main() -> None:
|
|
|
88
129
|
print(f"[conformance] composition: {ev['status']} "
|
|
89
130
|
f"({ev['rules_evaluated']} rule(s) over {ev['artifacts_examined']} artifacts)")
|
|
90
131
|
|
|
132
|
+
# Signing follows sealing and never precedes it: what is authenticated is the identity
|
|
133
|
+
# the seal produced. A snapshot is sealed whether or not anyone signs it; a signature
|
|
134
|
+
# says who vouches for the one that was sealed.
|
|
135
|
+
if args.signing_key:
|
|
136
|
+
try:
|
|
137
|
+
record = signing.sign_identity(manifest["snapshot_id"], Path(args.signing_key))
|
|
138
|
+
path = signing.write_signature(out_root, record)
|
|
139
|
+
except (signing.SigningUnavailable, OSError, ValueError) as exc:
|
|
140
|
+
_fatal(f"signing failed: {exc}")
|
|
141
|
+
print(f"[signing] signed under key {record['key_id']} -> {path}")
|
|
142
|
+
|
|
91
143
|
elif args.command == "verify":
|
|
92
144
|
out_root = Path(args.out).resolve()
|
|
93
145
|
try:
|
|
@@ -96,6 +148,20 @@ def main() -> None:
|
|
|
96
148
|
_fatal(str(exc))
|
|
97
149
|
print(f"[assembler] VERIFIED snapshot_id={manifest['snapshot_id']}")
|
|
98
150
|
print(f"[assembler] domains: {json.dumps([d['domain'] for d in manifest['domains']])}")
|
|
151
|
+
_report_authentication(out_root, manifest["snapshot_id"])
|
|
152
|
+
|
|
153
|
+
elif args.command == "keygen":
|
|
154
|
+
try:
|
|
155
|
+
private_pem, public_pem = signing.generate_keypair()
|
|
156
|
+
except signing.SigningUnavailable as exc:
|
|
157
|
+
_fatal(str(exc))
|
|
158
|
+
priv, pub = Path(args.private), Path(args.public)
|
|
159
|
+
priv.write_bytes(private_pem)
|
|
160
|
+
priv.chmod(0o600)
|
|
161
|
+
pub.write_bytes(public_pem)
|
|
162
|
+
print(f"[keygen] key_id: {signing.key_id_from_public_pem(public_pem)}")
|
|
163
|
+
print(f"[keygen] private: {priv} (build machine only — never on an executing node)")
|
|
164
|
+
print(f"[keygen] public: {pub} (each node holds this, from outside the snapshot)")
|
|
99
165
|
|
|
100
166
|
elif args.command == "conform":
|
|
101
167
|
out_root = Path(args.out).resolve()
|
|
@@ -182,7 +182,13 @@ def _identity_view(domains: list[dict]) -> list[dict]:
|
|
|
182
182
|
# it is a determination ABOUT the snapshot, in the sense `3b` §7 means, and evidence rather than
|
|
183
183
|
# content. That it is written inside the tree is a placement defect recorded in Task D; excluding it
|
|
184
184
|
# here is what makes the tree honest about what it sealed, not a carve-out to make a check pass.
|
|
185
|
-
|
|
185
|
+
# A signature is the same shape as a conformance result and joins this set for the same reason:
|
|
186
|
+
# it is over `snapshot_id`, so it cannot exist before the identity does, and it records who
|
|
187
|
+
# vouches for what was sealed rather than forming part of it. The placement defect noted above
|
|
188
|
+
# applies to it equally — it is written inside the tree and is not of it. That is an existing
|
|
189
|
+
# defect extended to a second file, not a new class of one, and not a carve-out invented to
|
|
190
|
+
# admit signing: a signature that were a constituent would change the identity it signs.
|
|
191
|
+
POST_SEAL = ("conformance/", "signature.json")
|
|
186
192
|
|
|
187
193
|
|
|
188
194
|
def _is_post_seal(rel: str) -> bool:
|
|
@@ -190,7 +196,7 @@ def _is_post_seal(rel: str) -> bool:
|
|
|
190
196
|
|
|
191
197
|
|
|
192
198
|
# What an attestation records rather than constitutes, per
|
|
193
|
-
# `cryptographic_trust::
|
|
199
|
+
# `cryptographic_trust::CONSTITUTION_CRYPTOGRAPHIC_TRUST_V1`. The projection binding and the value
|
|
194
200
|
# over it are enforced by the runtime at boot and constitute the composition like any other content;
|
|
195
201
|
# when the signing happened records something *about* it, is read by nothing, and changes on every
|
|
196
202
|
# build. Counting it made a composition's identity a function of when it was built — two compiles of
|
|
@@ -242,16 +248,20 @@ def enumerate_constituents(out_root: Path) -> list[dict[str, str]]:
|
|
|
242
248
|
return out
|
|
243
249
|
|
|
244
250
|
|
|
245
|
-
def compute_snapshot_identity(domains: list[dict], constituents: list[dict], profile: str
|
|
251
|
+
def compute_snapshot_identity(domains: list[dict], constituents: list[dict], profile: str,
|
|
252
|
+
profile_sha256: str) -> str:
|
|
246
253
|
"""The snapshot's identity, derived from content and covering every constituent (SN-2, SN-6).
|
|
247
254
|
|
|
248
|
-
|
|
255
|
+
Four inputs, and each is there for a stated reason:
|
|
249
256
|
|
|
250
257
|
* the domains identity view — the semantic identity of what was compiled;
|
|
251
258
|
* every constituent, by path and by a hash OF ITS BYTES — so that changing any carried file
|
|
252
259
|
changes the identity, which is SN-2's totality clause;
|
|
253
260
|
* the claimed profile — a self-description that claims a profile and does not cover the claim
|
|
254
|
-
would let the claim change without the identity changing (SN-5, SN-6)
|
|
261
|
+
would let the claim change without the identity changing (SN-5, SN-6);
|
|
262
|
+
* the claimed profile's content — the name alone let a profile be weakened, requirements
|
|
263
|
+
removed from it, without the identity of any snapshot claiming it changing. The digest is
|
|
264
|
+
taken over the profile's declaration (`profile_sha256`).
|
|
255
265
|
|
|
256
266
|
Provenance is excluded and that exclusion is declared in the manifest's `identity_covers`.
|
|
257
267
|
It is observational content (`3e` §5): `assembled_at` differs between two assemblies of identical
|
|
@@ -259,7 +269,8 @@ def compute_snapshot_identity(domains: list[dict], constituents: list[dict], pro
|
|
|
259
269
|
SN-6. What SN-6 requires covered is the description's determinative content, which this is.
|
|
260
270
|
"""
|
|
261
271
|
payload = json.dumps(
|
|
262
|
-
{"domains": _identity_view(domains), "constituents": constituents, "profile": profile
|
|
272
|
+
{"domains": _identity_view(domains), "constituents": constituents, "profile": profile,
|
|
273
|
+
"profile_sha256": profile_sha256},
|
|
263
274
|
sort_keys=True, separators=(",", ":"),
|
|
264
275
|
)
|
|
265
276
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
@@ -326,6 +337,19 @@ def _profile_declaration(identity: str) -> dict[str, Any]:
|
|
|
326
337
|
f"{root} — a claim nobody can read is not a claim (3b SN-7)")
|
|
327
338
|
|
|
328
339
|
|
|
340
|
+
def profile_digest(profile: str) -> str:
|
|
341
|
+
"""The digest of the claimed profile's declaration, as the identity covers it.
|
|
342
|
+
|
|
343
|
+
Taken over the `snapshot_profile` block in canonical form rather than over the file: what the
|
|
344
|
+
profile requires is its declaration, and a change to the prose around it changes nothing a
|
|
345
|
+
snapshot is checked against.
|
|
346
|
+
"""
|
|
347
|
+
declared = _profile_declaration(profile)
|
|
348
|
+
return hashlib.sha256(
|
|
349
|
+
json.dumps(declared, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
|
350
|
+
).hexdigest()
|
|
351
|
+
|
|
352
|
+
|
|
329
353
|
def verify_profile(out_root: Path, profile: str) -> list[str]:
|
|
330
354
|
"""What the claimed profile requires and the composition does not carry.
|
|
331
355
|
|
|
@@ -515,7 +539,8 @@ def assemble(source_roots: list[Path], out_root: Path, profile: str) -> dict[str
|
|
|
515
539
|
# `3b` §6 requires all five, with the enumeration total. It was four per domain; six top-level
|
|
516
540
|
# constituents were carried and enumerated nowhere, which under §6 is undeclared content.
|
|
517
541
|
constituents = enumerate_constituents(out_root)
|
|
518
|
-
|
|
542
|
+
sealed_profile = profile_digest(profile)
|
|
543
|
+
snapshot_id = compute_snapshot_identity(domains, constituents, profile, sealed_profile)
|
|
519
544
|
manifest = {
|
|
520
545
|
"manifest_version": MANIFEST_VERSION,
|
|
521
546
|
"snapshot_id": snapshot_id,
|
|
@@ -523,11 +548,14 @@ def assemble(source_roots: list[Path], out_root: Path, profile: str) -> dict[str
|
|
|
523
548
|
# What the identity is taken over, declared so a party who did not build this snapshot can
|
|
524
549
|
# recompute it without reading the assembler (AI-16). Provenance is excluded as observational
|
|
525
550
|
# content (`3e` §5) — see compute_snapshot_identity.
|
|
526
|
-
"identity_covers": ["domains", "constituents", "profile"],
|
|
551
|
+
"identity_covers": ["domains", "constituents", "profile", "profile_sha256"],
|
|
527
552
|
# Written after sealing and therefore outside what was sealed. Declared rather than silently
|
|
528
553
|
# skipped, so a party recomputing the identity knows exactly what to exclude (AI-16).
|
|
529
554
|
"post_seal": list(POST_SEAL),
|
|
530
555
|
"profile": profile,
|
|
556
|
+
# What the claimed profile declared when this snapshot was sealed. Acceptance recomputes it
|
|
557
|
+
# from the profile as it now reads and refuses a difference.
|
|
558
|
+
"profile_sha256": sealed_profile,
|
|
531
559
|
"domains": domains,
|
|
532
560
|
"constituents": constituents,
|
|
533
561
|
"provenance": _build_provenance(inputs, domains),
|
|
@@ -547,7 +575,9 @@ def assemble(source_roots: list[Path], out_root: Path, profile: str) -> dict[str
|
|
|
547
575
|
# Kinds a domain build instantiates. MUST match the compiler's _inject_imported_governance
|
|
548
576
|
# filter (compiler/stages/s1_extract.py): a governance invariant is imported into a domain iff
|
|
549
577
|
# its applies_to_kinds intersects this set and it declares no layer/surface scope.
|
|
550
|
-
|
|
578
|
+
# TEST_DATA joined when transform vectors moved into the supplying domain's build
|
|
579
|
+
# (conformance::CONSTITUTION_TEST_DATA_V2).
|
|
580
|
+
_DOMAIN_INSTANTIATED = frozenset({"WF", "CC", "CS", "CT", "RB", "AC", "IN", "EV", "TI", "TE", "TEST_DATA"})
|
|
551
581
|
|
|
552
582
|
|
|
553
583
|
def _recompute_governance_closure(out_root: Path, source_domain: str) -> tuple[str, int]:
|
|
@@ -675,7 +705,8 @@ def verify_snapshot(out_root: Path) -> dict[str, Any]:
|
|
|
675
705
|
3. identity — recomputed from the RECOMPUTED constituent hashes, compared to the identity
|
|
676
706
|
borne. Recomputing over recorded hashes detects a tampered manifest; only
|
|
677
707
|
recomputing over recomputed hashes detects a tampered constituent
|
|
678
|
-
4. profile — the snapshot claims one
|
|
708
|
+
4. profile — the snapshot claims one, and the profile it claims is the one it was sealed
|
|
709
|
+
against: its declaration digests to the value the identity covers
|
|
679
710
|
|
|
680
711
|
This previously compared recorded values to recorded values — manifest against metadata.json
|
|
681
712
|
against attestation — which is transitive from construction and establishes nothing to a party
|
|
@@ -741,7 +772,20 @@ def verify_snapshot(out_root: Path) -> dict[str, Any]:
|
|
|
741
772
|
+ "; ".join(unmet[:5])
|
|
742
773
|
+ (f"; and {len(unmet) - 5} more" if len(unmet) > 5 else ""))
|
|
743
774
|
|
|
744
|
-
|
|
775
|
+
# The profile read now, not the digest the manifest carries: recomputing over the recorded value
|
|
776
|
+
# would accept a profile weakened after sealing, which is what covering its content is for.
|
|
777
|
+
sealed_profile = manifest.get("profile_sha256")
|
|
778
|
+
if not sealed_profile:
|
|
779
|
+
raise AssemblyError(
|
|
780
|
+
"manifest carries no profile_sha256 — the profile's content is not in the identity, so "
|
|
781
|
+
"a weakened profile could not be told from the one this snapshot was sealed against.")
|
|
782
|
+
current_profile = profile_digest(profile)
|
|
783
|
+
if current_profile != sealed_profile:
|
|
784
|
+
raise AssemblyError(
|
|
785
|
+
f"profile failure: {profile} now digests to {current_profile[:16]}…, and this snapshot "
|
|
786
|
+
f"was sealed against {sealed_profile[:16]}…. The profile it claims has changed since.")
|
|
787
|
+
|
|
788
|
+
derived = compute_snapshot_identity(domains, recomputed, profile, current_profile)
|
|
745
789
|
borne = manifest.get("snapshot_id")
|
|
746
790
|
if derived != borne:
|
|
747
791
|
raise AssemblyError(
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Snapshot authentication — a signature over a sealed snapshot's identity.
|
|
2
|
+
|
|
3
|
+
Three things are kept apart here, and conflating any two of them defeats the purpose:
|
|
4
|
+
|
|
5
|
+
snapshot identity `snapshot_id`, derived from what was sealed
|
|
6
|
+
snapshot authentication a signature over that identity
|
|
7
|
+
trust anchor a public key the verifying party holds, from outside the snapshot
|
|
8
|
+
|
|
9
|
+
Verification is `Verify(K, I, sigma)` where **K comes from the node and never from the
|
|
10
|
+
artifact**. A snapshot carrying the key that authenticates it is self-authenticating, which
|
|
11
|
+
establishes nothing: whoever replaced the snapshot would replace the key with it. The
|
|
12
|
+
`key_id` in a signature record exists only so a refusal can say *which* failure occurred —
|
|
13
|
+
signed by a key this node does not anchor, versus signed badly. It MUST NOT be used to
|
|
14
|
+
select a key.
|
|
15
|
+
|
|
16
|
+
**What is signed is the identity, not the manifest.** The self-description sits outside the
|
|
17
|
+
identity it declares, so signing the file would bind the signature to bytes the identity does
|
|
18
|
+
not cover. And the record is a sidecar rather than a manifest field, because a sealed
|
|
19
|
+
snapshot's manifest is written once and never rewritten.
|
|
20
|
+
|
|
21
|
+
**Asymmetric by requirement, not by preference.** A profile requiring that a node able to
|
|
22
|
+
verify is unable to sign rules out every shared-secret construction, whatever its strength.
|
|
23
|
+
|
|
24
|
+
The dependency is an extra (`snapshot_assembler[signing]`) rather than a core requirement.
|
|
25
|
+
Signing is something a profile selects; a platform whose profile does not select it should
|
|
26
|
+
not acquire a cryptography dependency because the capability exists elsewhere. Absent the
|
|
27
|
+
extra, this module refuses — it never degrades to an unsigned success.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
SIGNATURE_FILENAME = "signature.json"
|
|
40
|
+
SIGNATURE_VERSION = "V0"
|
|
41
|
+
ALGORITHM = "ed25519"
|
|
42
|
+
|
|
43
|
+
# Fields recorded beside the signature that the signature does not cover. `signed_at` says
|
|
44
|
+
# when signing happened; it is an accompanying fact, and covering it would make an identical
|
|
45
|
+
# snapshot produce a different record on every run.
|
|
46
|
+
ACCOMPANYING = ("signed_at",)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SigningUnavailable(RuntimeError):
|
|
50
|
+
"""The signing extra is not installed. Never a reason to proceed unsigned."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SignatureError(RuntimeError):
|
|
54
|
+
"""Base for every refusal this module issues."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SignatureAbsent(SignatureError):
|
|
58
|
+
"""The snapshot carries no signature and the verifying party requires one."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SignatureMalformed(SignatureError):
|
|
62
|
+
"""A signature record exists and cannot be read as one."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TrustRootMismatch(SignatureError):
|
|
66
|
+
"""Signed by a key this party does not anchor. Distinct from a bad signature."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SignatureInvalid(SignatureError):
|
|
70
|
+
"""Anchored by the right key and the signature does not verify."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IdentityMismatch(SignatureError):
|
|
74
|
+
"""The signature authenticates an identity the snapshot does not claim."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ed25519():
|
|
78
|
+
"""The primitives, or a refusal naming what to install.
|
|
79
|
+
|
|
80
|
+
Imported here rather than at module scope so that reading this module, and importing the
|
|
81
|
+
assembler at all, costs nothing when signing is not in use.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
from cryptography.hazmat.primitives import serialization
|
|
85
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
86
|
+
except ImportError as exc: # pragma: no cover - exercised by absence, not by tests
|
|
87
|
+
raise SigningUnavailable(
|
|
88
|
+
"snapshot signing requires the 'signing' extra: "
|
|
89
|
+
"pip install 'snapshot-assembler[signing]'"
|
|
90
|
+
) from exc
|
|
91
|
+
return serialization, ed25519
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def key_id(public_key_bytes: bytes) -> str:
|
|
95
|
+
"""A short, stable name for a public key.
|
|
96
|
+
|
|
97
|
+
Identifies which anchor a record was produced under so that a refusal can distinguish a
|
|
98
|
+
key this party does not hold from a signature that does not verify. It is a label, never
|
|
99
|
+
a credential.
|
|
100
|
+
"""
|
|
101
|
+
return hashlib.sha256(public_key_bytes).hexdigest()[:16]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def key_id_from_public_pem(public_pem: bytes) -> str:
|
|
105
|
+
"""The `key_id` of a PEM-encoded public key, for reporting at mint time."""
|
|
106
|
+
serialization, _ = _ed25519()
|
|
107
|
+
return key_id(_raw_public(serialization.load_pem_public_key(public_pem)))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def generate_keypair() -> tuple[bytes, bytes]:
|
|
111
|
+
"""A new Ed25519 pair as (private_pem, public_pem).
|
|
112
|
+
|
|
113
|
+
The private half belongs on the machine that seals snapshots and on no machine that
|
|
114
|
+
executes them: a node able to sign can mint a snapshot its peers would accept.
|
|
115
|
+
"""
|
|
116
|
+
serialization, ed25519 = _ed25519()
|
|
117
|
+
private = ed25519.Ed25519PrivateKey.generate()
|
|
118
|
+
private_pem = private.private_bytes(
|
|
119
|
+
encoding=serialization.Encoding.PEM,
|
|
120
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
121
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
122
|
+
)
|
|
123
|
+
public_pem = private.public_key().public_bytes(
|
|
124
|
+
encoding=serialization.Encoding.PEM,
|
|
125
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
126
|
+
)
|
|
127
|
+
return private_pem, public_pem
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _raw_public(public_key) -> bytes:
|
|
131
|
+
serialization, _ = _ed25519()
|
|
132
|
+
return public_key.public_bytes(
|
|
133
|
+
encoding=serialization.Encoding.Raw,
|
|
134
|
+
format=serialization.PublicFormat.Raw,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def sign_identity(snapshot_id: str, private_key_path: Path) -> dict[str, Any]:
|
|
139
|
+
"""Produce the signature record for a sealed snapshot's identity."""
|
|
140
|
+
serialization, _ = _ed25519()
|
|
141
|
+
private = serialization.load_pem_private_key(
|
|
142
|
+
Path(private_key_path).read_bytes(), password=None
|
|
143
|
+
)
|
|
144
|
+
signature = private.sign(snapshot_id.encode("utf-8"))
|
|
145
|
+
return {
|
|
146
|
+
"signature_version": SIGNATURE_VERSION,
|
|
147
|
+
"snapshot_id": snapshot_id,
|
|
148
|
+
"algorithm": ALGORITHM,
|
|
149
|
+
"key_id": key_id(_raw_public(private.public_key())),
|
|
150
|
+
"signature": base64.b64encode(signature).decode("ascii"),
|
|
151
|
+
"signed_at": datetime.now(timezone.utc).isoformat(),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def write_signature(out_root: Path, record: dict[str, Any]) -> Path:
|
|
156
|
+
"""Write the sidecar beside the manifest, leaving the manifest untouched."""
|
|
157
|
+
path = Path(out_root) / SIGNATURE_FILENAME
|
|
158
|
+
path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
159
|
+
return path
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def read_signature(out_root: Path) -> dict[str, Any] | None:
|
|
163
|
+
"""The signature record, or None where the snapshot carries none."""
|
|
164
|
+
path = Path(out_root) / SIGNATURE_FILENAME
|
|
165
|
+
if not path.exists():
|
|
166
|
+
return None
|
|
167
|
+
try:
|
|
168
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
169
|
+
except json.JSONDecodeError as exc:
|
|
170
|
+
raise SignatureMalformed(f"{path} is not readable as JSON: {exc}") from exc
|
|
171
|
+
if not isinstance(record, dict):
|
|
172
|
+
raise SignatureMalformed(f"{path} does not carry a signature record")
|
|
173
|
+
return record
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def verify_identity(snapshot_id: str, record: dict[str, Any], public_key_path: Path) -> str:
|
|
177
|
+
"""Verify a signature over a claimed identity against a party-supplied anchor.
|
|
178
|
+
|
|
179
|
+
Returns the `key_id` of the anchor on success. Raises one of this module's refusals
|
|
180
|
+
otherwise, each naming which of the four failures occurred: the record authenticates a
|
|
181
|
+
different identity, it was produced under a key this party does not anchor, the record is
|
|
182
|
+
unreadable, or it does not verify.
|
|
183
|
+
|
|
184
|
+
The anchor is the caller's. `record["key_id"]` is read only to report a mismatch.
|
|
185
|
+
"""
|
|
186
|
+
serialization, ed25519 = _ed25519()
|
|
187
|
+
|
|
188
|
+
for field in ("snapshot_id", "algorithm", "signature"):
|
|
189
|
+
if field not in record:
|
|
190
|
+
raise SignatureMalformed(f"signature record carries no {field!r}")
|
|
191
|
+
|
|
192
|
+
if record["algorithm"] != ALGORITHM:
|
|
193
|
+
raise SignatureMalformed(
|
|
194
|
+
f"signature algorithm is {record['algorithm']!r}; this verifier knows {ALGORITHM!r}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Checked before the cryptography: a record that verifies over a different identity is
|
|
198
|
+
# valid and irrelevant, and reporting it as a bad signature would name the wrong fault.
|
|
199
|
+
if record["snapshot_id"] != snapshot_id:
|
|
200
|
+
raise IdentityMismatch(
|
|
201
|
+
f"signature authenticates {record['snapshot_id'][:16]}… and this snapshot claims "
|
|
202
|
+
f"{snapshot_id[:16]}…"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
public = serialization.load_pem_public_key(Path(public_key_path).read_bytes())
|
|
206
|
+
if not isinstance(public, ed25519.Ed25519PublicKey):
|
|
207
|
+
raise SignatureMalformed(f"{public_key_path} is not an Ed25519 public key")
|
|
208
|
+
anchor = key_id(_raw_public(public))
|
|
209
|
+
|
|
210
|
+
borne = record.get("key_id")
|
|
211
|
+
if borne and borne != anchor:
|
|
212
|
+
raise TrustRootMismatch(
|
|
213
|
+
f"signed under key {borne} and this party anchors {anchor} — the signature may be "
|
|
214
|
+
f"sound and is not one this party accepts"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
signature = base64.b64decode(record["signature"], validate=True)
|
|
219
|
+
except (ValueError, TypeError) as exc:
|
|
220
|
+
raise SignatureMalformed(f"signature is not valid base64: {exc}") from exc
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
public.verify(signature, snapshot_id.encode("utf-8"))
|
|
224
|
+
except Exception as exc:
|
|
225
|
+
raise SignatureInvalid(
|
|
226
|
+
f"signature over {snapshot_id[:16]}… does not verify under key {anchor}"
|
|
227
|
+
) from exc
|
|
228
|
+
|
|
229
|
+
return anchor
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgc-assembler
|
|
3
|
-
Version:
|
|
3
|
+
Version: 5.0.0
|
|
4
4
|
Summary: PGC snapshot assembler — composes compiled projections into a manifest-pinned executable snapshot (import package: assembler)
|
|
5
5
|
Author-email: Bhash Ganti <bachipeachy@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -21,6 +21,8 @@ Description-Content-Type: text/markdown
|
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
License-File: NOTICE
|
|
23
23
|
Requires-Dist: pyyaml
|
|
24
|
+
Provides-Extra: signing
|
|
25
|
+
Requires-Dist: cryptography>=42; extra == "signing"
|
|
24
26
|
Dynamic: license-file
|
|
25
27
|
|
|
26
28
|
# snapshot_assembler
|
|
@@ -40,8 +42,10 @@ repos → protocol_compiler → each repo's compiled/ projections
|
|
|
40
42
|
```
|
|
41
43
|
|
|
42
44
|
The platform is an ordinary member of the composition — no singleton branch. The composition
|
|
43
|
-
currently assembles **
|
|
44
|
-
and the business domains `ai_governance`, `blockchain` and
|
|
45
|
+
currently assembles **eight domains**: `platform`, `workload`, `inspection`, `transformation`,
|
|
46
|
+
and the business domains `ai_governance`, `blockchain`, `book_library_mgmt` and
|
|
47
|
+
`causal_language_model`. A deliberately narrower composition is assembled by naming its roots in
|
|
48
|
+
`PGC_SOURCE_ROOTS`.
|
|
45
49
|
|
|
46
50
|
## Install
|
|
47
51
|
|
|
@@ -134,13 +138,13 @@ pgc # reports what is installed and whether the anchor resolves
|
|
|
134
138
|
|
|
135
139
|
`PGC_DOMAIN_ROOTS` names an additional domain contributing its own `registry/structures` — the
|
|
136
140
|
directory that *directly contains* it, not the repository above it; pointing one level too high is a
|
|
137
|
-
silent no-op.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
silent no-op. Where compiled output is written is declared by each build configuration
|
|
142
|
+
(`output_configuration.root`), not by the environment. `PGC_SNAPSHOT_PROFILES` is the directory
|
|
143
|
+
holding snapshot profiles, required by the assembler and the runtime alike. A snapshot's identity
|
|
144
|
+
covers the content of the profile it claims, so the profiles a node reads must be the ones it was
|
|
145
|
+
sealed against.
|
|
141
146
|
|
|
142
|
-
`PGC_BUILD_ROOT` is accepted and reported and **nothing reads it
|
|
143
|
-
anchor that controls output.
|
|
147
|
+
`PGC_BUILD_ROOT` is accepted and reported and **nothing reads it**.
|
|
144
148
|
|
|
145
149
|
The full sequence, with the repositories it needs, is in
|
|
146
150
|
[`pgc_install`](https://github.com/protocol-governed-computing/pgc_install).
|
|
@@ -5,7 +5,7 @@ backend-path = ["."]
|
|
|
5
5
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "pgc-assembler"
|
|
8
|
-
version = "
|
|
8
|
+
version = "5.0.0"
|
|
9
9
|
description = "PGC snapshot assembler — composes compiled projections into a manifest-pinned executable snapshot (import package: assembler)"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.10"
|
|
@@ -30,6 +30,13 @@ classifiers = [
|
|
|
30
30
|
# install without this raises ModuleNotFoundError there instead of refusing.
|
|
31
31
|
dependencies = ["pyyaml"]
|
|
32
32
|
|
|
33
|
+
# Snapshot signing is a profile's selection, not a property of the core. A platform whose
|
|
34
|
+
# profile requires authenticated snapshots installs this; one that does not should not
|
|
35
|
+
# acquire a cryptography dependency because the capability exists elsewhere. Absent it,
|
|
36
|
+
# assembler.signing refuses rather than degrading to an unsigned success.
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
signing = ["cryptography>=42"]
|
|
39
|
+
|
|
33
40
|
[project.scripts]
|
|
34
41
|
snapshot_assembler = "assembler.cli:main"
|
|
35
42
|
|
pgc_assembler-4.0.0/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
16
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
16
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
pyyaml
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|