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.
Files changed (25) hide show
  1. {pgc_assembler-4.0.0/pgc_assembler.egg-info → pgc_assembler-5.0.0}/PKG-INFO +13 -9
  2. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/README.md +10 -8
  3. pgc_assembler-5.0.0/VERSION +1 -0
  4. pgc_assembler-5.0.0/assembler/VERSION +1 -0
  5. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/cli.py +67 -1
  6. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/core.py +55 -11
  7. pgc_assembler-5.0.0/assembler/signing.py +229 -0
  8. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0/pgc_assembler.egg-info}/PKG-INFO +13 -9
  9. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/SOURCES.txt +1 -0
  10. pgc_assembler-5.0.0/pgc_assembler.egg-info/requires.txt +4 -0
  11. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pyproject.toml +8 -1
  12. pgc_assembler-4.0.0/VERSION +0 -1
  13. pgc_assembler-4.0.0/assembler/VERSION +0 -1
  14. pgc_assembler-4.0.0/pgc_assembler.egg-info/requires.txt +0 -1
  15. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/LICENSE +0 -0
  16. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/MANIFEST.in +0 -0
  17. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/NOTICE +0 -0
  18. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/_build_hook.py +0 -0
  19. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/__init__.py +0 -0
  20. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/conformance.py +0 -0
  21. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/assembler/indexes.py +0 -0
  22. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/dependency_links.txt +0 -0
  23. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/entry_points.txt +0 -0
  24. {pgc_assembler-4.0.0 → pgc_assembler-5.0.0}/pgc_assembler.egg-info/top_level.txt +0 -0
  25. {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: 4.0.0
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 **seven domains**: `platform`, `workload`, `inspection`, `transformation`,
44
- and the business domains `ai_governance`, `blockchain` and `book_library_mgmt`.
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. `PGC_SNAPSHOT_ROOT` is where compiled output is written, and each domain build needs
138
- its own: every layer's output consolidates into one root, and verification rejects any file in that
139
- root the current build did not declare. `PGC_SNAPSHOT_PROFILES` is the directory holding snapshot
140
- profiles, required by the assembler and the runtime alike.
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** — `PGC_SNAPSHOT_ROOT` is the
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 **seven domains**: `platform`, `workload`, `inspection`, `transformation`,
19
- and the business domains `ai_governance`, `blockchain` and `book_library_mgmt`.
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. `PGC_SNAPSHOT_ROOT` is where compiled output is written, and each domain build needs
113
- its own: every layer's output consolidates into one root, and verification rejects any file in that
114
- root the current build did not declare. `PGC_SNAPSHOT_PROFILES` is the directory holding snapshot
115
- profiles, required by the assembler and the runtime alike.
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** — `PGC_SNAPSHOT_ROOT` is the
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
- POST_SEAL = ("conformance/",)
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::CONSTITUTION_CRYPTOGRAPHIC_TRUST_V0`. The projection binding and the value
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) -> 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
- Three inputs, and each is there for a stated reason:
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
- snapshot_id = compute_snapshot_identity(domains, constituents, profile)
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
- _DOMAIN_INSTANTIATED = frozenset({"WF", "CC", "CS", "CT", "RB", "AC", "IN", "EV", "TI", "TE"})
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
- derived = compute_snapshot_identity(domains, recomputed, profile)
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: 4.0.0
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 **seven domains**: `platform`, `workload`, `inspection`, `transformation`,
44
- and the business domains `ai_governance`, `blockchain` and `book_library_mgmt`.
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. `PGC_SNAPSHOT_ROOT` is where compiled output is written, and each domain build needs
138
- its own: every layer's output consolidates into one root, and verification rejects any file in that
139
- root the current build did not declare. `PGC_SNAPSHOT_PROFILES` is the directory holding snapshot
140
- profiles, required by the assembler and the runtime alike.
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** — `PGC_SNAPSHOT_ROOT` is the
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).
@@ -11,6 +11,7 @@ assembler/cli.py
11
11
  assembler/conformance.py
12
12
  assembler/core.py
13
13
  assembler/indexes.py
14
+ assembler/signing.py
14
15
  pgc_assembler.egg-info/PKG-INFO
15
16
  pgc_assembler.egg-info/SOURCES.txt
16
17
  pgc_assembler.egg-info/dependency_links.txt
@@ -0,0 +1,4 @@
1
+ pyyaml
2
+
3
+ [signing]
4
+ cryptography>=42
@@ -5,7 +5,7 @@ backend-path = ["."]
5
5
 
6
6
  [project]
7
7
  name = "pgc-assembler"
8
- version = "4.0.0"
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
 
@@ -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