matrixscroll 0.2.4__tar.gz → 0.2.5__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 (97) hide show
  1. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/CHANGELOG.md +14 -0
  2. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/CONTRIBUTING.md +20 -1
  3. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/PKG-INFO +3 -1
  4. matrixscroll-0.2.5/docs/CI_HARD_GATE.md +49 -0
  5. matrixscroll-0.2.5/docs/hardware-provider.md +38 -0
  6. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/rekor-guac-bridge-design.md +1 -1
  7. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/yubikey-bridge.md +14 -0
  8. matrixscroll-0.2.5/docs/yubikey-smoke.md +35 -0
  9. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/__init__.py +1 -1
  10. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/cli.py +54 -0
  11. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/git.py +58 -17
  12. matrixscroll-0.2.5/matrixscroll/guac_export.py +78 -0
  13. matrixscroll-0.2.5/matrixscroll/manifest.py +101 -0
  14. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/providers/base.py +6 -0
  15. matrixscroll-0.2.5/matrixscroll/providers/hardware.py +69 -0
  16. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/providers/registry.py +4 -0
  17. matrixscroll-0.2.5/matrixscroll/providers/se050_transport.py +67 -0
  18. matrixscroll-0.2.5/matrixscroll/providers/tpm.py +144 -0
  19. matrixscroll-0.2.5/matrixscroll/providers/yubikey.py +199 -0
  20. matrixscroll-0.2.5/matrixscroll/rekor_publish.py +131 -0
  21. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/pyproject.toml +3 -2
  22. matrixscroll-0.2.5/tests/fixtures/github-gpgsig-commit.txt +26 -0
  23. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_core.py +1 -1
  24. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_git_envelope.py +41 -0
  25. matrixscroll-0.2.5/tests/test_guac_rekor.py +75 -0
  26. matrixscroll-0.2.5/tests/test_se050_transport.py +49 -0
  27. matrixscroll-0.2.5/tests/test_tpm_provider.py +30 -0
  28. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_yubikey_provider.py +24 -1
  29. matrixscroll-0.2.4/docs/hardware-provider.md +0 -20
  30. matrixscroll-0.2.4/matrixscroll/manifest.py +0 -59
  31. matrixscroll-0.2.4/matrixscroll/providers/hardware.py +0 -22
  32. matrixscroll-0.2.4/matrixscroll/providers/yubikey.py +0 -104
  33. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/.gitignore +0 -0
  34. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/LICENSE +0 -0
  35. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/README.md +0 -0
  36. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/SECURITY.md +0 -0
  37. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/SPEC.md +0 -0
  38. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/controls/agentic_ai_controls.json +0 -0
  39. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/AGENTIC_AI_SECURITY.md +0 -0
  40. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/COMPARISON.md +0 -0
  41. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/WHITEPAPER.md +0 -0
  42. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/delegation-attestation-rfc.md +0 -0
  43. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/quickstart-claude-code.md +0 -0
  44. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/quickstart-copilot.md +0 -0
  45. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/quickstart-cursor.md +0 -0
  46. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/quickstart-git.md +0 -0
  47. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/superpowers/plans/2026-06-19-ci-action-plan.md +0 -0
  48. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/superpowers/plans/2026-06-19-sdk-refactor-plan.md +0 -0
  49. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/superpowers/specs/2026-06-19-matrixscroll-git-design.md +0 -0
  50. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/agentic_ai_evidence_manifest.json +0 -0
  51. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/agentic_ai_evidence_manifest.signed.json +0 -0
  52. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/ci/protected-branch.yml +0 -0
  53. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/commit-envelope.json +0 -0
  54. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/commit-envelope.signed.json +0 -0
  55. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/demo/agent-commit-demo.sh +0 -0
  56. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/demo/generate_signed_examples.py +0 -0
  57. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/release-manifest.json +0 -0
  58. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/release-manifest.signed.json +0 -0
  59. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/trusted-keys-policy.json +0 -0
  60. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/_core.py +0 -0
  61. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/canonical.py +0 -0
  62. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/constants.py +0 -0
  63. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/errors.py +0 -0
  64. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/gate.py +0 -0
  65. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/hooks/post-commit +0 -0
  66. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/hooks/pre-push +0 -0
  67. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/policy.py +0 -0
  68. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/providers/__init__.py +0 -0
  69. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/providers/emulated.py +0 -0
  70. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/py.typed +0 -0
  71. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/schemas/commit-envelope.v1.json +0 -0
  72. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/schemas/evidence-pack.v1.json +0 -0
  73. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/schemas/release-manifest.v1.json +0 -0
  74. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/__init__.py +0 -0
  75. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_agentic_guidance.py +0 -0
  76. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_canonical.py +0 -0
  77. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_cli.py +0 -0
  78. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_gate.py +0 -0
  79. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_policy.py +0 -0
  80. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_release_metadata.py +0 -0
  81. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_vectors.py +0 -0
  82. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/README.md +0 -0
  83. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/_fixture_key.json +0 -0
  84. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/_generate.py +0 -0
  85. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_algorithm.json +0 -0
  86. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_device_id.json +0 -0
  87. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_field.json +0 -0
  88. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_nested.json +0 -0
  89. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_public_key.json +0 -0
  90. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_schema.json +0 -0
  91. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_signature.json +0 -0
  92. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/unsigned_empty_block.json +0 -0
  93. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/unsigned_no_block.json +0 -0
  94. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/valid_commit_envelope.json +0 -0
  95. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/valid_nested.json +0 -0
  96. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/valid_simple.json +0 -0
  97. {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/valid_unicode.json +0 -0
@@ -4,6 +4,20 @@ All notable changes to the Matrix Scroll Python SDK are documented here. The
4
4
  format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
5
5
  this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.2.5] - 2026-06-24
