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.
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/CHANGELOG.md +14 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/CONTRIBUTING.md +20 -1
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/PKG-INFO +3 -1
- matrixscroll-0.2.5/docs/CI_HARD_GATE.md +49 -0
- matrixscroll-0.2.5/docs/hardware-provider.md +38 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/rekor-guac-bridge-design.md +1 -1
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/yubikey-bridge.md +14 -0
- matrixscroll-0.2.5/docs/yubikey-smoke.md +35 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/__init__.py +1 -1
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/cli.py +54 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/git.py +58 -17
- matrixscroll-0.2.5/matrixscroll/guac_export.py +78 -0
- matrixscroll-0.2.5/matrixscroll/manifest.py +101 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/providers/base.py +6 -0
- matrixscroll-0.2.5/matrixscroll/providers/hardware.py +69 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/providers/registry.py +4 -0
- matrixscroll-0.2.5/matrixscroll/providers/se050_transport.py +67 -0
- matrixscroll-0.2.5/matrixscroll/providers/tpm.py +144 -0
- matrixscroll-0.2.5/matrixscroll/providers/yubikey.py +199 -0
- matrixscroll-0.2.5/matrixscroll/rekor_publish.py +131 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/pyproject.toml +3 -2
- matrixscroll-0.2.5/tests/fixtures/github-gpgsig-commit.txt +26 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_core.py +1 -1
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_git_envelope.py +41 -0
- matrixscroll-0.2.5/tests/test_guac_rekor.py +75 -0
- matrixscroll-0.2.5/tests/test_se050_transport.py +49 -0
- matrixscroll-0.2.5/tests/test_tpm_provider.py +30 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_yubikey_provider.py +24 -1
- matrixscroll-0.2.4/docs/hardware-provider.md +0 -20
- matrixscroll-0.2.4/matrixscroll/manifest.py +0 -59
- matrixscroll-0.2.4/matrixscroll/providers/hardware.py +0 -22
- matrixscroll-0.2.4/matrixscroll/providers/yubikey.py +0 -104
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/.gitignore +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/LICENSE +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/README.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/SECURITY.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/SPEC.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/controls/agentic_ai_controls.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/AGENTIC_AI_SECURITY.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/COMPARISON.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/WHITEPAPER.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/delegation-attestation-rfc.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/quickstart-claude-code.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/quickstart-copilot.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/quickstart-cursor.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/quickstart-git.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/superpowers/plans/2026-06-19-ci-action-plan.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/superpowers/plans/2026-06-19-sdk-refactor-plan.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/docs/superpowers/specs/2026-06-19-matrixscroll-git-design.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/agentic_ai_evidence_manifest.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/agentic_ai_evidence_manifest.signed.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/ci/protected-branch.yml +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/commit-envelope.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/commit-envelope.signed.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/demo/agent-commit-demo.sh +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/demo/generate_signed_examples.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/release-manifest.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/release-manifest.signed.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/examples/trusted-keys-policy.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/_core.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/canonical.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/constants.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/errors.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/gate.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/hooks/post-commit +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/hooks/pre-push +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/policy.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/providers/__init__.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/providers/emulated.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/matrixscroll/py.typed +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/schemas/commit-envelope.v1.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/schemas/evidence-pack.v1.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/schemas/release-manifest.v1.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/__init__.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_agentic_guidance.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_canonical.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_cli.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_gate.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_policy.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_release_metadata.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/tests/test_vectors.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/README.md +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/_fixture_key.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/_generate.py +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_algorithm.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_device_id.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_field.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_nested.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_public_key.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_schema.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/tampered_signature.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/unsigned_empty_block.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/unsigned_no_block.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/valid_commit_envelope.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/valid_nested.json +0 -0
- {matrixscroll-0.2.4 → matrixscroll-0.2.5}/vectors/valid_simple.json +0 -0
- {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
|
-
- [ ]
|
|
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.
|
|
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)
|
|
@@ -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.
|
|
@@ -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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 ""
|