8
+
9
+ Post-HN patch: GitHub gpgsig commit binding, SE050 mock transport, YubiKey pubkey export, Rekor/GUAC CLI MVP.
10
+
11
+ ### Added
12
+ - **GitHub `gpgsig` commit binding** — verify envelopes against raw commit object SHA when OpenPGP signatures are present.
13
+ - **SE050 mock transport** — `MATRIXSCROLL_SE050_MOCK=1` enables `HardwareProvider` for development (`providers/se050_transport.py`).
14
+ - **YubiKey public key export** — PKCS#11 EC public key read path (mock + real token).
15
+ - **GUAC export CLI** — `matrixscroll envelope-export-guac --bundle DIR --output guac.jsonl`.
16
+ - **Rekor publish stub** — `matrixscroll envelope-publish-rekor --bundle DIR` (dry-run artifacts; optional `--rekor-cli`).
17
+
18
+ ### Fixed
19
+ - **`parse_commit()` / envelope verify** on GitHub-signed commits with multi-line `gpgsig` headers (Windows CRLF safe).
20
+
7
21
  ## [0.2.4] - 2026-06-20
8
22
 
9
23
  Who-Acted gate productization: attribution policy, delegation schema, CI hardening.
@@ -29,6 +29,25 @@ pip install -e ".[dev]"
29
29
  pytest -q
30
30
  ```
31
31
 
32
+ ## Commit envelope provenance (Scroll Gate)
33
+
34
+ For PRs that include agent-assisted commits, publish signed commit envelopes to git notes:
35
+
36
+ ```bash
37
+ matrixscroll hook-install
38
+ export MATRIXSCROLL_ACTOR_TYPE=agent # or human / ci as appropriate
39
+ export MATRIXSCROLL_TOOL=cursor # or your IDE / agent tool name
40
+ git commit -m "feat: your change"
41
+ matrixscroll envelope-publish-notes --base origin/main --head HEAD
42
+ git push origin refs/notes/matrixscroll
43
+ ```
44
+
45
+ CI verifies the PR range with `matrixscroll envelope-verify-range --source notes`.
46
+ Until repository variable `ENFORCE_PROVENANCE=true` is set, missing notes produce a **warning**
47
+ in the GitHub Step Summary rather than a hard failure.
48
+
49
+ Optional policy file for release branches: [`.github/trusted-keys.json`](.github/trusted-keys.json).
50
+
32
51
  ## Running the conformance vectors
33
52
 
34
53
  ```bash
@@ -42,7 +61,7 @@ vectors are welcome.
42
61
 
43
62
  ## Pull request checklist
44
63
 
45
- - [ ] Tests pass locally (`pytest -q`).
64
+ - [ ] For agent-assisted commits: publish envelope git notes (`envelope-publish-notes` + push `refs/notes/matrixscroll`).
46
65
  - [ ] New public API has a docstring and is re-exported from
47
66
  `matrixscroll/__init__.py` if it's part of the supported surface.
48
67
  - [ ] `CHANGELOG.md` updated with a dated version entry (see Keep a Changelog format).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: matrixscroll
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Open protocol for signing AI-assisted code provenance with Ed25519; shipping software root of trust with SSX360 hardware support in progress.
5
5
  Project-URL: Homepage, https://matrixscroll.com
6
6
  Project-URL: Documentation, https://matrixscroll.com/docs
@@ -32,7 +32,9 @@ Provides-Extra: dev
32
32
  Requires-Dist: build>=1.0; extra == 'dev'
33
33
  Requires-Dist: pytest>=7.4; extra == 'dev'
34
34
  Provides-Extra: git
35
+ Provides-Extra: tpm
35
36
  Provides-Extra: yubikey
37
+ Requires-Dist: python-pkcs11>=0.7; extra == 'yubikey'
36
38
  Description-Content-Type: text/markdown
37
39
 
38
40
  # Matrix Scroll
@@ -0,0 +1,49 @@
1
+ # CI hard gate — Scroll Gate Phase C
2
+
3
+ Enable required provenance on `SSX360/matrixscroll` after contributors can publish git notes.
4
+
5
+ ## 1. Repository variable
6
+
7
+ ```bash
8
+ gh variable set ENFORCE_PROVENANCE --body true --repo SSX360/matrixscroll
9
+ ```
10
+
11
+ When set, [`verify-manifest.yml`](.github/workflows/verify-manifest.yml) **fails** PRs missing `refs/notes/matrixscroll` envelopes.
12
+
13
+ ## 2. Branch protection
14
+
15
+ In GitHub → Settings → Branches → `main`:
16
+
17
+ - Require status check: **Verify Matrix Scroll manifest**
18
+ - Require branches up to date before merging
19
+
20
+ ```bash
21
+ gh api repos/SSX360/matrixscroll/branches/main/protection \
22
+ --method PUT \
23
+ -f required_status_checks[strict]=true \
24
+ -f required_status_checks[checks][][context]='Verify Matrix Scroll manifest' \
25
+ -f enforce_admins=false \
26
+ -f required_pull_request_reviews[required_approving_review_count]=0
27
+ ```
28
+
29
+ Adjust review count to match your team policy.
30
+
31
+ ## 3. Contributor flow
32
+
33
+ Every PR must:
34
+
35
+ ```bash
36
+ matrixscroll envelope-publish-notes --base origin/main --head HEAD
37
+ git push origin refs/notes/matrixscroll
38
+ matrixscroll envelope-verify-range --base origin/main --head HEAD --source notes
39
+ ```
40
+
41
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
42
+
43
+ ## Rollback
44
+
45
+ ```bash
46
+ gh variable set ENFORCE_PROVENANCE --body false --repo SSX360/matrixscroll
47
+ ```
48
+
49
+ Soft gate resumes (warning only in Step Summary).
@@ -0,0 +1,38 @@
1
+ # SSX360 hardware provider (L2)
2
+
3
+ **Status:** Mock transport available — set `MATRIXSCROLL_SE050_MOCK=1` for development.
4
+ Real I2C firmware ships when Pico 2 + OM-SE050ARD-E kits are on the bench.
5
+
6
+ ## Quickstart (mock)
7
+
8
+ ```powershell
9
+ $env:MATRIXSCROLL_MODE = "hardware"
10
+ $env:MATRIXSCROLL_SE050_MOCK = "1"
11
+ matrixscroll status
12
+ ```
13
+
14
+ ## Planned behavior
15
+
16
+ - `MATRIXSCROLL_MODE=hardware` selects the secure-element provider
17
+ - Private keys never leave the SE050; no seed on disk
18
+ - User-presence touch gating for protected-branch commits
19
+ - Compatible with the same manifest and commit-envelope schemas as L1 emulated mode
20
+
21
+ ## Environment variables
22
+
23
+ | Variable | Purpose |
24
+ |----------|---------|
25
+ | `MATRIXSCROLL_MODE=hardware` | Select hardware provider |
26
+ | `MATRIXSCROLL_SE050_MOCK=1` | Use in-process Ed25519 mock transport (dev/CI) |
27
+
28
+ Firmware scaffold: [`launch/firmware/ssx360-se050/README.md`](../../launch/firmware/ssx360-se050/README.md)
29
+
30
+ ## Related docs
31
+
32
+ - [`docs/yubikey-bridge.md`](yubikey-bridge.md) — bridge path before SSX360 GA
33
+ - [`launch/reference-architecture-se050-rp2350.md`](../../launch/reference-architecture-se050-rp2350.md)
34
+ - [`SPEC.md`](../SPEC.md) — wire format (unchanged for L2 signing algorithm)
35
+
36
+ ## Device
37
+
38
+ Reference hardware: [matrixscroll.com/device](https://matrixscroll.com/device)
@@ -1,6 +1,6 @@
1
1
  # Rekor / GUAC envelope bridge (design)
2
2
 
3
- > Status: design RFC for Phase 3. Not implemented.
3
+ > Status: MVP shipped in v0.2.5 (`envelope-export-guac`, `envelope-publish-rekor` dry-run).
4
4
 
5
5
  ## Goal
6
6
 
@@ -123,6 +123,20 @@ Add to SPEC.md when PIV bridge ships:
123
123
 
124
124
  Conformance vectors required before enabling in release CI.
125
125
 
126
+ ## device_id derivation
127
+
128
+ YubiKey envelopes use the same `MS-XXXX-YYYY` format as emulated mode:
129
+
130
+ ```
131
+ device_id = "MS-" + sha256(public_key_der)[:8] uppercased with hyphen at position 4
132
+ ```
133
+
134
+ Implementation: [`matrixscroll/providers/emulated.py`](../matrixscroll/providers/emulated.py) `device_id()`.
135
+
136
+ Public key bytes come from PKCS#11 EC key export (or mock P-256 key when `MATRIXSCROLL_YKCS11_MOCK=1`).
137
+
138
+ Manual smoke test: [`docs/yubikey-smoke.md`](yubikey-smoke.md)
139
+
126
140
  ## Test plan
127
141
 
128
142
  1. Unit: mock PKCS#11 session returns fixed signature → verify path
@@ -0,0 +1,35 @@
1
+ # YubiKey manual smoke test
2
+
3
+ Requires a YubiKey 5 with PIV EC key and [Yubico PKCS#11 module](https://www.yubico.com/support/download/yubikey-manager/) installed.
4
+
5
+ ## Linux / macOS
6
+
7
+ ```bash
8
+ export MATRIXSCROLL_MODE=yubikey
9
+ export MATRIXSCROLL_YKCS11_MODULE=/usr/lib/x86_64-linux-gnu/libykcs11.so # adjust path
10
+ export MATRIXSCROLL_PIV_PIN='your-pin'
11
+
12
+ matrixscroll status
13
+ matrixscroll sign vectors/valid_simple.json
14
+ matrixscroll verify vectors/valid_simple.json
15
+ ```
16
+
17
+ ## Windows
18
+
19
+ ```powershell
20
+ $env:MATRIXSCROLL_MODE = "yubikey"
21
+ $env:MATRIXSCROLL_YKCS11_MODULE = "C:\Program Files\Yubico\Yubico PIV Tool\bin\ykcs11.dll"
22
+ $env:MATRIXSCROLL_PIV_PIN = "your-pin"
23
+
24
+ matrixscroll status
25
+ ```
26
+
27
+ ## Mock (no hardware)
28
+
29
+ ```powershell
30
+ $env:MATRIXSCROLL_MODE = "yubikey"
31
+ $env:MATRIXSCROLL_YKCS11_MOCK = "1"
32
+ python -m pytest tests/test_yubikey_provider.py -q
33
+ ```
34
+
35
+ Expected: `algorithm: ecdsa-p256`, `mode: yubikey`, verify passes.
@@ -38,7 +38,7 @@ from ._core import (
38
38
  verify_manifest,
39
39
  )
40
40
 
41
- __version__ = "0.2.4"
41
+ __version__ = "0.2.5"
42
42
 
43
43
  __all__ = [
44
44
  "ALGORITHM",
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import argparse
16
16
  import json
17
+ import subprocess
17
18
  import sys
18
19
  from pathlib import Path
19
20
 
@@ -168,6 +169,35 @@ def _cmd_sign(args: argparse.Namespace) -> int:
168
169
  return 0
169
170
 
170
171
 
172
+ def _cmd_envelope_export_guac(args: argparse.Namespace) -> int:
173
+ from . import guac_export as guac_mod
174
+
175
+ try:
176
+ result = guac_mod.export_guac_jsonl(Path(args.bundle), Path(args.output))
177
+ except (RuntimeError, FileNotFoundError, ValueError) as exc:
178
+ print(json.dumps({"ok": False, "error": str(exc)}))
179
+ return 1
180
+ print(json.dumps(result, indent=2, sort_keys=True))
181
+ return 0 if result.get("ok") else 2
182
+
183
+
184
+ def _cmd_envelope_publish_rekor(args: argparse.Namespace) -> int:
185
+ from . import rekor_publish as rekor_mod
186
+
187
+ bundle = Path(args.bundle)
188
+ try:
189
+ if args.rekor_cli:
190
+ result = rekor_mod.publish_rekor_cli(bundle, rekor_url=args.rekor_url or None)
191
+ else:
192
+ out = Path(args.output) if args.output else bundle / ".rekor-dry-run"
193
+ result = rekor_mod.publish_rekor_dry_run(bundle, out)
194
+ except (RuntimeError, FileNotFoundError, subprocess.CalledProcessError) as exc:
195
+ print(json.dumps({"ok": False, "error": str(exc)}))
196
+ return 1
197
+ print(json.dumps(result, indent=2, sort_keys=True))
198
+ return 0 if result.get("ok") else 2
199
+
200
+
171
201
  def _cmd_envelope_export(args: argparse.Namespace) -> int:
172
202
  if _gate is None:
173
203
  print(json.dumps({"ok": False, "error": "gate module unavailable"}))
@@ -357,6 +387,28 @@ def build_parser() -> argparse.ArgumentParser:
357
387
  _add_policy_args(env_verify_range)
358
388
  env_verify_range.set_defaults(command="envelope-verify-range")
359
389
 
390
+ guac_export = sub.add_parser(
391
+ "envelope-export-guac",
392
+ help="Export verified envelopes from a bundle to GUAC JSONL",
393
+ )
394
+ guac_export.add_argument("--bundle", required=True, help="Envelope bundle directory")
395
+ guac_export.add_argument("--output", "-o", required=True, help="Output JSONL path")
396
+ guac_export.set_defaults(command="envelope-export-guac")
397
+
398
+ rekor_pub = sub.add_parser(
399
+ "envelope-publish-rekor",
400
+ help="Publish envelopes to Rekor (dry-run by default)",
401
+ )
402
+ rekor_pub.add_argument("--bundle", required=True, help="Envelope bundle directory")
403
+ rekor_pub.add_argument("--output", "-o", default="", help="Dry-run output directory")
404
+ rekor_pub.add_argument(
405
+ "--rekor-cli",
406
+ action="store_true",
407
+ help="Upload via rekor-cli (requires REKOR_URL)",
408
+ )
409
+ rekor_pub.add_argument("--rekor-url", default="", help="Rekor server URL override")
410
+ rekor_pub.set_defaults(command="envelope-publish-rekor")
411
+
360
412
  return parser
361
413
 
362
414
 
@@ -377,6 +429,8 @@ def main(argv: list[str] | None = None) -> int:
377
429
  "envelope-publish-notes": _cmd_envelope_publish_notes,
378
430
  "envelope-fetch-notes": _cmd_envelope_fetch_notes,
379
431
  "envelope-verify-range": _cmd_envelope_verify_range,
432
+ "envelope-export-guac": _cmd_envelope_export_guac,
433
+ "envelope-publish-rekor": _cmd_envelope_publish_rekor,
380
434
  }
381
435
  handler = handlers.get(args.command)
382
436
  if handler is None:
@@ -34,6 +34,24 @@ def _run_git(*args: str, cwd: Path | None = None, strip: bool = True) -> str:
34
34
  return result.stdout
35
35
 
36
36
 
37
+ def _run_git_bytes(*args: str, cwd: Path | None = None) -> bytes:
38
+ result = subprocess.run(
39
+ ["git", *args],
40
+ capture_output=True,
41
+ check=False,
42
+ cwd=cwd,
43
+ )
44
+ if result.returncode != 0:
45
+ err = (result.stderr or result.stdout or b"").decode("utf-8", errors="replace").strip()
46
+ raise RuntimeError(err or "git command failed")
47
+ return result.stdout
48
+
49
+
50
+ def _commit_object_sha(raw: bytes) -> str:
51
+ wrapped = b"commit " + str(len(raw)).encode("ascii") + b"\0" + raw
52
+ return hashlib.sha1(wrapped, usedforsecurity=False).hexdigest()
53
+
54
+
37
55
  def repo_root() -> Path:
38
56
  return Path(_run_git("rev-parse", "--show-toplevel"))
39
57
 
@@ -127,15 +145,19 @@ def _parse_identity_line(line: str) -> dict[str, str]:
127
145
  return {"name": name, "email": email, "date": f"{timestamp} {tz}"}
128
146
 
129
147
 
130
- def parse_commit(sha: str, root: Path | None = None) -> dict[str, Any]:
131
- root = root or repo_root()
132
- commit_sha = _run_git("rev-parse", sha, cwd=root)
133
- raw = _run_git("cat-file", "commit", commit_sha, cwd=root, strip=False)
148
+ def _commit_headers_and_body(raw: str) -> tuple[str, list[str], dict[str, str], dict[str, str], str]:
149
+ """Parse a raw commit object, including multi-line ``gpgsig`` headers."""
150
+ lines = raw.split("\n")
134
151
  tree = ""
135
152
  parents: list[str] = []
136
153
  author: dict[str, str] | None = None
137
154
  committer: dict[str, str] | None = None
138
- for line in raw.splitlines():
155
+ i = 0
156
+ while i < len(lines):
157
+ line = lines[i]
158
+ if line == "":
159
+ i += 1
160
+ break
139
161
  if line.startswith("tree "):
140
162
  tree = line.split(" ", 1)[1]
141
163
  elif line.startswith("parent "):
@@ -144,18 +166,37 @@ def parse_commit(sha: str, root: Path | None = None) -> dict[str, Any]:
144
166
  author = _parse_identity_line(line)
145
167
  elif line.startswith("committer "):
146
168
  committer = _parse_identity_line(line)
147
- if not tree or author is None or committer is None or "\n\n" not in raw:
148
- raise RuntimeError(f"unexpected commit object for {sha}")
149
- body = raw.split("\n\n", 1)[1]
150
- actual_id = compute_commit_id(
151
- tree=tree,
152
- parents=parents,
153
- author=author,
154
- committer=committer,
155
- message=body,
156
- )
157
- if actual_id != commit_sha:
158
- raise RuntimeError(f"commit id mismatch for {sha}")
169
+ elif line.startswith("gpgsig "):
170
+ i += 1
171
+ while i < len(lines) and lines[i].startswith(" "):
172
+ i += 1
173
+ continue
174
+ i += 1
175
+ if not tree or author is None or committer is None:
176
+ raise RuntimeError("unexpected commit object headers")
177
+ body = "\n".join(lines[i:])
178
+ return tree, parents, author, committer, body
179
+
180
+
181
+ def parse_commit(sha: str, root: Path | None = None) -> dict[str, Any]:
182
+ root = root or repo_root()
183
+ commit_sha = _run_git("rev-parse", sha, cwd=root)
184
+ raw_bytes = _run_git_bytes("cat-file", "commit", commit_sha, cwd=root)
185
+ if _commit_object_sha(raw_bytes) != commit_sha:
186
+ raise RuntimeError(f"commit object hash mismatch for {sha}")
187
+ raw = raw_bytes.decode("utf-8").replace("\r\n", "\n")
188
+ tree, parents, author, committer, body = _commit_headers_and_body(raw)
189
+ has_gpgsig = any(line.startswith("gpgsig ") for line in raw.split("\n"))
190
+ if not has_gpgsig:
191
+ actual_id = compute_commit_id(
192
+ tree=tree,
193
+ parents=parents,
194
+ author=author,
195
+ committer=committer,
196
+ message=body,
197
+ )
198
+ if actual_id != commit_sha:
199
+ raise RuntimeError(f"commit id mismatch for {sha}")
159
200
  return {
160
201
  "actual_id": commit_sha,
161
202
  "tree": tree,
@@ -0,0 +1,78 @@
1
+ """Export verified commit envelopes to GUAC-compatible JSONL (Phase 3 MVP)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .gate import BUNDLE_INDEX, verify_commit_envelope_for_sha
10
+ from .git import _repository_info, repo_root
11
+
12
+ PREDICATE_TYPE = "https://matrixscroll.com/attestation/commit-envelope/v1"
13
+
14
+
15
+ def _load_bundle_index(bundle_dir: Path) -> dict[str, Any]:
16
+ index_path = bundle_dir / BUNDLE_INDEX
17
+ if not index_path.is_file():
18
+ raise FileNotFoundError(f"bundle index missing: {index_path}")
19
+ return json.loads(index_path.read_text(encoding="utf-8-sig"))
20
+
21
+
22
+ def envelope_to_guac_statement(
23
+ envelope: dict[str, Any],
24
+ *,
25
+ sha: str,
26
+ repo_name: str,
27
+ ) -> dict[str, Any]:
28
+ """Build one in-toto / GUAC-adjacent attestation object."""
29
+ return {
30
+ "_type": "https://in-toto.io/Statement/v1",
31
+ "predicateType": PREDICATE_TYPE,
32
+ "subject": [{"name": f"{repo_name}@{sha}", "digest": {"sha1": sha}}],
33
+ "predicate": envelope,
34
+ }
35
+
36
+
37
+ def export_guac_jsonl(
38
+ bundle_dir: Path,
39
+ output: Path,
40
+ *,
41
+ root: Path | None = None,
42
+ ) -> dict[str, Any]:
43
+ """Verify each envelope in a bundle and write GUAC-ingestible JSONL."""
44
+ root = root or repo_root()
45
+ index = _load_bundle_index(bundle_dir)
46
+ repo_info = index.get("repository") or _repository_info(root)
47
+ repo_name = str(repo_info.get("remote_url") or repo_info.get("root") or "unknown")
48
+
49
+ commits = index.get("commits") or []
50
+ exported: list[str] = []
51
+ skipped: list[dict[str, str]] = []
52
+
53
+ lines: list[str] = []
54
+ for sha in commits:
55
+ path = bundle_dir / f"{sha}.json"
56
+ if not path.is_file():
57
+ skipped.append({"sha": sha, "error": "envelope file missing"})
58
+ continue
59
+ envelope = json.loads(path.read_text(encoding="utf-8-sig"))
60
+ result = verify_commit_envelope_for_sha(envelope, sha, root=root)
61
+ if not result.ok:
62
+ skipped.append({"sha": sha, "error": result.error or "verification failed"})
63
+ continue
64
+ statement = envelope_to_guac_statement(envelope, sha=sha, repo_name=repo_name)
65
+ lines.append(json.dumps(statement, sort_keys=True, separators=(",", ":")))
66
+ exported.append(sha)
67
+
68
+ output.parent.mkdir(parents=True, exist_ok=True)
69
+ output.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
70
+
71
+ return {
72
+ "ok": len(skipped) == 0,
73
+ "schema": "matrixscroll.guac_export.v1",
74
+ "output": str(output),
75
+ "exported": len(exported),
76
+ "skipped": skipped,
77
+ "commits": exported,
78
+ }
@@ -0,0 +1,101 @@
1
+ """Manifest signing and verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import copy
8
+ import hashlib
9
+ import time
10
+ from typing import Any
11
+
12
+ from cryptography.exceptions import InvalidSignature
13
+ from cryptography.hazmat.primitives import hashes
14
+ from cryptography.hazmat.primitives.asymmetric import ec
15
+ from cryptography.hazmat.primitives.asymmetric.utils import Prehashed
16
+
17
+ from .canonical import canonical_bytes
18
+ from .constants import ALGORITHM, SIGNATURE_SCHEMA
19
+ from .providers.emulated import device_id
20
+ from .providers.registry import get_provider, identity_info, verify
21
+
22
+
23
+ def _provider_algorithm(provider) -> str:
24
+ return getattr(provider, "algorithm", ALGORITHM)
25
+
26
+
27
+ def sign_manifest(
28
+ manifest: dict[str, Any], provider=None
29
+ ) -> dict[str, Any]:
30
+ provider = provider or get_provider()
31
+ info = identity_info(provider)
32
+ signed = copy.deepcopy(manifest)
33
+ signed.pop("signature", None)
34
+ canonical = canonical_bytes(signed)
35
+ signing_input = provider.signing_input(canonical) if hasattr(provider, "signing_input") else canonical
36
+ signature_value = base64.b64encode(provider.sign(signing_input)).decode("ascii")
37
+ algorithm = _provider_algorithm(provider)
38
+ block: dict[str, Any] = {
39
+ "schema": SIGNATURE_SCHEMA,
40
+ "algorithm": algorithm,
41
+ "device_id": info["device_id"],
42
+ "public_key": info["public_key"],
43
+ "mode": info["mode"],
44
+ "signed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
45
+ "value": signature_value,
46
+ }
47
+ digest = getattr(provider, "digest", None)
48
+ if digest:
49
+ block["digest"] = digest
50
+ signed["signature"] = block
51
+ return signed
52
+
53
+
54
+ def _verify_ecdsa_p256(public_key: bytes, data: bytes, signature: bytes, digest: str | None) -> bool:
55
+ try:
56
+ from cryptography.hazmat.primitives.serialization import load_der_public_key
57
+
58
+ key = load_der_public_key(public_key)
59
+ if not isinstance(key, ec.EllipticCurvePublicKey):
60
+ return False
61
+ payload = hashlib.sha256(data).digest() if digest == "sha256" else data
62
+ key.verify(signature, payload, ec.ECDSA(Prehashed(hashes.SHA256())))
63
+ return True
64
+ except (InvalidSignature, ValueError, TypeError):
65
+ return False
66
+
67
+
68
+ def verify_manifest(manifest: dict[str, Any]) -> bool:
69
+ if not isinstance(manifest, dict):
70
+ return False
71
+ block = manifest.get("signature")
72
+ if not isinstance(block, dict):
73
+ return False
74
+ if block.get("schema") != SIGNATURE_SCHEMA:
75
+ return False
76
+ algorithm = block.get("algorithm", ALGORITHM)
77
+ public_key = block.get("public_key")
78
+ signature = block.get("value")
79
+ if not isinstance(public_key, str) or not isinstance(signature, str):
80
+ return False
81
+ try:
82
+ public_key_bytes = base64.b64decode(public_key.encode("ascii"), validate=True)
83
+ signature_bytes = base64.b64decode(signature.encode("ascii"), validate=True)
84
+ except (ValueError, binascii.Error):
85
+ return False
86
+ if block.get("device_id") != device_id(public_key_bytes):
87
+ return False
88
+ try:
89
+ signing_input = canonical_bytes(manifest)
90
+ except (TypeError, ValueError):
91
+ return False
92
+ if algorithm == "ecdsa-p256":
93
+ return _verify_ecdsa_p256(
94
+ public_key_bytes,
95
+ signing_input,
96
+ signature_bytes,
97
+ block.get("digest") if isinstance(block.get("digest"), str) else "sha256",
98
+ )
99
+ if algorithm != ALGORITHM:
100
+ return False
101
+ return verify(public_key_bytes, signing_input, signature_bytes)
@@ -9,6 +9,8 @@ class IdentityProvider(ABC):
9
9
  """A root-of-trust provider. Signing happens here; keys never escape."""
10
10
 
11
11
  mode: str = "unknown"
12
+ algorithm: str = "ed25519"
13
+ digest: str | None = None
12
14
 
13
15
  @abstractmethod
14
16
  def public_key_bytes(self) -> bytes:
@@ -18,6 +20,10 @@ class IdentityProvider(ABC):
18
20
  def sign(self, data: bytes) -> bytes:
19
21
  ...
20
22
 
23
+ def signing_input(self, canonical: bytes) -> bytes:
24
+ """Transform canonical manifest bytes before signing."""
25
+ return canonical
26
+
21
27
  @property
22
28
  def created_at(self) -> str:
23
29
  return ""